Custom sources templates
A template turns your sources files into the site: one index page listing every video, and one video page per video listing its sources. If none of the built-in templates fits, make your own — it is two HTML files with Mustache tags, plus any CSS and JavaScript you like.
Start from a built-in one
srcnexus sources eject plain ./my-template
This copies the built-in plain template (any id works: compact, cards, table, sidebar, document) into ./my-template. Then point the site at it in srcnexus.sources.yaml — the path is relative to that file:
template: ./my-template
A value with a slash, or starting with ., is a folder; anything else is a built-in name. The template's id (shown by check and build) is the folder's name.
Folder layout
my-template/
template.json {"name": "...", "description": "..."} (optional)
index.html the index page (Mustache)
video.html one page per video (Mustache)
assets/ optional — copied as-is to <site>/assets/
index.html and video.html are required. Everything under assets/ (sub-folders too; files starting with . are skipped) is copied to the site's assets/ and uploaded on every deploy.
Mustache basics
| Tag | Meaning |
|---|---|
{{x}} | the value, HTML-escaped |
{{{x}}} | the value, not escaped — only use it for searchJson |
{{#list}}…{{/list}} | repeat for each item of a list; for a value, show the block if it is set (not empty, 0 or false) |
{{^x}}…{{/x}} | show the block if x is empty, 0, false or an empty list — "if not" |
{{a.b}} | a nested field |
Inside {{#list}}, a name refers to the current item first, then to the page — so inside {{#videos}} the tag {{title}} is the video's title and {{site.title}} still works.
The escaping covers &, <, >, " and ', so {{x}} is safe in text and inside quoted attributes.
What a page may use
A page must be self-contained apart from assets/ and web fonts: no build step, no framework, no tracking. Search and filters are plain JavaScript in the page, reading the searchJson blob or data-* attributes.
Paths
The index is at <site>/, a video page at <site>/<video-id>/. Links between them are relative and given to you, so the site works under any project name and in preview:
| on the index | on a video page | |
|---|---|---|
| link to a video page | {{href}} of each video — ./<id>/ | {{href}} of prev / next — ../<id>/ |
| link to the index | — | {{indexHref}} — ../ |
| your assets | {{assetsHref}} — ./assets/ | {{assetsHref}} — ../assets/ |
<link rel="stylesheet" href="{{assetsHref}}style.css">
Images given as local files (a thumbnail or the channel avatar) are copied to <site>/media/; their thumbnailUrl / avatarUrl already point there with the right relative path.
Field reference
On both pages
| Field | Example |
|---|---|
page | "index" or "video"; also isIndex / isVideo booleans |
lang | "en" |
assetsHref | ./assets/ |
site.title | Sources |
site.description | one line under the heading (may be empty) |
site.channel.name | Example Channel |
site.channel.url | https://www.youtube.com/@<your-handle> (may be empty) |
site.channel.handle | @<your-handle> (may be empty) |
site.channel.avatarUrl | image URL (may be empty) |
site.links[] | {label, url} — the channel's other links |
site.accent | a CSS colour the owner chose, or empty: honour it when set (--accent: {{site.accent}}) |
site.videoCount, site.sourceCount | numbers, across the whole site |
site.updatedDisplay | 26 Oct 2026 — the newest build |
generator.name, generator.url | SrcNexus, https://srcnexus.com — a small "Made with" line |
Index page only (index.html)
| Field | |
|---|---|
videos[] | newest first (see Video below) |
hasVideos | false when there are none — show an empty state |
years[] | {year, count} newest first — for a year filter |
yearGroups[] | {year, videos[]} newest first, undated last as Undated — for year headings without JavaScript |
searchJson | the index search data, for {{{searchJson}}} |
Video page only (video.html)
| Field | |
|---|---|
video | the video (see Video below) plus video.sections[] |
video.sections[] | {heading, hasHeading, sources[]} — one section with hasHeading: false when the file is not grouped |
kinds[] | {kind, label, count} — for a type filter, most first |
domains[] | {domain, count} — for a website filter, most first |
indexHref | ../ |
prev, next | the neighbouring videos ({title, href}) or empty — prev is older, next is newer |
searchJson | the video search data, for scripts that prefer data to the DOM |
Video
| Field | |
|---|---|
id | the file name — how-tides-work |
title | always set |
href | ./<id>/ — on the index only |
youtubeUrl, youtubeId | https://www.youtube.com/watch?v=… and the 11-character id; empty without a youtube link |
thumbnailUrl | the video's thumbnail, else its YouTube thumbnail (hqdefault); may be empty |
published | ISO date (2026-10-20) or empty |
publishedDisplay | 20 Oct 2026 |
year | 2026, or empty |
description | may be empty |
sourceCount, sourceCountLabel | 21 and 21 sources (1 source) |
Source
| Field | |
|---|---|
n | 1-based number, in film order across sections |
title, url | always set |
domain | en.wikipedia.org → wikipedia.org (no www./en.) |
publisher, author, date, note | may be empty |
dateDisplay | date as 6 Jan 2023 when it is YYYY-MM-DD, otherwise as written |
kind, kindLabel | article paper book video archive data gov other → Article … Government … |
at, atUrl | the moment in the video it supports (4:35) and a YouTube link there; may be empty |
archiveUrl | a web.archive.org copy, may be empty |
searchText | lower-case title, publisher, author, domain, note — put it in data-search |
searchJson
Put it, unescaped, in a JSON script tag:
<script type="application/json" id="srcnexus-data">{{{searchJson}}}</script>
It is already made safe for that spot (<, > and & are escaped as < and so on), which is why triple braces are fine here and nowhere else.
On the index it lets the search look through every source of every video, not only titles:
{
"videos": [
{
"id": "molasses-flood",
"title": "The day a wave of molasses swept through Boston",
"href": "./molasses-flood/",
"youtubeId": "<video-id>",
"published": "2026-10-20",
"year": "2026",
"sourceCount": 11,
"sources": [
{ "title": "Boston Molasses Disaster", "url": "https://en.wikipedia.org/wiki/Boston_Molasses_Disaster",
"domain": "wikipedia.org", "kind": "article", "publisher": "Wikipedia" }
]
}
]
}
On a video page it is {"sources": [ … ]} — every source with all the Source fields.
How search and filters work
There is no search engine: the page's own JavaScript does it.
- Index — read
#srcnexus-data, and for each video test the query against its title and its sources'title,publisheranddomain. Show or hide the video's element (give it adata-id="{{id}}"to find it); the built-in templates also list which sources matched. - Video page — every source element carries
data-search="{{searchText}}",data-kind="{{kind}}"and a website attribute (data-site="{{domain}}"in the built-ins); the script filters on those. Build the filter buttons fromkinds[]anddomains[]. - Shareable links — the built-in templates keep the state in the address with
history.replaceState:?q=and?year=on the index,?q=,?kind=and?site=on a video page, and#s-{{n}}(the source'sid) to jump to one source. Read them on load and a filtered link opens filtered. Use the same names and links stay valid if you switch templates. - Without JavaScript everything must still be listed — filters simply do nothing. Render the full lists in HTML and only hide things from the script.
The built-in templates' assets/*.js are a complete, dependency-free implementation; eject one to borrow it.
Work on it with preview
srcnexus sources preview # your files, your template
srcnexus sources preview --sample --template ./my-template # the sample channel, your template
preview re-renders on every request — template files included — so save, reload the browser, and you see the change. A Mustache or data error is shown in the page. The sample channel is a good test bed: it has a video with sections, one with 120 sources, one with no thumbnail and a very long title, and one with no sources yet.
Then check and build:
srcnexus sources check # validates your files and renders every page with the template
srcnexus sources build --out ./_site # writes the site so you can inspect the HTML
On the command line, --template ./path is relative to where you run the command; in srcnexus.sources.yaml it is relative to that file.
A minimal working template
The smallest useful template: a list of videos with a search over every source, and each video's sources grouped by section. These exact files were rendered with srcnexus sources build --sample to check them.
minimal/
template.json
index.html
video.html
assets/style.css
template.json
{
"name": "Minimal",
"description": "The smallest working template: a list of videos, and each video's sources."
}
assets/style.css
body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }
a { color: var(--accent, #2563eb); }
.meta { color: #666; font-size: .9em; }
[hidden] { display: none !important; }
index.html
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{site.title}} · {{site.channel.name}}</title>
<link rel="stylesheet" href="{{assetsHref}}style.css">
{{#site.accent}}<style>:root { --accent: {{site.accent}}; }</style>{{/site.accent}}
</head>
<body>
<h1>{{site.title}}</h1>
{{#site.description}}<p>{{site.description}}</p>{{/site.description}}
<p class="meta">{{site.videoCount}} videos · {{site.sourceCount}} sources</p>
<input id="q" type="search" placeholder="Search videos and sources">
{{#hasVideos}}
<ul id="videos">
{{#videos}}
<li data-id="{{id}}">
<a href="{{href}}">{{title}}</a>
<span class="meta">{{publishedDisplay}} · {{sourceCountLabel}}</span>
</li>
{{/videos}}
</ul>
{{/hasVideos}}
{{^hasVideos}}<p>No videos yet.</p>{{/hasVideos}}
<footer class="meta">Made with <a href="{{generator.url}}">{{generator.name}}</a></footer>
<script type="application/json" id="srcnexus-data">{{{searchJson}}}</script>
<script>
// Hide every video whose title or sources do not match the search.
var data = JSON.parse(document.getElementById('srcnexus-data').textContent);
var q = document.getElementById('q');
q.addEventListener('input', function () {
var needle = q.value.trim().toLowerCase();
data.videos.forEach(function (v) {
var hay = [v.title].concat(v.sources.map(function (s) {
return s.title + ' ' + s.publisher + ' ' + s.domain;
})).join(' ').toLowerCase();
var li = document.querySelector('[data-id="' + v.id + '"]');
if (li) li.hidden = needle !== '' && hay.indexOf(needle) === -1;
});
});
</script>
</body>
</html>
video.html
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sources: {{video.title}}</title>
<link rel="stylesheet" href="{{assetsHref}}style.css">
{{#site.accent}}<style>:root { --accent: {{site.accent}}; }</style>{{/site.accent}}
</head>
<body>
<p><a href="{{indexHref}}">← All videos</a></p>
<h1>{{video.title}}</h1>
{{#video.thumbnailUrl}}
<a href="{{video.youtubeUrl}}"><img src="{{video.thumbnailUrl}}" alt="" width="480"></a>
{{/video.thumbnailUrl}}
<p class="meta">{{video.publishedDisplay}} · {{video.sourceCountLabel}}</p>
{{#video.description}}<p>{{video.description}}</p>{{/video.description}}
{{#video.sections}}
{{#hasHeading}}<h2>{{heading}}</h2>{{/hasHeading}}
<ol>
{{#sources}}
<li id="s-{{n}}" value="{{n}}" data-kind="{{kind}}" data-search="{{searchText}}">
<a href="{{url}}">{{title}}</a>
<span class="meta">{{kindLabel}} · {{domain}}{{#publisher}} · {{publisher}}{{/publisher}}</span>
{{#atUrl}}<a href="{{atUrl}}">at {{at}}</a>{{/atUrl}}
{{#note}}<div>{{note}}</div>{{/note}}
</li>
{{/sources}}
</ol>
{{/video.sections}}
{{^video.sourceCount}}<p>No sources yet.</p>{{/video.sourceCount}}
<nav class="meta">
{{#prev}}<a href="{{href}}">← Older: {{title}}</a>{{/prev}}
{{#next}}<a href="{{href}}">Newer: {{title}} →</a>{{/next}}
</nav>
</body>
</html>
Try it:
srcnexus sources preview --sample --template ./minimal
Checklist for a good template
The built-in templates are held to this; it is a good bar for yours.
- Looks designed on purpose: a clear point of view, real typography, good on a phone first (most visitors arrive from a YouTube description on mobile) and fine on a desktop.
- Index: a search box (filters videos by title and by any of their sources, showing which sources matched), a year filter, a result count, an empty state, a card or row per video with its thumbnail and source count.
- Video page: a back link, the video (thumbnail linking to YouTube), search over its sources, filters by kind and by website, sections when present, each source with its number, title (link), publisher/author/date, domain, note and timestamp link; keyboard focus visible;
/focuses search. - Light and dark (
prefers-color-scheme), WCAG AA contrast, no layout shift, works with JavaScript off (everything listed, filters just do nothing). - Long titles, 0 sources, 200 sources and a missing thumbnail all handled.