Skip to main content

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​

TagMeaning
{{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 indexon 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​

FieldExample
page"index" or "video"; also isIndex / isVideo booleans
lang"en"
assetsHref./assets/
site.titleSources
site.descriptionone line under the heading (may be empty)
site.channel.nameExample Channel
site.channel.urlhttps://www.youtube.com/@<your-handle> (may be empty)
site.channel.handle@<your-handle> (may be empty)
site.channel.avatarUrlimage URL (may be empty)
site.links[]{label, url} — the channel's other links
site.accenta CSS colour the owner chose, or empty: honour it when set (--accent: {{site.accent}})
site.videoCount, site.sourceCountnumbers, across the whole site
site.updatedDisplay26 Oct 2026 — the newest build
generator.name, generator.urlSrcNexus, https://srcnexus.com — a small "Made with" line

Index page only (index.html)​

Field
videos[]newest first (see Video below)
hasVideosfalse 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
searchJsonthe index search data, for {{{searchJson}}}

Video page only (video.html)​

Field
videothe 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, nextthe neighbouring videos ({title, href}) or empty — prev is older, next is newer
searchJsonthe video search data, for scripts that prefer data to the DOM

Video​

Field
idthe file name — how-tides-work
titlealways set
href./<id>/ — on the index only
youtubeUrl, youtubeIdhttps://www.youtube.com/watch?v=… and the 11-character id; empty without a youtube link
thumbnailUrlthe video's thumbnail, else its YouTube thumbnail (hqdefault); may be empty
publishedISO date (2026-10-20) or empty
publishedDisplay20 Oct 2026
year2026, or empty
descriptionmay be empty
sourceCount, sourceCountLabel21 and 21 sources (1 source)

Source​

Field
n1-based number, in film order across sections
title, urlalways set
domainen.wikipedia.org → wikipedia.org (no www./en.)
publisher, author, date, notemay be empty
dateDisplaydate as 6 Jan 2023 when it is YYYY-MM-DD, otherwise as written
kind, kindLabelarticle paper book video archive data gov other → Article … Government …
at, atUrlthe moment in the video it supports (4:35) and a YouTube link there; may be empty
archiveUrla web.archive.org copy, may be empty
searchTextlower-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, publisher and domain. Show or hide the video's element (give it a data-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 from kinds[] and domains[].
  • 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's id) 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.