Skip to content
Closed
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: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ docker-compose.yml
Dockerfile

# Markdown
src/pages.docs.json
src/manifest.json
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions mdsvex.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ export const mdsvexOptions = {
highlight: { highlighter },
layout: {
_: dirname(fileURLToPath(import.meta.url)) + '/src/templates/page.svelte',
docs: dirname(fileURLToPath(import.meta.url)) + '/src/templates/doc.svelte',
section: dirname(fileURLToPath(import.meta.url)) + '/src/templates/section.svelte',
legacy: dirname(fileURLToPath(import.meta.url)) + '/src/templates/legacy.svelte'
doc_page: dirname(fileURLToPath(import.meta.url)) + '/src/templates/doc-page.svelte',
doc_section: dirname(fileURLToPath(import.meta.url)) + '/src/templates/doc-section.svelte'
}
};
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "bun run sync-docs && vite dev",
"build": "bun run sync-docs && vite build",
"build-srv": "bun run sync-docs && bun run sync-changelog && bun run sync-robots && vite build",
"dev": "bun run sync-content && vite dev",
"build": "bun run sync-content && vite build",
"build-srv": "bun run sync-content && bun run sync-changelog && bun run sync-robots && vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"sync-docs": "node --experimental-strip-types scripts/sync-docs/index.ts",
"sync-content": "node --experimental-strip-types scripts/mdsvx/index.ts",
"sync-changelog": "node --experimental-strip-types scripts/sync-changelog/index.ts",
"sync-robots": "node --experimental-strip-types scripts/sync-robots/index.ts",
"check": "bun run sync-docs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "bun run sync-content && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
Expand All @@ -37,7 +37,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.20.0",
"globals": "^17.7.0",
"lapikit": "^0.0.0-insiders.bd3f1f3",
"lapikit": "^0.0.0-insiders.aacf580",
"mdsvex": "^0.12.7",
"prettier": "^3.9.3",
"prettier-plugin-svelte": "^3.5.2",
Expand Down
127 changes: 127 additions & 0 deletions scripts/mdsvx/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { FrontmatterData, FrontmatterValue } from './types.ts';

const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;

export function readFrontmatter(content: string): FrontmatterData {
const match = content.match(FRONTMATTER_BLOCK);

if (!match || !match[1].trim()) {
return {};
}

const lines = match[1].replace(/\r\n/g, '\n').split('\n');

return parseObject(lines, { line: 0 }, 0);
}

function parseObject(
lines: string[],
cursor: { line: number },
indent: number
): FrontmatterData {
const result: FrontmatterData = {};

while (cursor.line < lines.length) {
skipBlank(lines, cursor);

if (cursor.line >= lines.length || indentOf(lines[cursor.line]) < indent) {
break;
}

const trimmed = lines[cursor.line].trim();
const separator = trimmed.indexOf(':');

if (separator === -1) {
throw new Error(`Invalid frontmatter line: "${trimmed}"`);
}

const key = trimmed.slice(0, separator).trim();
const remainder = trimmed.slice(separator + 1).trim();
cursor.line += 1;

if (remainder) {
result[key] = parseScalar(remainder);
continue;
}

result[key] = parseNested(lines, cursor, indent);
}

return result;
}

function parseArray(
lines: string[],
cursor: { line: number },
indent: number
): FrontmatterValue[] {
const result: FrontmatterValue[] = [];

while (cursor.line < lines.length) {
skipBlank(lines, cursor);

if (cursor.line >= lines.length || indentOf(lines[cursor.line]) < indent) {
break;
}

const trimmed = lines[cursor.line].trim();

if (!trimmed.startsWith('-')) {
throw new Error(`Invalid array entry: "${trimmed}"`);
}

const remainder = trimmed.slice(1).trim();
cursor.line += 1;

result.push(remainder ? parseScalar(remainder) : parseNested(lines, cursor, indent));
}

return result;
}

function parseNested(
lines: string[],
cursor: { line: number },
parentIndent: number
): FrontmatterValue {
skipBlank(lines, cursor);

if (cursor.line >= lines.length || indentOf(lines[cursor.line]) <= parentIndent) {
return null;
}

const nestedIndent = indentOf(lines[cursor.line]);

return lines[cursor.line].trim().startsWith('-')
? parseArray(lines, cursor, nestedIndent)
: parseObject(lines, cursor, nestedIndent);
}

function parseScalar(value: string): FrontmatterValue {
if (value === 'null') return null;
if (value === 'true') return true;
if (value === 'false') return false;
if (value === '[]') return [];
if (value === '{}') return {};
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);

if (value.startsWith('"') && value.endsWith('"')) {
return JSON.parse(value) as string;
}

if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/\\'/g, "'");
}

return value;
}

function skipBlank(lines: string[], cursor: { line: number }) {
while (cursor.line < lines.length && !lines[cursor.line].trim()) {
cursor.line += 1;
}
}

function indentOf(line: string): number {
return line.length - line.trimStart().length;
}
101 changes: 101 additions & 0 deletions scripts/mdsvx/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { readFrontmatter } from './frontmatter.ts';
import { deriveSource } from './source.ts';
import type { FrontmatterData, ManifestEntry } from './types.ts';

