-
Notifications
You must be signed in to change notification settings - Fork 0
feat(lectio-docs): links to files you don't publish go to the forge #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ?? []; | ||
|
|
@@ -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('/') || '.'; | ||
| } | ||
|
Comment on lines
196
to
214
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Identical to the non-brace glob over the same tree, which is the property that was missing. |
||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }; | ||
|
Comment on lines
156
to
+162
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Verified that the case this PR is actually for still works: |
||
| } | ||
|
|
||
| function safeDecode(value: string): string { | ||
|
|
||
There was a problem hiding this comment.
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
ResolvedLinkis a breaking change for astrictNullChecksconsumer. But the package is at 0.4.0, and changesets bumps amajoron 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.