diff --git a/.changeset/runtime-manifests-and-links.md b/.changeset/runtime-manifests-and-links.md new file mode 100644 index 0000000..df365f9 --- /dev/null +++ b/.changeset/runtime-manifests-and-links.md @@ -0,0 +1,24 @@ +--- +"@eventuras/lectio-docs": minor +--- + +Build a manifest without `collect()`, and resolve the links documents make to +each other. + +`pathToPage` exports the file-to-page logic the collector uses — the slug a path +gets, and the language it is written in — so a host whose content changes +without a rebuild can assemble a manifest at runtime and still get the tree, +the locale fallback and the search index. `pathToSlug`, `pathToLocale`, +`normalizeSlug` and `resolveRelativePath` come with it, all pure and +dependency-free. + +`source.resolveLink(href, fromSource)` maps a relative `*.md` link to the page +it means, resolved against the source path of the document containing it rather +than against a bare filename. Two sections can then each hold a `config.md` +without `[overview](../guides/config.md)` becoming ambiguous, and a link from +`nb/privacy.md` to `terms.md` lands on the Norwegian version of that page. + +A `slug:` in frontmatter now overrides the path-derived slug, so a document can +keep a short, stable URL while its filename stays descriptive. Translations that +disagree on the slug they declare are warned about while collecting — they would +otherwise quietly stop being one page. diff --git a/packages/lectio-docs/README.md b/packages/lectio-docs/README.md index ac69af6..57b5d5a 100644 --- a/packages/lectio-docs/README.md +++ b/packages/lectio-docs/README.md @@ -41,7 +41,9 @@ original path), and a `manifest.json` is written alongside — a flat list of pages with slugs, titles and file paths. `README.md` becomes a page named after its parent directory, so -`libs/event-sdk/README.md` → `/libraries/event-sdk`. +`libs/event-sdk/README.md` → `/libraries/event-sdk`. A `slug:` in frontmatter +overrides the path, so a document can keep a short, stable URL while its +filename stays descriptive — `terms-of-use.md` with `slug: terms` is `/terms`. ## 3. Read it back @@ -66,6 +68,45 @@ await source.getPage('/libraries/event-sdk'); // metadata + raw markdown body `fs` in Node (works for SSR and prerendering), `fetch` in a SPA, `import.meta.glob` with a bundler. Rendering the markdown is entirely yours. +### Without a build step + +A manifest is only data, so a host whose content changes without a rebuild — a +directory mounted into a container, a CMS export — can build one itself. +`pathToPage` is the same file-to-page logic `collect()` uses, exported: + +```ts +import { pathToPage, parseFrontmatter, createContentSource } from '@eventuras/lectio-docs/content'; + +const pages = files.map((file) => { // file is relative to the content root + const { frontmatter } = parseFrontmatter(read(file)); + const { slug, locale } = pathToPage(file, { locales: ['en', 'nb'], frontmatter }); + return { slug, locale, title: String(frontmatter.title ?? slug), source: file, file, frontmatter }; +}); + +const source = createContentSource({ manifest: { version: 1, pages }, loadBody, defaultLocale: 'en' }); +``` + +## Links between documents + +Documentation is written to read on disk and on a forge as well as in a host, so +documents link to each other by path. `resolveLink` maps such a link to the page +it means, resolved against the **source path of the document containing it**: + +```ts +const page = await source.getPage('/privacy', 'nb'); +const link = source.resolveLink('terms-of-use.md', page.source); +// → { page: { slug: '/terms', locale: 'nb', … }, suffix: '' } +``` + +Resolving against the source rather than the bare filename is what lets two +sections each hold a `config.md` — `[overview](../guides/config.md)` still lands +on the right one — and it settles language on the way: the link above came from +`nb/privacy-policy.md`, so it resolved to the Norwegian `/terms`. + +Off-site, root-relative and anchor-only hrefs return null, as do files the +manifest doesn't hold. Leave those as the author wrote them: a typo should read +as a broken link, not point somewhere unintended. + ## Languages (opt-in) List the locales a documentation set is written in, and translations of a @@ -146,7 +187,7 @@ hook wraps the provider with debouncing and stale-response protection. | Import | Runs in | Contents | | --- | --- | --- | | `@eventuras/lectio-docs` | Node, build time | `collect`, `runCollect`, `defineDocsConfig` | -| `@eventuras/lectio-docs/content` | anywhere | `createContentSource`, `buildTree`, types | +| `@eventuras/lectio-docs/content` | anywhere | `createContentSource`, `buildTree`, `pathToPage`, `parseFrontmatter`, types | | `@eventuras/lectio-docs/search` | browser + Node | `OramaProvider`, `SearchProvider`/`SearchResult` types | | `@eventuras/lectio-docs/build-index` | Node, build time | `buildSearchIndex` | diff --git a/packages/lectio-docs/src/collector/collect.ts b/packages/lectio-docs/src/collector/collect.ts index d448f92..dc4bbba 100644 --- a/packages/lectio-docs/src/collector/collect.ts +++ b/packages/lectio-docs/src/collector/collect.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'; import fg from 'fast-glob'; import { parseFrontmatter } from '../content/frontmatter.js'; +import { normalizeSlug, pathToLocale, pathToSlug } from '../content/paths.js'; import type { Manifest, PageMeta } from '../content/types.js'; import type { DocSource, DocsConfig } from './config.js'; @@ -81,10 +82,14 @@ export async function collect({ rootDir, config, configDir }: CollectOptions): P writeFileSync(targetPath, enriched); const relTarget = relative(outputDir, targetPath).replaceAll('\\', '/'); - const slug = fileToSlug(relTarget, locales); + // `slug:` in frontmatter overrides the path — a document can keep a short, + // stable URL (`/terms`) while its file stays descriptive (terms-of-use.md). + const declaredSlug = typeof frontmatter.slug === 'string' ? frontmatter.slug.trim() : ''; + const slug = + declaredSlug === '' ? pathToSlug(relTarget, locales) : normalizeSlug(declaredSlug); // Read from the source path, not the target: a locale-named directory is // part of where the file came from and need not survive into the output. - const locale = detectLocale(file, frontmatter, locales, defaultLocale); + const locale = pathToLocale(file, { locales, defaultLocale, frontmatter }); // Only frontmatter can name an unlisted locale — a suffix or a directory // has to match `locales` to be read as one at all. Left in the manifest, // but said out loud: a typo here silently orphans a translation. @@ -118,6 +123,8 @@ export async function collect({ rootDir, config, configDir }: CollectOptions): P } } + warnOnSlugDisagreement(pages, locales); + const manifest: Manifest = { version: 1, pages }; const manifestPath = join(outputDir, 'manifest.json'); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); @@ -243,58 +250,7 @@ function enrichContent( return { content: formatFrontmatter(frontmatter) + body, frontmatter }; } -/** - * Map an output-relative file path to a URL slug. - * "index.md" → "/", "guides/index.md" → "/guides", "libraries/x.md" → "/libraries/x" - * - * A locale marker is dropped on the way — "terms.nb.md" and "nb/terms.md" both - * slug to "/terms" — so a document's translations share one URL. - */ -function fileToSlug(file: string, locales: string[] = []): string { - const segments = file - .replaceAll('\\', '/') - .split('/') - .filter((segment) => !locales.includes(segment)); - - let slug = '/' + segments.join('/'); - slug = slug.replace(/\.mdx?$/i, ''); - slug = stripLocaleSuffix(slug, locales); - slug = slug.replace(/\/index$/i, ''); - return slug || '/'; -} - -/** Drops a trailing "." from an extension-less path. */ -function stripLocaleSuffix(path: string, locales: string[]): string { - const lastDot = path.lastIndexOf('.'); - if (lastDot <= path.lastIndexOf('/')) return path; - return locales.includes(path.slice(lastDot + 1)) ? path.slice(0, lastDot) : path; -} - -/** - * The locale a document is written in: what its frontmatter declares, else a - * recognised filename suffix or path segment, else the configured default. - * See {@link DocsConfig.defaultLocale} for why frontmatter outranks the path. - */ -function detectLocale( - file: string, - frontmatter: Record, - locales: string[], - defaultLocale: string, -): string { - const declared = frontmatter.locale ?? frontmatter.language; - if (typeof declared === 'string' && declared.trim() !== '') return declared.trim(); - - const segments = file.replaceAll('\\', '/').split('/'); - // The name outranks the directory: `nb/terms.en.md` is English filed under a - // Norwegian directory, and naming the file is the more deliberate act. - const name = (segments.at(-1) ?? '').replace(/\.mdx?$/i, ''); - const suffix = name.slice(name.lastIndexOf('.') + 1); - if (locales.includes(suffix)) return suffix; - - const inPath = segments.slice(0, -1).find((segment) => locales.includes(segment)); - return inPath ?? defaultLocale; -} /** Fallback page title derived from the slug's last segment. */ function slugTitle(slug: string): string { @@ -343,3 +299,29 @@ function formatFrontmatter(data: Record): string { return `---\n${lines.join('\n')}\n---\n\n`; } + +/** + * A document's translations must agree on its slug, or they stop being one + * page. The path says which files belong together; a `slug:` that only some of + * them carry silently splits the set, so it is called out here rather than + * discovered as a missing translation later. + */ +function warnOnSlugDisagreement(pages: PageMeta[], locales: string[]): void { + if (locales.length === 0) return; + + const byPathSlug = new Map(); + for (const page of pages) { + const key = pathToSlug(page.file, locales); + byPathSlug.set(key, [...(byPathSlug.get(key) ?? []), page]); + } + + for (const [pathSlug, group] of byPathSlug) { + const declared = new Set(group.map((page) => page.slug)); + if (declared.size <= 1) continue; + console.warn( + ` ⚠ ${pathSlug}: translations disagree on their slug ` + + `(${[...declared].join(', ')}) — they will be separate pages. ` + + `Sources: ${group.map((page) => page.source).join(', ')}`, + ); + } +} diff --git a/packages/lectio-docs/src/content/content-source.ts b/packages/lectio-docs/src/content/content-source.ts index e96f2d9..15990bf 100644 --- a/packages/lectio-docs/src/content/content-source.ts +++ b/packages/lectio-docs/src/content/content-source.ts @@ -1,6 +1,12 @@ import { stripFrontmatter } from './frontmatter.js'; +import { normalizeSlug, resolveRelativePath } from './paths.js'; import { buildTree } from './tree.js'; -import type { ContentSource, CreateContentSourceOptions, PageMeta } from './types.js'; +import type { + ContentSource, + CreateContentSourceOptions, + PageMeta, + ResolvedLink, +} from './types.js'; /** Locale assumed for a page, and for a read, that names none. */ const FALLBACK_LOCALE = 'en'; @@ -26,6 +32,8 @@ export function createContentSource({ // Slug → its versions, keyed by locale. Insertion order is manifest order, // which getPages/getTree preserve. const bySlug = new Map>(); + // Original path → page, for resolving the links documents make to each other. + const bySource = new Map(); const locales: string[] = []; for (const page of pages) { @@ -48,6 +56,7 @@ export function createContentSource({ } versions.set(locale, page); if (!locales.includes(locale)) locales.push(locale); + bySource.set(page.source.replaceAll('\\', '/'), page); } /** The version of a page closest to `locale`: it, else the default, else any. */ @@ -90,6 +99,9 @@ export function createContentSource({ getLocales() { return [...locales]; }, + resolveLink(href, fromSource) { + return resolveLink(href, fromSource, bySource); + }, async getPage(slug, locale = defaultLocale) { const versions = bySlug.get(normalizeSlug(slug)); const meta = versions === undefined ? undefined : resolve(versions, locale); @@ -100,9 +112,45 @@ export function createContentSource({ }; } -/** Tolerate a missing leading slash and a trailing slash when looking up a page. */ -function normalizeSlug(slug: string): string { - let s = slug.startsWith('/') ? slug : `/${slug}`; - if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1); - return s; +// `scheme:` or protocol-relative `//host` — anything that leaves this origin. +const ABSOLUTE_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; + +/** + * The page a relative `*.md` link points at, resolved against the path of the + * document containing it. + * + * Resolving against the *source* rather than the filename is what makes nested + * documentation work: two sections can each hold a `config.md`, and + * `[overview](../guides/config.md)` still lands on the right one. It also + * settles language for free — a link from `nb/privacy.md` to `terms.md` + * resolves to `nb/terms.md`, the Norwegian version of that page. + * + * Off-site, root-relative and anchor-only hrefs, and files the manifest does + * not hold, all return null: the host leaves those alone rather than guessing. + */ +function resolveLink( + href: string, + fromSource: string, + bySource: Map, +): ResolvedLink | null { + if (href === '' || href.startsWith('#') || href.startsWith('/') || ABSOLUTE_HREF.test(href)) { + return null; + } + + const suffixAt = href.search(/[#?]/); + const path = suffixAt === -1 ? href : href.slice(0, suffixAt); + const suffix = suffixAt === -1 ? '' : href.slice(suffixAt); + if (!/\.mdx?$/i.test(path)) return null; + + const target = resolveRelativePath(fromSource, safeDecode(path)); + const page = bySource.get(target); + return page === undefined ? null : { page, suffix }; +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } } diff --git a/packages/lectio-docs/src/content/index.ts b/packages/lectio-docs/src/content/index.ts index ad60e7c..a416bd4 100644 --- a/packages/lectio-docs/src/content/index.ts +++ b/packages/lectio-docs/src/content/index.ts @@ -1,6 +1,14 @@ export { createContentSource } from './content-source.js'; export { buildTree } from './tree.js'; export { parseFrontmatter, stripFrontmatter } from './frontmatter.js'; +export { + normalizeSlug, + pathToLocale, + pathToPage, + pathToSlug, + resolveRelativePath, +} from './paths.js'; +export type { PagePath, PathToPageOptions } from './paths.js'; export type { Frontmatter } from './frontmatter.js'; export type { Manifest, @@ -10,4 +18,5 @@ export type { ContentSource, CreateContentSourceOptions, LoadBody, + ResolvedLink, } from './types.js'; diff --git a/packages/lectio-docs/src/content/paths.ts b/packages/lectio-docs/src/content/paths.ts new file mode 100644 index 0000000..0287948 --- /dev/null +++ b/packages/lectio-docs/src/content/paths.ts @@ -0,0 +1,160 @@ +/** + * Turning a file path into a page: the slug it gets, and the language it is + * written in. Pure string work, no filesystem and no `node:path`, so a host + * that reads its content at runtime can build a manifest with exactly the + * logic `collect()` uses at build time. + */ + +/** A file path's page identity. */ +export interface PagePath { + /** URL path, always starting with "/". */ + slug: string; + /** BCP-47 locale tag the document is written in. */ + locale: string; +} + +export interface PathToPageOptions { + /** Locales recognised in a filename suffix or a path segment. */ + locales?: readonly string[]; + /** Locale for a document that declares none. Defaults to `"en"`. */ + defaultLocale?: string; + /** + * The document's frontmatter, when the caller has read it. `slug:` overrides + * the path-derived slug, and `locale:`/`language:` the path-derived locale. + */ + frontmatter?: Record; +} + +/** + * The slug and locale a file path resolves to. + * + * `file` is relative to the **root of the content set** — the directory the + * slugs are counted from — not to the repository. A host scanning a mounted + * `/app/content` passes `nb/terms-of-use.md`, not `content/nb/terms-of-use.md`, + * or the extra segment lands in the URL. + * + * ```ts + * pathToPage('nb/terms-of-use.md', { locales: ['en', 'nb'] }) + * // → { slug: '/terms-of-use', locale: 'nb' } + * + * pathToPage('nb/terms-of-use.md', { + * locales: ['en', 'nb'], + * frontmatter: { slug: 'terms' }, + * }) + * // → { slug: '/terms', locale: 'nb' } + * ``` + * + * The locale marker is dropped from the slug in every form, so `terms.md`, + * `terms.nb.md` and `nb/terms.md` are one page in three languages. + */ +export function pathToPage(file: string, options: PathToPageOptions = {}): PagePath { + const locales = options.locales ?? []; + const frontmatter = options.frontmatter ?? {}; + const declaredSlug = asNonEmptyString(frontmatter.slug); + + return { + slug: declaredSlug === null ? pathToSlug(file, locales) : normalizeSlug(declaredSlug), + locale: pathToLocale(file, { + locales, + defaultLocale: options.defaultLocale ?? 'en', + frontmatter, + }), + }; +} + +/** + * Map a file path to a URL slug: `index.md` → `/`, `guides/index.md` → + * `/guides`, `libraries/x.md` → `/libraries/x`. Locale markers are dropped, so + * translations of a document share one URL. + */ +export function pathToSlug(file: string, locales: readonly string[] = []): string { + const segments = toPosix(file) + .split('/') + .filter((segment) => segment !== '' && !locales.includes(segment)); + + let slug = '/' + segments.join('/'); + slug = slug.replace(/\.mdx?$/i, ''); + slug = stripLocaleSuffix(slug, locales); + slug = slug.replace(/\/index$/i, ''); + return slug || '/'; +} + +/** + * The locale a document is written in: what its frontmatter declares, else a + * recognised filename suffix or path segment, else the default. + * + * Frontmatter outranks the path because it is the only mechanism available + * when the filename isn't the author's to choose — a package's `README.md`. + */ +export function pathToLocale( + file: string, + options: { locales?: readonly string[]; defaultLocale?: string; frontmatter?: Record } = {}, +): string { + const locales = options.locales ?? []; + const frontmatter = options.frontmatter ?? {}; + + const declared = asNonEmptyString(frontmatter.locale ?? frontmatter.language); + if (declared !== null) return declared; + + const segments = toPosix(file).split('/'); + + // The name outranks the directory: `nb/terms.en.md` is English filed under a + // Norwegian directory, and naming the file is the more deliberate act. + const name = (segments.at(-1) ?? '').replace(/\.mdx?$/i, ''); + const suffix = name.slice(name.lastIndexOf('.') + 1); + if (locales.includes(suffix)) return suffix; + + return segments.slice(0, -1).find((segment) => locales.includes(segment)) ?? options.defaultLocale ?? 'en'; +} + +/** Drops a trailing `.` from an extension-less path. */ +export function stripLocaleSuffix(path: string, locales: readonly string[]): string { + const lastDot = path.lastIndexOf('.'); + if (lastDot <= path.lastIndexOf('/')) return path; + return locales.includes(path.slice(lastDot + 1)) ? path.slice(0, lastDot) : path; +} + +/** A leading slash, and no trailing one — the shape every slug is compared in. */ +export function normalizeSlug(slug: string): string { + const withLeading = slug.startsWith('/') ? slug : `/${slug}`; + return withLeading.length > 1 && withLeading.endsWith('/') + ? withLeading.slice(0, -1) + : withLeading; +} + +/** + * Resolve a relative path against the directory of `from`, the way a link in a + * markdown file reads on disk. POSIX semantics only — these are repo-relative + * paths, never platform paths. + */ +export function resolveRelativePath(from: string, relative: string): string { + const rooted = relative.startsWith('/'); + const segments = rooted ? [] : toPosix(from).split('/').slice(0, -1); + + for (const segment of toPosix(relative).replace(/^\//, '').split('/')) { + if (segment === '' || segment === '.') continue; + if (segment !== '..') { + segments.push(segment); + } else if (segments.length > 0 && segments.at(-1) !== '..') { + segments.pop(); + } else if (!rooted) { + // Climbing past the start keeps the `..`, the way POSIX normalize does. + // Swallowing it would let `../../../c.md` land on a real `c.md` near the + // top — a link pointing somewhere unintended, which is the one outcome + // resolveLink exists to avoid. Left in, nothing in the manifest matches. + segments.push('..'); + } + } + + return segments.join('/'); +} + +function toPosix(path: string): string { + return path.replaceAll('\\', '/'); +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} diff --git a/packages/lectio-docs/src/content/types.ts b/packages/lectio-docs/src/content/types.ts index 0cb8d56..10a4bbf 100644 --- a/packages/lectio-docs/src/content/types.ts +++ b/packages/lectio-docs/src/content/types.ts @@ -66,6 +66,13 @@ export interface TreeNode { children: TreeNode[]; } +/** A link resolved to the page it points at, and whatever followed the path. */ +export interface ResolvedLink { + page: PageMeta; + /** The `#fragment` or `?query` the href carried, or an empty string. */ + suffix: string; +} + /** Host-injected body loader. Receives a page's metadata (use `page.file`). */ export type LoadBody = (page: PageMeta) => string | Promise; @@ -104,4 +111,19 @@ export interface ContentSource { * `[defaultLocale]` rather than nothing — one locale, not zero. */ getLocales(): string[]; + /** + * The page a relative `*.md` link points at, resolved against the `source` + * path of the document containing it — pass `page.source`. + * + * Documentation is authored to read on disk and on a forge as well as in a + * host, so documents link to each other by path. Resolving against the + * source rather than the bare filename is what lets two sections each hold a + * `config.md`, and settles language on the way: a link from `nb/privacy.md` + * to `terms.md` lands on the Norwegian version of that page. + * + * Null for off-site, root-relative and anchor-only hrefs, and for files the + * manifest doesn't hold — leave those as the author wrote them, so a typo + * reads as a broken link instead of pointing somewhere unintended. + */ + resolveLink(href: string, fromSource: string): ResolvedLink | null; }