Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
@@ -0,0 +1,56 @@
import { parse } from 'smol-toml';
// Zola frontmatter is delimited by a `+++` line, then TOML, then another
// `+++` line. Only the first such block (right at the top of the file) is
// treated as frontmatter; anything else is body content.
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?/;
// Values that would be ambiguous or invalid as a plain (unquoted) YAML
// scalar: leading YAML indicator characters, empty strings, and strings
// that would otherwise parse as a different type (bool/null/number).
const LEADING_SPECIAL_RE = /^[-?:,[\]{}#&*!|>'"%@`]/;
const AMBIGUOUS_SCALAR_RE = /^(true|false|null|~|-?\d+(\.\d+)?)$/i;
function needsQuoting(value) {
if (value === '') return true;
if (LEADING_SPECIAL_RE.test(value)) return true;
if (value.includes(':') || value.includes('#')) return true;
if (/^\s|\s$/.test(value)) return true;
if (AMBIGUOUS_SCALAR_RE.test(value)) return true;
return false;
}
function yamlScalar(value) {
return needsQuoting(value) ? JSON.stringify(value) : value;
}
/**
* Converts a leading Zola `+++ TOML +++` frontmatter block into a Starlight
* `--- YAML ---` block, per the documented field mapping. The body (and the
* rest of the file after the frontmatter) is left byte-for-byte untouched.
* Files with no leading `+++` block are returned unchanged.
*
* @param {string} rawMarkdown
* @returns {string}
*/
export function convertFrontmatter(rawMarkdown) {
const match = rawMarkdown.match(FRONTMATTER_RE);
if (!match) return rawMarkdown;
const toml = parse(match[1]);
const body = rawMarkdown.slice(match[0].length);
const lines = ['---'];
if ('title' in toml) {
lines.push(`title: ${yamlScalar(String(toml.title))}`);
}
if ('description' in toml) {
lines.push(`description: ${yamlScalar(String(toml.description))}`);
}
if ('weight' in toml) {
lines.push('sidebar:', ` order: ${toml.weight}`);
}
lines.push('---');
return lines.join('\n') + '\n' + body;
}
@@ -0,0 +1,158 @@
import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Content root mirrors the site's URL space 1:1: `src/content/docs/docs/**`
// serves `/docs/**` (see astro.config.mjs `trailingSlash: 'always'`).
const CONTENT_ROOT = path.resolve(__dirname, '../src/content/docs/docs');
const OUT_DIR = path.resolve(__dirname, '../public');
const SITE = 'https://loco.rs';
// Same six Diátaxis groups + labels as the sidebar in astro.config.mjs.
const SECTION_LABELS = {
tutorials: 'Tutorials',
'how-to': 'How-to guides',
reference: 'Reference',
explanation: 'Explanation',
extras: 'Extras',
resources: 'Resources',
};
const SECTION_ORDER = ['tutorials', 'how-to', 'reference', 'explanation', 'extras', 'resources'];
function walk(dir) {
const entries = readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walk(full));
else if (entry.name.endsWith('.md')) files.push(full);
}
return files;
}
function unquote(value) {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
try {
return JSON.parse(trimmed.replace(/^'|'$/g, '"'));
} catch {
return trimmed.slice(1, -1);
}
}
return trimmed;
}
/**
* Minimal parser for the flat YAML frontmatter this site's docs actually
* produce (see scripts/convert-frontmatter.mjs): a `---` delimited block of
* `title:`, `description:`, and `sidebar:\n order: N`.
*
* @param {string} raw
* @returns {{ title: string, description: string, order: number, body: string }}
*/
export function parseDoc(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { title: '', description: '', order: 999, body: raw };
const fm = match[1];
const body = raw.slice(match[0].length).trim();
const title = unquote(fm.match(/^title:\s*(.+)$/m)?.[1] ?? '');
const description = unquote(fm.match(/^description:\s*(.*)$/m)?.[1] ?? '');
const order = Number(fm.match(/^\s+order:\s*(\d+)/m)?.[1] ?? 999);
return { title, description, order, body };
}
export function urlFor(relPath) {
// relPath is relative to CONTENT_ROOT, e.g. 'index.md', 'how-to/index.md',
// 'how-to/add-model.md'.
const dir = path.dirname(relPath);
const base = path.basename(relPath, '.md');
if (base === 'index') {
return dir === '.' ? '/docs/' : `/docs/${dir}/`;
}
return `/docs/${dir}/${base}/`;
}
function loadPages() {
return walk(CONTENT_ROOT)
.map((file) => {
const relPath = path.relative(CONTENT_ROOT, file);
const raw = readFileSync(file, 'utf8');
const doc = parseDoc(raw);
const section = path.dirname(relPath).split(path.sep)[0]; // '.' for docs/index.md
return { ...doc, url: urlFor(relPath), section, relPath };
})
.sort((a, b) => a.order - b.order || a.url.localeCompare(b.url));
}
/**
* Builds `llms.txt` per the llmstxt.org convention: an H1 title, a summary
* blockquote, then one H2 per Diátaxis section listing every page as a
* `[title](url): description` bullet.
*/
function buildLlmsTxt(pages) {
const root = pages.find((p) => p.section === '.');
const lines = ['# Loco', ''];
lines.push(`> ${root?.description || 'Loco is a Rust web framework for full-stack productivity, batteries included.'}`);
lines.push('');
if (root) {
lines.push(`- [${root.title}](${SITE}${root.url})${root.description ? `: ${root.description}` : ''}`);
lines.push('');
}
for (const section of SECTION_ORDER) {
const sectionPages = pages.filter((p) => p.section === section);
if (sectionPages.length === 0) continue;
lines.push(`## ${SECTION_LABELS[section]}`);
for (const page of sectionPages) {
const desc = page.description ? `: ${page.description}` : '';
lines.push(`- [${page.title}](${SITE}${page.url})${desc}`);
}
lines.push('');
}
return lines.join('\n').trimEnd() + '\n';
}
/**
* Builds `llms-full.txt`: the full rendered-markdown body of every doc page,
* concatenated in the same section/order as `llms.txt`, each preceded by
* its title and canonical URL so an LLM can attribute/cite a passage.
*/
function buildLlmsFullTxt(pages) {
const ordered = [
...pages.filter((p) => p.section === '.'),
...SECTION_ORDER.flatMap((section) => pages.filter((p) => p.section === section)),
];
return (
ordered
.map((page) => `# ${page.title}\n\nSource: ${SITE}${page.url}\n\n${page.body}`)
.join('\n\n---\n\n') + '\n'
);
}
function main() {
const pages = loadPages();
mkdirSync(OUT_DIR, { recursive: true });
const llmsTxt = buildLlmsTxt(pages);
const llmsFullTxt = buildLlmsFullTxt(pages);
writeFileSync(path.join(OUT_DIR, 'llms.txt'), llmsTxt, 'utf8');
writeFileSync(path.join(OUT_DIR, 'llms-full.txt'), llmsFullTxt, 'utf8');
console.log(`llms.txt: ${llmsTxt.length} bytes, ${pages.length} pages linked`);
console.log(`llms-full.txt: ${llmsFullTxt.length} bytes, ${pages.length} pages inlined`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,139 @@
import { parse } from 'smol-toml';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Zola frontmatter is delimited by a `+++` line, then TOML, then another
// `+++` line. Only the first such block (right at the top of the file) is
// treated as frontmatter; anything else is body content.
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?/;
// Values that would be ambiguous or invalid as a plain (unquoted) YAML
// scalar: leading YAML indicator characters, empty strings, and strings
// that would otherwise parse as a different type (bool/null/number).
const LEADING_SPECIAL_RE = /^[-?:,[\]{}#&*!|>'"%@`]/;
const AMBIGUOUS_SCALAR_RE = /^(true|false|null|~|-?\d+(\.\d+)?)$/i;
function needsQuoting(value) {
if (value === '') return true;
if (LEADING_SPECIAL_RE.test(value)) return true;
if (value.includes(':') || value.includes('#')) return true;
if (/^\s|\s$/.test(value)) return true;
if (AMBIGUOUS_SCALAR_RE.test(value)) return true;
return false;
}
function yamlScalar(value) {
return needsQuoting(value) ? JSON.stringify(value) : value;
}
function authorSlug(name) {
return name.toLowerCase().replace(/\s+/g, '-');
}
// Formats a TOML date (Date object, or a string) as YAML-safe `YYYY-MM-DD`.
function isoDate(value) {
const d = value instanceof Date ? value : new Date(value);
return d.toISOString().slice(0, 10);
}
/**
* Converts a leading Zola `+++ TOML +++` frontmatter block (blog post, cast,
* or author page) into a content-collection `--- YAML ---` block, per the
* documented field mapping for each `kind`. The body is left byte-for-byte
* untouched. Returns `null` for posts marked `draft = true` (callers should
* skip writing them). Files with no leading `+++` block are returned
* unchanged.
*
* @param {string} rawMarkdown
* @param {'blog'|'cast'|'author'} kind
* @returns {string|null}
*/
export function convertBlogFrontmatter(rawMarkdown, kind) {
const match = rawMarkdown.match(FRONTMATTER_RE);
if (!match) return rawMarkdown;
const toml = parse(match[1]);
const body = rawMarkdown.slice(match[0].length);
if (toml.draft === true) return null;
const lines = ['---'];
if (kind === 'author') {
if ('title' in toml) lines.push(`name: ${yamlScalar(String(toml.title))}`);
if ('description' in toml) lines.push(`description: ${yamlScalar(String(toml.description))}`);
lines.push('---');
return lines.join('\n') + '\n' + body;
}
// blog + cast share title/description/pubDate/updatedDate/authors
if ('title' in toml) lines.push(`title: ${yamlScalar(String(toml.title))}`);
if ('description' in toml) lines.push(`description: ${yamlScalar(String(toml.description))}`);
if ('date' in toml) lines.push(`pubDate: ${isoDate(toml.date)}`);
if ('updated' in toml) lines.push(`updatedDate: ${isoDate(toml.updated)}`);
const authors = toml.taxonomies?.authors ?? [];
if (authors.length) {
lines.push('authors:');
for (const name of authors) lines.push(` - ${authorSlug(name)}`);
}
if (kind === 'cast') {
if (toml.extra?.num !== undefined) lines.push(`episode: ${yamlScalar(String(toml.extra.num))}`);
if (toml.extra?.id !== undefined) lines.push(`youtube: ${yamlScalar(String(toml.extra.id))}`);
}
lines.push('---');
return lines.join('\n') + '\n' + body;
}
// --- CLI walker: migrates docs-site/content/{blog,casts,authors} into
// website/src/content/{blog,casts,authors}, dropping each section's
// `_index.md` (no equivalent needed in a content collection).
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SRC_ROOT = path.resolve(__dirname, '../../docs-site/content');
const DEST_ROOT = path.resolve(__dirname, '../src/content');
const SECTIONS = [
{ dir: 'blog', kind: 'blog' },
{ dir: 'casts', kind: 'cast' },
{ dir: 'authors', kind: 'author' },
];
function migrateSection(dir, kind) {
const srcDir = path.join(SRC_ROOT, dir);
const destDir = path.join(DEST_ROOT, dir);
mkdirSync(destDir, { recursive: true });
let count = 0;
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
if (entry.name === '_index.md') continue;
const raw = readFileSync(path.join(srcDir, entry.name), 'utf8');
const converted = convertBlogFrontmatter(raw, kind);
if (converted === null) continue; // draft, skipped
writeFileSync(path.join(destDir, entry.name), converted, 'utf8');
count += 1;
}
return count;
}
function migrate() {
let total = 0;
for (const { dir, kind } of SECTIONS) {
const count = migrateSection(dir, kind);
console.log(`${dir}: ${count}`);
total += count;
}
console.log(`total: ${total}`);
}
// Only run the CLI walker when this file is executed directly (not when
// imported for its `convertBlogFrontmatter` export by tests).
if (import.meta.url === `file://${process.argv[1]}`) {
migrate();
}
@@ -0,0 +1,65 @@
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { convertFrontmatter } from './convert-frontmatter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Source: the (read-only) Zola docs tree. Destination: the Starlight docs
// collection, mirroring the same relative paths, with each section's
// `_index.md` renamed to `index.md`.
const SRC_ROOT = path.resolve(__dirname, '../../docs-site/content/docs');
const DEST_ROOT = path.resolve(__dirname, '../src/content/docs/docs');
function walk(dir) {
const entries = readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walk(full));
} else if (entry.name.endsWith('.md')) {
files.push(full);
}
}
return files;
}
function migrate() {
const files = walk(SRC_ROOT);
const counts = {};
for (const srcFile of files) {
const relPath = path.relative(SRC_ROOT, srcFile);
const relDir = path.dirname(relPath); // '.' for the root _index.md
const baseName = path.basename(relPath) === '_index.md' ? 'index.md' : path.basename(relPath);
const destRelPath = path.join(relDir, baseName);
const destFile = path.join(DEST_ROOT, destRelPath);
const raw = readFileSync(srcFile, 'utf8');
const converted = convertFrontmatter(raw);
mkdirSync(path.dirname(destFile), { recursive: true });
writeFileSync(destFile, converted, 'utf8');
const section = relDir === '.' ? 'index' : relDir.split(path.sep)[0];
counts[section] = (counts[section] ?? 0) + 1;
}
const sectionOrder = ['tutorials', 'how-to', 'reference', 'explanation', 'extras', 'resources', 'index'];
let total = 0;
for (const section of sectionOrder) {
if (counts[section] === undefined) continue;
console.log(`${section}: ${counts[section]}`);
total += counts[section];
}
for (const section of Object.keys(counts)) {
if (!sectionOrder.includes(section)) {
console.log(`${section}: ${counts[section]}`);
total += counts[section];
}
}
console.log(`total: ${total}`);
}
migrate();
@@ -0,0 +1,85 @@
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Zola's docs section used absolute-from-content-root links of the form
// `@/docs/<path>.md` (optionally with a `#anchor`), resolved by Zola's link
// checker at build time. Starlight has no equivalent shorthand, so these are
// rewritten to plain site-relative URLs: drop the `.md` extension, prepend
// a leading slash, and keep any anchor untouched. Every other link form
// (external, relative `./x.md`, bare `#frag`, images, non-docs `@/` links)
// is left exactly as-is.
//
// Two surface forms occur in the migrated content: standard Markdown link
// syntax `](@/docs/<path>.md)`, and a handful of raw inline HTML anchors
// `<a href="@/docs/<path>.md">` (Markdown passes raw HTML through
// untouched, so these survive migration as literal `@/` links too).
const MARKDOWN_DOCS_LINK_RE = /\]\(@\/docs\/([^)#]+?)\.md(#[^)]*)?\)/g;
const HTML_HREF_DOCS_LINK_RE = /href="@\/docs\/([^"#]+?)\.md(#[^"]*)?"/g;
/**
* Rewrites Zola-style `@/docs/<path>.md[#anchor]` links (in both Markdown
* link syntax and raw HTML `href="..."` attributes) into Starlight-style
* `/docs/<path>[#anchor]` links. All other link syntax is left untouched.
*
* @param {string} markdown
* @returns {string}
*/
export function rewriteLinks(markdown) {
return markdown
.replace(MARKDOWN_DOCS_LINK_RE, (_match, docPath, anchor = '') => `](/docs/${docPath}${anchor})`)
.replace(HTML_HREF_DOCS_LINK_RE, (_match, docPath, anchor = '') => `href="/docs/${docPath}${anchor}"`);
}
const DOCS_ROOT = path.resolve(__dirname, '../src/content/docs/docs');
function walk(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walk(full));
} else if (entry.name.endsWith('.md')) {
files.push(full);
}
}
return files;
}
function countMatches(text) {
const markdownCount = text.match(MARKDOWN_DOCS_LINK_RE)?.length ?? 0;
const htmlCount = text.match(HTML_HREF_DOCS_LINK_RE)?.length ?? 0;
return markdownCount + htmlCount;
}
function apply() {
const files = walk(DOCS_ROOT);
let changedFiles = 0;
let totalLinks = 0;
for (const file of files) {
const raw = readFileSync(file, 'utf8');
const linkCount = countMatches(raw);
if (linkCount === 0) continue;
const rewritten = rewriteLinks(raw);
if (rewritten !== raw) {
writeFileSync(file, rewritten, 'utf8');
changedFiles += 1;
totalLinks += linkCount;
}
}
console.log(`rewrote ${totalLinks} link(s) across ${changedFiles} file(s)`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
if (process.argv.includes('--apply')) {
apply();
} else {
console.log('Usage: node rewrite-links.mjs --apply');
process.exit(1);
}
}
@@ -0,0 +1,113 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { convertFrontmatter } from './convert-frontmatter.mjs';
import { rewriteLinks } from './rewrite-links.mjs';
import { oldDocUrls, newDocUrls } from './url-parity.mjs';
import { parseDoc, urlFor } from './generate-llms-txt.mjs';
import { convertBlogFrontmatter } from './migrate-blog-casts.mjs';
it('maps title/description/weight and drops zola-only keys', () => {
const zola = `+++\ntitle = "Add a worker"\ndescription = "How to add a worker"\nsort_by = "weight"\nweight = 30\ntemplate = "docs/page.html"\n+++\n\n# Body\ntext {{ get_env(name='X') }}\n`;
const out = convertFrontmatter(zola);
expect(out).toMatch(/^---\n/);
expect(out).toContain('title: Add a worker');
expect(out).toContain('description: How to add a worker');
expect(out).toContain('sidebar:\n order: 30');
expect(out).not.toContain('sort_by');
expect(out).not.toContain('template');
// body + literal config syntax preserved verbatim
expect(out).toContain("text {{ get_env(name='X') }}");
});
it('quotes titles containing colons/special chars safely', () => {
const zola = `+++\ntitle = "Loco: the tour"\nweight = 1\n+++\nbody\n`;
expect(convertFrontmatter(zola)).toContain('title: "Loco: the tour"');
});
it('rewrites @/docs links, preserving anchors, dropping .md', () => {
expect(rewriteLinks('see [cfg](@/docs/reference/configuration.md)')).toBe('see [cfg](/docs/reference/configuration.md)'.replace('.md', ''));
expect(rewriteLinks('[m](@/docs/how-to/add-worker.md#queues)')).toBe('[m](/docs/how-to/add-worker#queues)');
});
it('leaves external and relative links alone', () => {
const s = '[x](https://example.com) and [y](./local.md) and [z](#frag)';
expect(rewriteLinks(s)).toBe(s);
});
it('rewrites @/docs links embedded in raw HTML href attributes too', () => {
expect(rewriteLinks('<a href="@/docs/tutorials/saas-with-auth.md">x</a>')).toBe(
'<a href="/docs/tutorials/saas-with-auth">x</a>'
);
expect(
rewriteLinks('<a href="@/docs/reference/query-pagination.md#daterangebuilder">x</a>')
).toBe('<a href="/docs/reference/query-pagination#daterangebuilder">x</a>');
});
describe('url-parity', () => {
let dir;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
it('maps old Zola paths to /docs/<section>/<slug>/, with _index.md as the section root', () => {
dir = mkdtempSync(path.join(tmpdir(), 'old-docs-'));
writeFileSync(path.join(dir, '_index.md'), 'x');
mkdirSync(path.join(dir, 'how-to'));
writeFileSync(path.join(dir, 'how-to', '_index.md'), 'x');
writeFileSync(path.join(dir, 'how-to', 'add-model.md'), 'x');
expect(oldDocUrls(dir).sort()).toEqual(['/docs/', '/docs/how-to/', '/docs/how-to/add-model/']);
});
it('maps new dist/docs/**/index.html paths to the same /docs/.../ URL shape', () => {
dir = mkdtempSync(path.join(tmpdir(), 'new-docs-'));
writeFileSync(path.join(dir, 'index.html'), 'x');
mkdirSync(path.join(dir, 'how-to'));
writeFileSync(path.join(dir, 'how-to', 'index.html'), 'x');
mkdirSync(path.join(dir, 'how-to', 'add-model'));
writeFileSync(path.join(dir, 'how-to', 'add-model', 'index.html'), 'x');
expect(newDocUrls(dir).sort()).toEqual(['/docs/', '/docs/how-to/', '/docs/how-to/add-model/']);
});
});
describe('generate-llms-txt', () => {
it('parses title/description/order out of the migrated YAML frontmatter', () => {
const raw = '---\ntitle: Add a model\ndescription: Generate a model.\nsidebar:\n order: 1\n---\n\nBody text.\n';
expect(parseDoc(raw)).toEqual({
title: 'Add a model',
description: 'Generate a model.',
order: 1,
body: 'Body text.',
});
});
it('tolerates an empty description', () => {
const raw = '---\ntitle: Extras\ndescription: ""\nsidebar:\n order: 5\n---\nBody.\n';
const doc = parseDoc(raw);
expect(doc.description).toBe('');
});
it('derives /docs/... URLs the same way for index and non-index pages', () => {
expect(urlFor('index.md')).toBe('/docs/');
expect(urlFor('how-to/index.md')).toBe('/docs/how-to/');
expect(urlFor('how-to/add-model.md')).toBe('/docs/how-to/add-model/');
});
});
describe('migrate-blog-casts', () => {
it('blog: maps date→pubDate, taxonomy authors→slug array, drops template', () => {
const z = `+++\ntitle = "Hello"\ndescription = "d"\ndate = 2024-01-25T18:03:52+01:00\ndraft = false\ntemplate = "blog/page.html"\n[taxonomies]\nauthors = ["Team Loco"]\n+++\n\nbody\n`;
const out = convertBlogFrontmatter(z, 'blog');
expect(out).toContain('title: Hello');
expect(out).toContain('pubDate: 2024-01-25');
expect(out).toContain('authors:\n - team-loco');
expect(out).not.toContain('template');
expect(out).toContain('\nbody\n');
});
it('cast: maps extra.num→episode and extra.id→youtube', () => {
const z = `+++\ntitle = "T"\ndescription = "d"\ndate = 2024-06-27T14:20:42+00:00\ntemplate = "casts/page.html"\n[taxonomies]\nauthors = ["Team Loco"]\n[extra]\nnum = "007"\nid = "OWUvUSC1KvY"\n+++\nnotes\n`;
const out = convertBlogFrontmatter(z, 'cast');
expect(out).toContain('episode: "007"');
expect(out).toContain('youtube: OWUvUSC1KvY');
});
});
@@ -0,0 +1,109 @@
import { readdirSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The old Zola blog/casts/authors content (read-only) and the new Astro
// build output.
const OLD_CONTENT_ROOT = path.resolve(__dirname, '../../docs-site/content');
const NEW_DIST_ROOT = path.resolve(__dirname, '../dist');
function walk(dir, predicate) {
const entries = readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walk(full, predicate));
} else if (predicate(entry.name)) {
files.push(full);
}
}
return files;
}
/**
* Old Zola URL scheme for a `blog`/`casts`/`authors` section: every
* `<slug>.md` maps to `/<section>/<slug>/`; `_index.md` (the section
* listing page) is skipped — it maps to the static `/<section>/` URL added
* separately in `oldUrls()`, not to a per-entry URL here.
*
* Author slugs are read straight off the files that exist under
* `content/authors/` — a post can reference an author slug with no author
* file at all (e.g. `deploy-aws.md`'s `antonio-souza`), and since that
* slug never gets its own file there, it's naturally never enumerated or
* expected to have a `/authors/<slug>/` page.
*
* @returns {string[]} e.g. ['/blog/hello-world/', '/casts/001-.../', '/authors/team-loco/']
*/
export function oldSectionUrls(section, root = path.join(OLD_CONTENT_ROOT, section)) {
const files = walk(root, (name) => name.endsWith('.md') && name !== '_index.md');
return files.map((file) => {
const slug = path.basename(file, '.md');
return `/${section}/${slug}/`;
});
}
/**
* All old URLs expected to survive the migration: per-entry blog/casts/
* authors URLs, the two section index pages, and the two feed URLs that
* lived at `/blog/rss.xml` and `/blog/atom.xml`.
*
* @returns {string[]}
*/
export function oldUrls() {
return [
...oldSectionUrls('blog'),
...oldSectionUrls('casts'),
...oldSectionUrls('authors'),
'/blog/',
'/casts/',
'/blog/rss.xml',
'/blog/atom.xml',
];
}
/**
* New build URLs, read straight off `website/dist/**`. An `index.html`
* maps to its containing directory (with trailing slash, matching
* `trailingSlash: 'always'`); any other file maps to its path as-is (e.g.
* `dist/blog/rss.xml` -> `/blog/rss.xml`).
*
* @returns {string[]}
*/
export function newUrls(root = NEW_DIST_ROOT) {
const files = walk(root, () => true);
return files.map((file) => {
const rel = path.relative(root, file);
if (path.basename(rel) === 'index.html') {
const dir = path.dirname(rel);
return dir === '.' ? '/' : `/${dir}/`;
}
return `/${rel}`;
});
}
function main() {
if (!existsSync(NEW_DIST_ROOT)) {
console.error(`${NEW_DIST_ROOT} does not exist — run \`pnpm build\` before \`node scripts/url-parity-blog.mjs\`.`);
process.exit(1);
}
const old = oldUrls();
const fresh = new Set(newUrls());
const missing = old.filter((url) => !fresh.has(url)).sort();
if (missing.length === 0) {
console.log(`0 missing (checked ${old.length} old blog/casts/authors URLs against ${fresh.size} new URLs)`);
} else {
console.log(`${missing.length} missing:`);
for (const url of missing) console.log(` ${url}`);
process.exitCode = 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,86 @@
import { readdirSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The old Zola docs tree (read-only) and the new Starlight build output.
const OLD_DOCS_ROOT = path.resolve(__dirname, '../../docs-site/content/docs');
const NEW_DIST_DOCS_ROOT = path.resolve(__dirname, '../dist/docs');
function walk(dir, predicate) {
const entries = readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walk(full, predicate));
} else if (predicate(entry.name)) {
files.push(full);
}
}
return files;
}
/**
* Old Zola URL scheme: `/docs/<section>/<slug>/`, where a section (or the
* docs root) index file is named `_index.md` and maps to `/docs/<section>/`
* (or `/docs/` for the root), and every other `<slug>.md` maps to
* `/docs/<section>/<slug>/`.
*
* @returns {string[]} old doc URLs, e.g. ['/docs/', '/docs/how-to/', '/docs/how-to/add-model/']
*/
export function oldDocUrls(root = OLD_DOCS_ROOT) {
const files = walk(root, (name) => name.endsWith('.md'));
return files.map((file) => {
const rel = path.relative(root, file); // e.g. 'how-to/add-model.md', '_index.md', 'how-to/_index.md'
const dir = path.dirname(rel); // '.' | 'how-to'
const base = path.basename(rel, '.md'); // 'add-model' | '_index'
if (base === '_index') {
return dir === '.' ? '/docs/' : `/docs/${dir}/`;
}
return `/docs/${dir}/${base}/`;
});
}
/**
* New Starlight build URL scheme, read straight off the built `dist/docs`
* tree (with `trailingSlash: 'always'`, every route is `<path>/index.html`).
*
* @returns {string[]} new doc URLs, e.g. ['/docs/', '/docs/how-to/', '/docs/how-to/add-model/']
*/
export function newDocUrls(root = NEW_DIST_DOCS_ROOT) {
const files = walk(root, (name) => name === 'index.html');
return files.map((file) => {
const rel = path.relative(root, file); // e.g. 'how-to/add-model/index.html', 'index.html'
const dir = path.dirname(rel); // '.' | 'how-to/add-model'
return dir === '.' ? '/docs/' : `/docs/${dir}/`;
});
}
function main() {
if (!existsSync(NEW_DIST_DOCS_ROOT)) {
console.error(
`${NEW_DIST_DOCS_ROOT} does not exist — run \`pnpm build\` before \`node scripts/url-parity.mjs\`.`
);
process.exit(1);
}
const oldUrls = oldDocUrls();
const newUrls = new Set(newDocUrls());
const missing = oldUrls.filter((url) => !newUrls.has(url)).sort();
if (missing.length === 0) {
console.log(`0 missing (checked ${oldUrls.length} old doc URLs against ${newUrls.size} new doc URLs)`);
} else {
console.log(`${missing.length} missing:`);
for (const url of missing) console.log(` ${url}`);
process.exitCode = 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}