const folders = [
{ dir: 'routes', urlPrefix: '' },
{ dir: 'content/docs', urlPrefix: '/docs' }
];
const extensionsFile = ['md'];
const routesFile = join(process.cwd(), 'src', 'routes', 'routes.json');
const manifestFile = join(process.cwd(), 'src', 'manifest.json');

const entries = [
...(await Promise.all(folders.map(collectFolderEntries))).flat(),
...(await collectManualEntries())
].sort((left, right) => left.path.pathname.localeCompare(right.path.pathname));

assertNoDuplicatePaths(entries);

await writeFile(manifestFile, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');

console.log(`Wrote ${entries.length} entries to src/manifest.json`);

async function collectFolderEntries({ dir, urlPrefix }: { dir: string; urlPrefix: string }) {
const baseDir = join(process.cwd(), 'src', dir);
const dirEntries = await readdir(baseDir, { withFileTypes: true, recursive: true });

const files = dirEntries.filter(
(entry) => entry.isFile() && extensionsFile.includes(extname(entry.name).slice(1))
);

return Promise.all(
files.map(async (entry): Promise<ManifestEntry> => {
const filePath = join(entry.parentPath, entry.name);
const content = await readFile(filePath, 'utf8');
const frontmatter = readFrontmatter(content);
const path = deriveSource(filePath, baseDir, urlPrefix);
const title = asOptionalTitle(frontmatter.title) ?? fallbackTitle(path.slugSegments);

return { ...frontmatter, title, path };
})
);
}

async function collectManualEntries(): Promise<ManifestEntry[]> {
const content = await readFile(routesFile, 'utf8');
const routes: Record<string, FrontmatterData> = JSON.parse(content);
const sourcePath = 'src/routes/routes.json';

return Object.entries(routes).map(([pathname, frontmatter]) => {
const slug = pathname === '/' ? '' : pathname.replace(/^\//, '');
const slugSegments = slug ? slug.split('/') : [];
const title = requireTitle(frontmatter, `${sourcePath} (${pathname})`);

return { ...frontmatter, title, path: { sourcePath, slug, slugSegments, pathname } };
});
}

function asOptionalTitle(value: FrontmatterData['title']) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function requireTitle(frontmatter: FrontmatterData, source: string) {
const title = asOptionalTitle(frontmatter.title);

if (!title) {
throw new Error(`Missing "title" in frontmatter: ${source}`);
}

return title;
}

function fallbackTitle(slugSegments: string[]) {
const lastSegment = slugSegments.at(-1);

if (!lastSegment) {
return 'Documentation';
}

return lastSegment
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

function assertNoDuplicatePaths(manifestEntries: ManifestEntry[]) {
const seen = new Set<string>();

for (const entry of manifestEntries) {
if (seen.has(entry.path.pathname)) {
throw new Error(
`Duplicate path detected in manifest: "${entry.path.pathname}" (${entry.path.sourcePath})`
);
}

seen.add(entry.path.pathname);
}
}
43 changes: 43 additions & 0 deletions scripts/mdsvx/source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { relative } from 'node:path';

export type SourceMeta = {
sourcePath: string;
slug: string;
slugSegments: string[];
pathname: string;
};

const ROUTE_GROUP = /^\(.*\)$/;

export function deriveSource(filePath: string, baseDir: string, urlPrefix: string): SourceMeta {
const sourcePath = toPosixPath(relative(process.cwd(), filePath));
const segments = toPosixPath(relative(baseDir, filePath)).replace(/\.md$/, '').split('/');

const slugSegments = segments
.filter((segment) => !ROUTE_GROUP.test(segment))
.filter((segment, index, all) => index !== all.length - 1 || !isIndexLike(segment))
.map(slugify)
.filter(Boolean);

const slug = slugSegments.join('/');
const pathname = `${urlPrefix}${slug ? `/${slug}` : ''}` || '/';

return { sourcePath, slug, slugSegments, pathname };
}

function isIndexLike(segment: string) {
return segment === 'index' || segment.startsWith('+');
}

function slugify(value: string) {
return value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

function toPosixPath(value: string) {
return value.replaceAll('\\', '/');
}
19 changes: 7 additions & 12 deletions scripts/sync-docs/types.ts → scripts/mdsvx/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,14 @@ export type FrontmatterValue =

export type FrontmatterData = Record<string, FrontmatterValue>;

export type ParsedMarkdownFile = {
body: string;
frontmatter: FrontmatterData;
hasFrontmatter: boolean;
};

export type DerivedDoc = {
id: string;
metadata: FrontmatterData & { title: string };
path: string;
section?: string;
export type ManifestPath = {
sourcePath: string;
slug: string;
slugSegments: string[];
sourcePath: string;
pathname: string;
};

export type ManifestEntry = FrontmatterData & {
title: string;
path: ManifestPath;
};
7 changes: 0 additions & 7 deletions scripts/sync-docs/config.ts

This file was deleted.

Loading