diff --git a/.gitignore b/.gitignore index 397735a0..c58a0c9d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,4 @@ docker-compose.yml Dockerfile # Markdown -src/pages.docs.json \ No newline at end of file +src/manifest.json \ No newline at end of file diff --git a/bun.lock b/bun.lock index c1b5ba78..25d7c8a6 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,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", @@ -529,7 +529,7 @@ "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], - "lapikit": ["lapikit@0.0.0-insiders.bd3f1f3", "", { "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "lapikit": "bin/index.js" } }, "sha512-eGmZvlcC002R1i+k5z3pLAu7Q1TkDePQENDWCpFbLoc1P9XeU8CevMXCsRVPCs7/aoKMxzfirWZWp5nJsl6RLg=="], + "lapikit": ["lapikit@0.0.0-insiders.aacf580", "", { "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "lapikit": "bin/index.js" } }, "sha512-UhqfWnyGiV0MYOzlepcxetL3ZflJWwA4+4FOCNty+0zfr6HimQwNMBZLDEs5paFLKYOLc0v+DcZPJKYehGq6fg=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], diff --git a/mdsvex.config.js b/mdsvex.config.js index 120d4c39..09810d7a 100644 --- a/mdsvex.config.js +++ b/mdsvex.config.js @@ -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' } }; diff --git a/package.json b/package.json index 6e8769c2..aa5fdbfa 100644 --- a/package.json +++ b/package.json @@ -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 .", @@ -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", diff --git a/scripts/mdsvx/frontmatter.ts b/scripts/mdsvx/frontmatter.ts new file mode 100644 index 00000000..a536c1d0 --- /dev/null +++ b/scripts/mdsvx/frontmatter.ts @@ -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; +} diff --git a/scripts/mdsvx/index.ts b/scripts/mdsvx/index.ts new file mode 100644 index 00000000..e30dca5b --- /dev/null +++ b/scripts/mdsvx/index.ts @@ -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 => { + 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 { + const content = await readFile(routesFile, 'utf8'); + const routes: Record = 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(); + + 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); + } +} diff --git a/scripts/mdsvx/source.ts b/scripts/mdsvx/source.ts new file mode 100644 index 00000000..c151b78b --- /dev/null +++ b/scripts/mdsvx/source.ts @@ -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('\\', '/'); +} diff --git a/scripts/sync-docs/types.ts b/scripts/mdsvx/types.ts similarity index 55% rename from scripts/sync-docs/types.ts rename to scripts/mdsvx/types.ts index b576a78b..4d7a8e36 100644 --- a/scripts/sync-docs/types.ts +++ b/scripts/mdsvx/types.ts @@ -8,19 +8,14 @@ export type FrontmatterValue = export type FrontmatterData = Record; -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; }; diff --git a/scripts/sync-docs/config.ts b/scripts/sync-docs/config.ts deleted file mode 100644 index 4ecf90a9..00000000 --- a/scripts/sync-docs/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { join } from 'node:path'; - -export const projectRoot = process.cwd(); -export const docsContentDir = join(projectRoot, 'src/content/docs'); -export const generatedDir = join(projectRoot, 'src/'); -export const docsMetadataFile = join(generatedDir, 'pages.docs.json'); -export const generatedFrontmatterKeys = new Set(['slug', 'path', 'section', 'order']); diff --git a/scripts/sync-docs/docs.ts b/scripts/sync-docs/docs.ts deleted file mode 100644 index fd3ec8b1..00000000 --- a/scripts/sync-docs/docs.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { basename, relative } from 'node:path'; -import { docsContentDir, projectRoot } from './config.ts'; -import type { DerivedDoc, FrontmatterData, FrontmatterValue } from './types.ts'; - -export function deriveDoc(filePath: string, frontmatter: FrontmatterData): DerivedDoc { - const relativeFilePath = toPosixPath(relative(docsContentDir, filePath)); - const sourcePath = toPosixPath(relative(projectRoot, filePath)); - const pathSegments = relativeFilePath.split('/'); - const fileName = pathSegments.pop(); - - if (!fileName) { - throw new Error(`Invalid documentation file path: ${filePath}`); - } - - const fileBasename = basename(fileName, '.md'); - const slugSegments = pathSegments - .map(slugify) - .concat(fileBasename !== 'index' ? [slugify(fileBasename)] : []) - .filter(Boolean); - const slug = slugSegments.join('/'); - const path = slug ? `/docs/${slug}` : '/docs'; - const fallbackTitle = toTitle(fileBasename !== 'index' ? fileBasename : (pathSegments.at(-1) ?? '')) || 'Documentation'; - const title = asOptionalString(frontmatter.title) ?? fallbackTitle; - - return { - id: relativeFilePath.replace(/\.md$/, ''), - metadata: createDocMetadata(frontmatter, title), - path, - section: slugSegments[0], - slug, - slugSegments, - sourcePath, - title - }; -} - -export function compareDocs(left: DerivedDoc, right: DerivedDoc) { - return left.path.localeCompare(right.path); -} - -export function assertNoDuplicateRoutes(docs: DerivedDoc[]) { - const slugs = new Set(); - const paths = new Set(); - - for (const doc of docs) { - if (slugs.has(doc.slug)) { - throw new Error(`Duplicate documentation slug detected: "${doc.slug}".`); - } - - if (paths.has(doc.path)) { - throw new Error(`Duplicate documentation path detected: "${doc.path}".`); - } - - slugs.add(doc.slug); - paths.add(doc.path); - } -} - -export function createDocsMetadataJson(docs: DerivedDoc[]) { - const docsPayload = docs.map((doc) => ({ - id: doc.id, - sourcePath: doc.sourcePath, - metadata: doc.metadata, - slug: doc.slug, - slugSegments: doc.slugSegments, - path: doc.path, - section: doc.section - })); - - return `${JSON.stringify(docsPayload, null, 2)}\n`; -} - -function createDocMetadata(frontmatter: FrontmatterData, title: string) { - const metadata: FrontmatterData & { title: string } = { - title - }; - - for (const [key, value] of Object.entries(frontmatter)) { - if (key === 'title') { - continue; - } - - metadata[key] = value; - } - - return metadata; -} - -function slugify(value: string) { - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -function toTitle(value: string) { - return value - .split(/[-_\s]+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(' '); -} - -function asOptionalString(value: FrontmatterValue | undefined) { - return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} - -function toPosixPath(value: string) { - return value.replaceAll('\\', '/'); -} diff --git a/scripts/sync-docs/files.ts b/scripts/sync-docs/files.ts deleted file mode 100644 index fcfcf8aa..00000000 --- a/scripts/sync-docs/files.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Dirent } from 'node:fs'; -import { readdir, readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -export async function collectMarkdownFiles(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - const files = await Promise.all( - entries - .filter((entry: Dirent) => !entry.name.startsWith('.')) - .map(async (entry: Dirent) => { - const fullPath = join(directory, entry.name); - - if (entry.isDirectory()) { - return collectMarkdownFiles(fullPath); - } - - return entry.isFile() && entry.name.endsWith('.md') ? [fullPath] : []; - }) - ); - - return files.flat().sort((left: string, right: string) => left.localeCompare(right)); -} - -export async function readTextFile(filePath: string) { - return readFile(filePath, 'utf8'); -} - -export async function writeTextFile(filePath: string, content: string) { - await writeFile(filePath, content, 'utf8'); -} diff --git a/scripts/sync-docs/frontmatter.ts b/scripts/sync-docs/frontmatter.ts deleted file mode 100644 index a730c8c8..00000000 --- a/scripts/sync-docs/frontmatter.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { generatedFrontmatterKeys } from './config.ts'; -import type { FrontmatterData, FrontmatterValue, ParsedMarkdownFile } from './types.ts'; - -export function parseMarkdownFile(content: string): ParsedMarkdownFile { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); - - if (!match) { - return { - body: content, - frontmatter: {} satisfies FrontmatterData, - hasFrontmatter: false - }; - } - - const parsed = match[1].trim() ? parseFrontmatter(match[1]) : {}; - - return { - body: content.slice(match[0].length), - frontmatter: parsed, - hasFrontmatter: true - }; -} - -export function stripGeneratedFrontmatter(frontmatter: FrontmatterData): FrontmatterData { - return Object.fromEntries( - Object.entries(frontmatter).filter(([key]) => !generatedFrontmatterKeys.has(key)) - ); -} - -export function serializeMarkdownFile( - frontmatter: FrontmatterData, - body: string, - newline: string, - hasFrontmatter: boolean -) { - const normalizedBody = body.replace(/^\r?\n/, ''); - - if (Object.keys(frontmatter).length === 0) { - return hasFrontmatter ? normalizedBody : body; - } - - const serializedFrontmatter = serializeYamlObject(frontmatter).trimEnd(); - - return ['---', serializedFrontmatter, '---', '', normalizedBody] - .join('\n') - .replace(/\n/g, newline); -} - -function serializeYamlObject(value: FrontmatterData, indent = 0): string { - const padding = ' '.repeat(indent); - - return Object.entries(value) - .map(([key, entry]) => serializeYamlEntry(key, entry, padding, indent)) - .join('\n'); -} - -function serializeYamlEntry( - key: string, - value: FrontmatterValue, - padding: string, - indent: number -): string { - if (Array.isArray(value)) { - if (value.length === 0) { - return `${padding}${key}: []`; - } - - const items = value - .map((item) => { - if (isPlainObject(item)) { - const nested = serializeYamlObject(item, indent + 4); - return `${padding} -\n${nested}`; - } - - if (Array.isArray(item)) { - throw new Error(`Nested arrays are not supported in frontmatter key "${key}".`); - } - - return `${padding} - ${serializeYamlScalar(item)}`; - }) - .join('\n'); - - return `${padding}${key}:\n${items}`; - } - - if (isPlainObject(value)) { - const nestedEntries = Object.keys(value); - - if (nestedEntries.length === 0) { - return `${padding}${key}: {}`; - } - - return `${padding}${key}:\n${serializeYamlObject(value, indent + 2)}`; - } - - return `${padding}${key}: ${serializeYamlScalar(value)}`; -} - -function serializeYamlScalar(value: string | number | boolean | null) { - if (value === null) { - return 'null'; - } - - if (typeof value === 'string') { - return JSON.stringify(value); - } - - return String(value); -} - -function parseFrontmatter(source: string): FrontmatterData { - const lines = source.replace(/\r\n/g, '\n').split('\n'); - const { value, nextIndex } = parseObjectBlock(lines, 0, 0, 'frontmatter'); - - skipBlankLines(lines, nextIndex); - - return value; -} - -function parseObjectBlock( - lines: string[], - startIndex: number, - indent: number, - context: string -): { value: FrontmatterData; nextIndex: number } { - const result: FrontmatterData = {}; - let index = skipBlankLines(lines, startIndex); - - while (index < lines.length) { - const line = lines[index]; - const lineIndent = getIndent(line); - const trimmed = line.trim(); - - if (!trimmed) { - index += 1; - continue; - } - - if (lineIndent < indent) { - break; - } - - if (lineIndent !== indent) { - throw new Error(`Unexpected indentation at ${context} line ${index + 1}.`); - } - - if (trimmed.startsWith('-')) { - throw new Error(`Unexpected array item at ${context} line ${index + 1}.`); - } - - const separatorIndex = trimmed.indexOf(':'); - - if (separatorIndex === -1) { - throw new Error(`Invalid frontmatter entry at ${context} line ${index + 1}.`); - } - - const key = trimmed.slice(0, separatorIndex).trim(); - const remainder = trimmed.slice(separatorIndex + 1).trim(); - - if (!key) { - throw new Error(`Missing key at ${context} line ${index + 1}.`); - } - - if (remainder) { - result[key] = parseScalar(remainder, `${context}.${key}`); - index += 1; - continue; - } - - const nextIndex = skipBlankLines(lines, index + 1); - - if (nextIndex >= lines.length || getIndent(lines[nextIndex]) <= indent) { - result[key] = null; - index = nextIndex; - continue; - } - - const nextLine = lines[nextIndex]; - const nextIndent = getIndent(nextLine); - const nextTrimmed = nextLine.trim(); - - if (nextTrimmed.startsWith('-')) { - const arrayResult = parseArrayBlock(lines, nextIndex, nextIndent, `${context}.${key}`); - result[key] = arrayResult.value; - index = arrayResult.nextIndex; - continue; - } - - const objectResult = parseObjectBlock(lines, nextIndex, nextIndent, `${context}.${key}`); - result[key] = objectResult.value; - index = objectResult.nextIndex; - } - - return { value: result, nextIndex: index }; -} - -function parseArrayBlock( - lines: string[], - startIndex: number, - indent: number, - context: string -): { value: FrontmatterValue[]; nextIndex: number } { - const result: FrontmatterValue[] = []; - let index = skipBlankLines(lines, startIndex); - - while (index < lines.length) { - const line = lines[index]; - const lineIndent = getIndent(line); - const trimmed = line.trim(); - - if (!trimmed) { - index += 1; - continue; - } - - if (lineIndent < indent) { - break; - } - - if (lineIndent !== indent || !trimmed.startsWith('-')) { - throw new Error(`Invalid array entry at ${context} line ${index + 1}.`); - } - - const remainder = trimmed.slice(1).trim(); - - if (remainder) { - result.push(parseScalar(remainder, `${context}[${result.length}]`)); - index += 1; - continue; - } - - const nextIndex = skipBlankLines(lines, index + 1); - - if (nextIndex >= lines.length || getIndent(lines[nextIndex]) <= indent) { - result.push(null); - index = nextIndex; - continue; - } - - const nextLine = lines[nextIndex]; - const nextIndent = getIndent(nextLine); - const nextTrimmed = nextLine.trim(); - - if (nextTrimmed.startsWith('-')) { - const arrayResult = parseArrayBlock( - lines, - nextIndex, - nextIndent, - `${context}[${result.length}]` - ); - result.push(arrayResult.value); - index = arrayResult.nextIndex; - continue; - } - - const objectResult = parseObjectBlock( - lines, - nextIndex, - nextIndent, - `${context}[${result.length}]` - ); - result.push(objectResult.value); - index = objectResult.nextIndex; - } - - return { value: result, nextIndex: index }; -} - -function parseScalar(value: string, context: 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('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - if (value.startsWith('"')) { - return JSON.parse(value) as string; - } - - return value.slice(1, -1).replace(/\\'/g, "'"); - } - - if (value.includes(': ')) { - throw new Error(`Inline nested YAML is not supported at ${context}.`); - } - - return value; -} - -function skipBlankLines(lines: string[], index: number) { - let nextIndex = index; - - while (nextIndex < lines.length && !lines[nextIndex].trim()) { - nextIndex += 1; - } - - return nextIndex; -} - -function getIndent(line: string) { - return line.length - line.trimStart().length; -} - -function isPlainObject(value: unknown): value is Record { - return Object.prototype.toString.call(value) === '[object Object]'; -} diff --git a/scripts/sync-docs/index.ts b/scripts/sync-docs/index.ts deleted file mode 100644 index c9746c04..00000000 --- a/scripts/sync-docs/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { mkdir } from 'node:fs/promises'; -import { docsContentDir, docsMetadataFile, generatedDir } from './config.ts'; -import { assertNoDuplicateRoutes, compareDocs, createDocsMetadataJson, deriveDoc } from './docs.ts'; -import { collectMarkdownFiles, readTextFile, writeTextFile } from './files.ts'; -import { - parseMarkdownFile, - serializeMarkdownFile, - stripGeneratedFrontmatter -} from './frontmatter.ts'; -import type { DerivedDoc } from './types.ts'; - -await syncDocs(); - -async function syncDocs() { - const docFiles = await collectMarkdownFiles(docsContentDir); - const derivedDocs: DerivedDoc[] = []; - - for (const filePath of docFiles) { - derivedDocs.push(await syncDocFile(filePath)); - } - - const sortedDocs = [...derivedDocs].sort(compareDocs); - assertNoDuplicateRoutes(sortedDocs); - - await mkdir(generatedDir, { recursive: true }); - await writeTextFile(docsMetadataFile, createDocsMetadataJson(sortedDocs)); - - console.log(`Synced ${sortedDocs.length} documentation files.`); -} - -async function syncDocFile(filePath: string): Promise { - const rawContent = await readTextFile(filePath); - const newline = rawContent.includes('\r\n') ? '\r\n' : '\n'; - const { body, frontmatter, hasFrontmatter } = parseMarkdownFile(rawContent); - const nextFrontmatter = stripGeneratedFrontmatter(frontmatter); - const derivedDoc = deriveDoc(filePath, nextFrontmatter); - const nextContent = serializeMarkdownFile(nextFrontmatter, body, newline, hasFrontmatter); - - if (nextContent !== rawContent) { - await writeTextFile(filePath, nextContent); - } - - return derivedDoc; -} diff --git a/src/components/app-bar-global.svelte b/src/components/app-bar-global.svelte new file mode 100644 index 00000000..24f30086 --- /dev/null +++ b/src/components/app-bar-global.svelte @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/components/app-bar.svelte b/src/components/app-bar.svelte new file mode 100644 index 00000000..67734770 --- /dev/null +++ b/src/components/app-bar.svelte @@ -0,0 +1,113 @@ + + + + nav.toggle()} aria-label="open navigation" icon> + + + + + + + +
+ +
+ +
+ + + {@html discordIcon} + + + + + + {@html githubIcon} + + + + + + (openModal = true)} + color="fg-inverse" + background="bg-inverse" + > + {#snippet prepend()} + + + + {/snippet} + + Install Lapikit + +
+ + + + + diff --git a/src/components/aside/aside.svelte b/src/components/aside/aside.svelte new file mode 100644 index 00000000..1808b80a --- /dev/null +++ b/src/components/aside/aside.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/components/breadcrumbs.svelte b/src/components/breadcrumbs.svelte index 4264eff4..e15f07f5 100644 --- a/src/components/breadcrumbs.svelte +++ b/src/components/breadcrumbs.svelte @@ -2,7 +2,7 @@ import { resolve } from '$app/paths'; import type { BreadcrumbItem } from '$lib/@types'; import { capitalize } from '$lib/utils'; - import { ChevronRight } from 'lucide-svelte'; + import { ChevronRight, House } from 'lucide-svelte'; let { items = [] }: { items?: BreadcrumbItem[] } = $props(); @@ -15,10 +15,19 @@ {#if index === items.length - 1} {capitalize(item.label)} {:else if item.href} - {capitalize(item.label)} - + {#if item.label === 'Home'} + + + + + + {:else} + {capitalize(item.label)} + + {/if} {:else} {capitalize(item.label)} + {/if} {/each} @@ -27,13 +36,11 @@ {/if} diff --git a/src/components/drawer-release.svelte b/src/components/drawer-release.svelte new file mode 100644 index 00000000..0d923c5d --- /dev/null +++ b/src/components/drawer-release.svelte @@ -0,0 +1,81 @@ + + + + + Release + + {npmState.downloads || 0} + + + + + + {#each releases as release (release.key)} + + {#snippet prepend()} + + {#snippet prepend()} + + + + {/snippet} + {release.label} + + {/snippet} + +
+ {release.version} + published {formatPublishDate(release.publish)} +
+
+ {/each} +
+
+ + diff --git a/src/components/drawer.svelte b/src/components/drawer.svelte index a1980d95..a2abf9c1 100644 --- a/src/components/drawer.svelte +++ b/src/components/drawer.svelte @@ -1,23 +1,28 @@ {#if open}