Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .changeset/runtime-manifests-and-links.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 43 additions & 2 deletions packages/lectio-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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` |

Expand Down
88 changes: 35 additions & 53 deletions packages/lectio-docs/src/collector/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 ".<locale>" 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<string, unknown>,
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 {
Expand Down Expand Up @@ -343,3 +299,29 @@ function formatFrontmatter(data: Record<string, unknown>): 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<string, PageMeta[]>();
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(', ')}`,
);
}
}
60 changes: 54 additions & 6 deletions packages/lectio-docs/src/content/content-source.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, Map<string, PageMeta>>();
// Original path → page, for resolving the links documents make to each other.
const bySource = new Map<string, PageMeta>();
const locales: string[] = [];

for (const page of pages) {
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, PageMeta>,
): 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;
}
}
9 changes: 9 additions & 0 deletions packages/lectio-docs/src/content/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,4 +18,5 @@ export type {
ContentSource,
CreateContentSourceOptions,
LoadBody,
ResolvedLink,
} from './types.js';
Loading