diff --git a/.changeset/links-to-unpublished-files.md b/.changeset/links-to-unpublished-files.md new file mode 100644 index 0000000..8726dac --- /dev/null +++ b/.changeset/links-to-unpublished-files.md @@ -0,0 +1,22 @@ +--- +"@eventuras/lectio-docs": minor +--- + +Links to files you don't publish now go to the forge instead of nowhere. + +Documentation routinely links to files outside the collected set — a README the +globs didn't cover, a section a deployment leaves out. `resolveLink` used to +return null for those, indistinguishable from "not a link at all", so a host +could only leave them as authored and let them 404. It now resolves them with a +null `page` and the repo-relative `path`, and `source.sourceHref(path)` turns +that into a forge URL from the new `sourceUrl` template — recorded in the +manifest, since the host that renders links reads that rather than the config. + +`DocSource.ignore` leaves part of a tree out of a source without narrowing the +glob into something unreadable: `ignore: ['docs/ADR/**']`. + +Fixes a collector bug that made either of those awkward: the static base of a +glob was found by scanning for `*`, `{` and `?`, which missed character classes +and extglob — `docs/!(ADR)/**/*.md` was rooted at the literal directory +`docs/!(ADR)`, and every page collected through it got a slug with `../` in it. +fast-glob is now asked for the base rather than second-guessed. diff --git a/packages/lectio-docs/README.md b/packages/lectio-docs/README.md index 57b5d5a..95315af 100644 --- a/packages/lectio-docs/README.md +++ b/packages/lectio-docs/README.md @@ -95,7 +95,7 @@ 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: '' } +// → { page: { slug: '/terms', locale: 'nb', … }, path: 'nb/terms-of-use.md', suffix: '' } ``` Resolving against the source rather than the bare filename is what lets two @@ -103,9 +103,35 @@ sections each hold a `config.md` — `[overview](../guides/config.md)` still lan 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. +Off-site, root-relative and anchor-only hrefs return null — leave those as the +author wrote them. + +### Links to files you don't publish + +Documentation links to files that aren't in the collected set: a README outside +the globs, a section a deployment leaves out. Those still resolve, with a null +`page`, so they can go to the forge rather than nowhere. Give the collector a +`sourceUrl` and the template travels in the manifest: + +```ts +export default defineDocsConfig({ + output: '.lectio', + sourceUrl: 'https://github.com/org/repo/blob/main/{path}', + sources: [{ glob: 'docs/**/*.md', target: '/', ignore: ['docs/ADR/**'] }], +}); +``` + +```ts +const link = source.resolveLink(href, page.source); +if (link) { + const target = link.page + ? toPageUrl(link.page.slug) // yours to shape + : source.sourceHref(link.path); // null if no sourceUrl is configured +} +``` + +`ignore` is how a source leaves part of a tree out — decision records, drafts — +without narrowing the glob into something unreadable. ## Languages (opt-in) diff --git a/packages/lectio-docs/src/collector/collect.ts b/packages/lectio-docs/src/collector/collect.ts index dc4bbba..94bf05d 100644 --- a/packages/lectio-docs/src/collector/collect.ts +++ b/packages/lectio-docs/src/collector/collect.ts @@ -32,10 +32,15 @@ interface CollectOptions { export async function collect({ rootDir, config, configDir }: CollectOptions): Promise { // Fail fast on a template without the placeholder — it would otherwise // silently resolve to the same edit URL for every page. - if (config.editUrl && !config.editUrl.includes('{path}')) { - throw new Error( - `docs config: editUrl must contain a {path} placeholder, got "${config.editUrl}"`, - ); + for (const [name, template] of [ + ['editUrl', config.editUrl], + ['sourceUrl', config.sourceUrl], + ] as const) { + if (template && !template.includes('{path}')) { + throw new Error( + `docs config: ${name} must contain a {path} placeholder, got "${template}"`, + ); + } } const locales = config.locales ?? []; @@ -61,7 +66,7 @@ export async function collect({ rootDir, config, configDir }: CollectOptions): P for (const source of config.sources) { const files = await fg(source.glob, { cwd: rootDir, - ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**'], + ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...(source.ignore ?? [])], dot: false, }); @@ -125,7 +130,7 @@ export async function collect({ rootDir, config, configDir }: CollectOptions): P warnOnSlugDisagreement(pages, locales); - const manifest: Manifest = { version: 1, pages }; + const manifest: Manifest = { version: 1, pages, sourceUrl: config.sourceUrl }; const manifestPath = join(outputDir, 'manifest.json'); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); @@ -180,18 +185,32 @@ function buildTargetPath( } /** - * Get the static base directory from a glob pattern. - * "docs/(star)(star)/(star).mdx" -> "docs" - * "libs/(star)/README.md" -> "libs" + * The static base directory a glob starts from, so a collected file keeps its + * path below that base — `docs/**\/*.md` is rooted at `docs`. + * + * fast-glob is asked rather than told: recognising which characters make a + * segment dynamic means knowing about `*`, braces, character classes and + * extglob (`!(ADR)`), and getting that list short by one silently produces + * paths with `../` in them. */ function getGlobBase(glob: string): string { - const parts = glob.split('/'); - const staticParts: string[] = []; - for (const part of parts) { - if (part.includes('*') || part.includes('{') || part.includes('?')) break; - staticParts.push(part); + // One task per brace branch, each with its own base — `docs/{a,b}/**` gives + // `docs/a` and `docs/b`. Root at what they share, or files from every branch + // but the first sit outside the base and slug with `../` in them. + const bases = fg.generateTasks(glob).map((task) => task.base); + return bases.length === 0 ? '.' : bases.reduce(commonBase); +} + +/** The deepest directory two paths share, `.` when they share none. */ +function commonBase(a: string, b: string): string { + const left = a.split('/'); + const right = b.split('/'); + const shared: string[] = []; + for (let i = 0; i < Math.min(left.length, right.length); i++) { + if (left[i] !== right[i]) break; + shared.push(left[i] as string); } - return staticParts.join('/') || '.'; + return shared.join('/') || '.'; } /** diff --git a/packages/lectio-docs/src/collector/config.ts b/packages/lectio-docs/src/collector/config.ts index 3960e3f..be8e8ed 100644 --- a/packages/lectio-docs/src/collector/config.ts +++ b/packages/lectio-docs/src/collector/config.ts @@ -5,6 +5,13 @@ export interface DocSource { /** Target path in output directory, e.g. "/" or "/libraries" */ target: string; + /** + * Globs to leave out of this source, e.g. `['docs/ADR/**']` to publish the + * documentation without its decision records. Relative to the repo root, the + * same as `glob`. `node_modules`, `dist` and `.next` are always excluded. + */ + ignore?: string[]; + /** Read title from nearest package.json "name" field (strips @scope/) */ titleFromPackageJson?: boolean; @@ -35,6 +42,18 @@ export interface DocsConfig { */ editUrl?: string; + /** + * Template for linking to a source file on its forge, `{path}` replaced by + * the file's repo-relative path — `https://github.com/org/repo/blob/main/{path}`. + * + * Documentation links to files that aren't published: a README outside the + * collected globs, a section this deployment leaves out. With a template, + * those links go to the forge instead of nowhere; without one, they are left + * as the author wrote them. Recorded in the manifest, since the host that + * renders the links reads that rather than this config. + */ + sourceUrl?: string; + /** * Locales this documentation set is written in, as BCP-47 tags, e.g. * `['en', 'nb']`. Only these are recognised in a filename suffix or a path diff --git a/packages/lectio-docs/src/content/content-source.ts b/packages/lectio-docs/src/content/content-source.ts index 15990bf..8ec877c 100644 --- a/packages/lectio-docs/src/content/content-source.ts +++ b/packages/lectio-docs/src/content/content-source.ts @@ -102,6 +102,16 @@ export function createContentSource({ resolveLink(href, fromSource) { return resolveLink(href, fromSource, bySource); }, + sourceHref(path) { + const template = manifest.sourceUrl; + if (template === undefined) return null; + const segments = path.split('/'); + // `.` and `..` would resolve against the template's own path and land + // somewhere other than the file asked for, so they get no URL at all. + if (segments.some((segment) => segment === '.' || segment === '..')) return null; + // Per segment: a filename may hold spaces or `#`, and `/` must survive. + return template.replaceAll('{path}', segments.map(encodeURIComponent).join('/')); + }, async getPage(slug, locale = defaultLocale) { const versions = bySlug.get(normalizeSlug(slug)); const meta = versions === undefined ? undefined : resolve(versions, locale); @@ -125,8 +135,9 @@ const ABSOLUTE_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; * 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. + * Null for hrefs that are not relative links to markdown at all — off-site, + * root-relative, anchor-only. A link to a file the collected set doesn't hold + * still resolves, with a null `page`, so the host can send it to the forge. */ function resolveLink( href: string, @@ -143,8 +154,12 @@ function resolveLink( if (!/\.mdx?$/i.test(path)) return null; const target = resolveRelativePath(fromSource, safeDecode(path)); - const page = bySource.get(target); - return page === undefined ? null : { page, suffix }; + // Above the collection root: resolveRelativePath keeps the `..` it could not + // consume, and there is no file here to name. Resolving it with a null page + // would hand the host a path to send to the forge — the "points somewhere + // unintended" outcome this whole function exists to rule out. + if (target === '..' || target.startsWith('../')) return null; + return { page: bySource.get(target) ?? null, path: target, suffix }; } function safeDecode(value: string): string { diff --git a/packages/lectio-docs/src/content/types.ts b/packages/lectio-docs/src/content/types.ts index 10a4bbf..970887b 100644 --- a/packages/lectio-docs/src/content/types.ts +++ b/packages/lectio-docs/src/content/types.ts @@ -48,6 +48,13 @@ export interface Manifest { version: 1; /** All collected pages, in collection order. */ pages: PageMeta[]; + /** + * Template for linking to a source file on its forge, `{path}` replaced by + * the file's repo-relative path — from `DocsConfig.sourceUrl`. Recorded here + * so it travels with the content: a host reads the manifest, not the config, + * and only the collector knows which repo the files came from. + */ + sourceUrl?: string; } /** A page with its body loaded. */ @@ -66,9 +73,18 @@ export interface TreeNode { children: TreeNode[]; } -/** A link resolved to the page it points at, and whatever followed the path. */ +/** A link resolved to the file it points at, and whatever followed the path. */ export interface ResolvedLink { - page: PageMeta; + /** + * The page for that file, or null when the collected set doesn't hold one — + * documentation routinely links to files that aren't published, a README a + * source didn't cover or a section a deployment leaves out. The link is + * still resolved, so a host can send it to the forge via + * {@link ContentSource.sourceHref} instead of rendering a dead end. + */ + page: PageMeta | null; + /** The file the link resolved to, relative to the repo root. */ + path: string; /** The `#fragment` or `?query` the href carried, or an empty string. */ suffix: string; } @@ -126,4 +142,10 @@ export interface ContentSource { * reads as a broken link instead of pointing somewhere unintended. */ resolveLink(href: string, fromSource: string): ResolvedLink | null; + /** + * Where a source file lives on its forge, from the manifest's `sourceUrl` + * template. Null when the manifest carries none. Use it for the links + * `resolveLink` resolves to a file with no page. + */ + sourceHref(path: string): string | null; }