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
22 changes: 22 additions & 0 deletions .changeset/links-to-unpublished-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@eventuras/lectio-docs": minor
---
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this at minor — deliberately, not by oversight.

You're right that ResolvedLink is a breaking change for a strictNullChecks consumer. But the package is at 0.4.0, and changesets bumps a major on a 0.x release straight to 1.0.0. So marking it major wouldn't communicate "breaking" — it would publish 1.0 and, with it, a stability guarantee this package isn't ready to make. Pre-1.0 is precisely the range where interfaces are still allowed to move; that is what the leading zero is for.

The break is called out in the changeset body, so it lands in the changelog where a consumer will read it.

Worth revisiting the moment we cut 1.0 — from then on this is exactly the right call.


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.
34 changes: 30 additions & 4 deletions packages/lectio-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,43 @@ 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
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.
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)

Expand Down
49 changes: 34 additions & 15 deletions packages/lectio-docs/src/collector/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ interface CollectOptions {
export async function collect({ rootDir, config, configDir }: CollectOptions): Promise<void> {
// 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 ?? [];
Expand All @@ -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,
});

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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('/') || '.';
}
Comment on lines 196 to 214

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 61f2d8d. This is the sharpest of the four: the commit claims to fix ../ in slugs, and left the bug live for brace patterns. Reproduced end to end against docs/{a,b}/**/*.md:

slug: "/one"          ← docs/a/one.md
slug: "/../b/two"     ← docs/b/two.md

getGlobBase now reduces the task bases to the deepest directory they share, rather than taking the first. Same fixture after:

docs/{a,b}/**/*.md      docs/**/*.md
  "/a/one"                "/a/one"
  "/b/two"                "/b/two"

Identical to the non-brace glob over the same tree, which is the property that was missing.


/**
Expand Down
19 changes: 19 additions & 0 deletions packages/lectio-docs/src/collector/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions packages/lectio-docs/src/content/content-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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 };
Comment on lines 156 to +162

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 61f2d8d. You're right that this undid the guarantee established one PR ago — reproduced before touching anything:

../../../../secrets.md → https://github.com/org/repo/blob/main/../../../secrets.md

resolveLink now returns null when the resolved target sits above the collection root, as you suggested. There is no file there to name, so there is nothing for a host to link to — and handing it a path anyway is exactly the outcome the ..-preserving behaviour in paths.ts exists to produce.

Verified that the case this PR is actually for still works:

../../../../secrets.md   null
../CONTRIBUTING.md       no page  → …/blob/main/CONTRIBUTING.md
terms.md                 /terms   → …/blob/main/docs/terms.md

}

function safeDecode(value: string): string {
Expand Down
26 changes: 24 additions & 2 deletions packages/lectio-docs/src/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}