Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@ $RECYCLE.BIN/
Network Trash Folder
Temporary Items
.apdisk
.workongoing
node_modules/
57 changes: 57 additions & 0 deletions .pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Interleaved CMS configuration.
# Maps the on-disk content layout into editable collections shown in the admin UI.

content:
- name: posts
label: Posts
path: content/posts
type: collection
format: yaml-frontmatter
fields:
- { name: title, label: Title, type: string, required: true }
- { name: date, label: Date, type: date }
- { name: layout, label: Layout, type: string }
- { name: categories, label: Categories, type: string, list: true }
- { name: tags, label: Tags, type: string, list: true }
- { name: excerpt, label: Excerpt, type: text }
- { name: body, label: Body, type: rich-text }

- name: pages
label: Pages
path: content
type: collection
filename: "*.md"
exclude:
- posts/**
format: yaml-frontmatter
fields:
- { name: title, label: Title, type: string, required: true }
- { name: layout, label: Layout, type: string }
- { name: permalink, label: Permalink, type: string }
- { name: excerpt, label: Excerpt, type: text }
- { name: body, label: Body, type: rich-text }

- name: site
label: Site settings
path: data/site.toml
type: file
format: toml

- name: authors
label: Authors
path: data/authors.toml
type: file
format: toml

- name: tokens
label: Site tokens
path: data/tokens.toml
type: file
format: toml

media:
- name: images
label: Images
input: static/images
output: /images
extensions: [jpg, jpeg, png, gif, svg, webp, avif]
120 changes: 120 additions & 0 deletions PORT-NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Jekyll → Interleaved port notes

This site was originally a Jekyll project with the HTML5 UP "Spectral" theme.
Migrated to [Interleaved](https://interleaved.app) on 2026-04-29.

## Layout

| Old (Jekyll) | New (Interleaved) |
|-----------------------------|--------------------------------|
| `_layouts/` | `templates/` (non-prefixed) |
| `_includes/` | `templates/_*.html` partials |
| `_pages/*.md` | `content/*.md` |
| `_posts/*.md` | `content/posts/*.md` |
| `_data/*.yml` | `data/*.toml` |
| `_config.yml` | `data/site.toml` |
| `_sass/`, `css/`, `js/` etc | `static/{sass,css,js,...}` |
| `index.md` | `content/index.md` |
| `.pages.yml` | new — Interleaved admin config |

Files are still browseable in the original `_*` directories for reference;
they no longer feed the build. Drop them once you're confident the port is good.

## Template engine

Jekyll uses Liquid → Interleaved uses Liquid (LiquidJS). Most syntax is
identical. Three changes:

- Layout chaining (`---layout: default---` in `post.html` referencing
`default.html`) doesn't exist in vanilla Liquid. Each layout now inlines
the wrapper. The duplicated head/header/footer blocks are kept DRY via
`{% include "head" %}`-style partials.
- Jekyll's `{% include foo.html with bar="baz" %}` argument syntax isn't
used here — partials inherit the parent context.
- Frontmatter format: prefer TOML (`+++…+++`) for new pages and posts.
YAML frontmatter (`---…---`) still works on existing files; auto-detected
by delimiter.

## Filter changes

| Jekyll filter | Interleaved equivalent |
|--------------------|----------------------------------|
| `markdownify` | `markdownify` (same name) |
| `strip_html` | `striphtml` (rename) |
| `escape` | LiquidJS built-in `escape` |
| `escape_once` | not provided — use `escape` |
| `strip_newlines` | not provided — use `replace` |
| `truncatewords` | `excerpt` (rename + word-aware) |
| `truncate` | `truncate` (same) |
| `date: "%Y"` | `formatDate`/`year` filters |
| `prepend`/`append` | LiquidJS built-ins (unchanged) |
| `sort` | LiquidJS built-in OR our `sortBy` (sortBy supports field+direction) |
| `slice: 0,2` | LiquidJS built-in `slice` |

## Gaps — Jekyll features not yet supported by Interleaved

These were used by the original site and dropped or stubbed during port.
Each one is a candidate enhancement to Interleaved itself.

1. ~~**`site.posts`, `site.pages`, `site.tags.X`, `site.categories.X` as
globals available in every render.**~~ **CLOSED** — Interleaved now
pre-scans content frontmatter and exposes Jekyll-style collections to
every render. Use `{{ site.posts | sortBy: 'date', 'desc' }}`,
`{{ site.categories.works }}`, `{{ site.tags.featured }}` directly in
templates. The landing page's "recent posts" block now uses this. Header
menu still uses `site.nav` (curated) instead of auto-deriving from
`site.pages` — that's a stylistic choice; either works now.

2. **`site.time`** — current build time. Used in the footer copyright. Replaced
here with a hard-coded `site.this_year` field in `site.toml`. A
`now` filter or a build-time-injected `site.now` would close this gap.

3. **`paginator`** — Jekyll's pagination object. Not used heavily here, but
the works/topics navigation (`_includes/fn_sortednav.html`,
`_includes/fn_groupsort_reverse.html`) does prev/next within sorted
subcollections. Those includes weren't ported; navigation between
works/topics is currently flat.

4. **Categories-as-collections** — Jekyll auto-creates `site.categories.works`,
`site.categories.topics` etc. from frontmatter. Used to organise
content/posts/* into nested URLs. The frontmatter still has
`categories: [works]` etc. but the directory hierarchy isn't auto-derived.

5. **`{% capture x %}{% include foo.md %}{% endcapture %}{{ x | markdownify }}`**
pattern — works as-is in LiquidJS. The home/platforms partials are
ported as `templates/_home.md` and `templates/_platforms.md` and
included this way in `templates/index.html`.

6. **`jekyll.environment`** — production/development gate around analytics.
Replaced with a `site.production = true` toggle in `site.toml`. Set per
deployment.

7. **Excerpt auto-generation** — Jekyll auto-extracts the first paragraph
of a post as `page.excerpt`. Frontmatter `excerpt:` works as before;
auto-extraction would need a small renderer enhancement.

8. **`compose.rb`, `site.bat`** — local Jekyll build/compose scripts. Not
ported. Use `npx tsx scripts/build-site.ts --src . --out _site` from
the Interleaved repo to build, or edit through the Interleaved admin
UI.

9. **Disqus comments** — `_includes/disqus.html` was Jekyll-only and
environment-gated. Not ported. Add back as a partial if comments are
desired.

10. **Sitemap + RSS feed generation** — `feed.xml` and `sitemap.xml` were
copied to `static/` verbatim from the original repo. They reference
`site.posts` and won't auto-update. Static site generators that want
these typically use a build-time plugin; Interleaved doesn't have one
yet.

## What works today

- Building via `npx tsx scripts/build-site.ts --src ~/work/imageflow-web-interleaved --out _site`
- Editing through Interleaved admin (read `.pages.yml` for the schema)
- All static pages (`benchmarks`, `licensing`, `credits`, etc.)
- All blog posts under `content/posts/`
- Header navigation (curated from `site.nav`)
- Footer with social icons (driven by `site.social`)
- Landing page with greeting + platforms + recent posts (last block
uses Interleaved's collection rendering)
179 changes: 179 additions & 0 deletions build/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env npx tsx
/**
* Static site generator for Interleaved.
*
* Reads content (markdown + JSON/TOML), templates (Liquid .html / .liquid),
* and data (.json / .toml) from a source directory, renders everything,
* and writes to _site/.
*
* Usage:
* npx tsx scripts/build-site.ts [--src ./my-site] [--out ./_site]
*
* Directory structure expected:
* templates/ — Liquid .html / .liquid files (base, post, index, ...)
* content/ — Markdown and JSON/TOML content files
* data/ — Global JSON/TOML data files (site.json, nav.toml, etc.)
* static/ — Copied as-is to output
*/

import fs from "fs";
import path from "path";
import { SiteRenderer } from "./lib/renderer";
import { parse as parseSerialization } from "./lib/serialization";

const args = process.argv.slice(2);
function getArg(name: string, fallback: string): string {
const idx = args.indexOf(`--${name}`);
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : fallback;
}

const SRC = path.resolve(getArg("src", "."));
const OUT = path.resolve(getArg("out", "./_site"));

function readDir(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
const entries: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
entries.push(...readDir(full));
} else {
entries.push(full);
}
}
return entries;
}

function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}

function copyDir(src: string, dest: string) {
if (!fs.existsSync(src)) return;
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
ensureDir(destPath);
fs.copyFileSync(srcPath, destPath);
}
}
}

async function main() {
const start = Date.now();
const renderer = new SiteRenderer();

// Step 1: Load templates (.html, .liquid, and .md partials)
const templatesDir = path.join(SRC, "templates");
if (fs.existsSync(templatesDir)) {
for (const file of readDir(templatesDir)) {
if (!/\.(html|liquid|md|markdown)$/i.test(file)) continue;
const name = path.relative(templatesDir, file)
.replace(/\.(html|liquid|md|markdown)$/i, "")
.replace(/\\/g, "/");
const source = fs.readFileSync(file, "utf-8");

// Files starting with _ are partials. .md/.markdown files are always
// partials (it's unusual to use raw markdown as a top-level template).
const isPartial =
path.basename(file).startsWith("_") ||
/\.(md|markdown)$/i.test(file);
if (isPartial) {
renderer.registerPartial(name.replace(/^_/, "").replace(/\/_/, "/"), source);
} else {
renderer.registerTemplate(name, source);
}
}
}

// Step 2: Load global data (.json or .toml)
const dataDir = path.join(SRC, "data");
if (fs.existsSync(dataDir)) {
for (const file of readDir(dataDir)) {
const ext = path.extname(file).toLowerCase();
if (ext !== ".json" && ext !== ".toml") continue;
const name = path.basename(file).replace(/\.(json|toml)$/i, "");
const raw = fs.readFileSync(file, "utf-8");
const data = parseSerialization(raw, {
format: ext === ".toml" ? "toml" : "json",
});
renderer.registerData(name, data);
}
}

// Step 3a: Scan content for frontmatter so site.posts / site.pages /
// site.categories / site.tags collections are available to every render.
type Pending =
| { kind: "md"; rel: string; content: string }
| { kind: "data"; rel: string; content: string };
const pending: Pending[] = [];
const contentDir = path.join(SRC, "content");

if (fs.existsSync(contentDir)) {
for (const file of readDir(contentDir)) {
const rel = path.relative(contentDir, file).replace(/\\/g, "/");
const ext = path.extname(file).toLowerCase();

if (ext === ".md" || ext === ".mdx" || ext === ".markdown" || ext === ".html") {
const content = fs.readFileSync(file, "utf-8");
const fm = parseSerialization(content) as Record<string, unknown>;
const frontmatter = { ...fm };
delete frontmatter.body;
renderer.registerContent(rel, frontmatter);
pending.push({ kind: "md", rel, content });
} else if (ext === ".json" || ext === ".toml") {
const content = fs.readFileSync(file, "utf-8");
const fm = parseSerialization(content, {
format: ext === ".toml" ? "toml" : "json",
}) as Record<string, unknown>;
renderer.registerContent(rel, fm);
pending.push({ kind: "data", rel, content });
}
}
}

// Step 3b: Render content with collections available.
const pages: Awaited<ReturnType<typeof renderer.renderMarkdown>>[] = [];
let fileCount = 0;
for (const item of pending) {
const rendered = item.kind === "md"
? await renderer.renderMarkdown(item.rel, item.content)
: await renderer.renderJson(item.rel, item.content);
const outPath = path.join(OUT, rendered.outputPath);
ensureDir(outPath);
fs.writeFileSync(outPath, rendered.html);
pages.push(rendered);
fileCount++;
}

// Step 4: Render index page if there's no content/index.{md,html,json,toml}
// already producing one. When the user has their own index page, they
// own the layout — passing posts via global context is enough.
const userOwnedIndex = pages.some((p) => p.outputPath === "index.html");
if (!userOwnedIndex) {
const indexHtml = await renderer.renderCollectionIndex("index", pages, "posts");
if (indexHtml) {
const outPath = path.join(OUT, "index.html");
ensureDir(outPath);
fs.writeFileSync(outPath, indexHtml);
fileCount++;
}
}

// Step 5: Copy static files
copyDir(path.join(SRC, "static"), OUT);

const elapsed = Date.now() - start;
console.log(`Built ${fileCount} pages in ${elapsed}ms → ${OUT}`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading