svelte-docsmith 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/svelte-docsmith.mjs +13 -158
  2. package/dist/buildtime/archive.js +5 -3
  3. package/dist/buildtime/archives.d.ts +18 -0
  4. package/dist/buildtime/archives.js +43 -0
  5. package/dist/buildtime/cli/archive-version.d.ts +18 -0
  6. package/dist/buildtime/cli/archive-version.js +104 -0
  7. package/dist/buildtime/cli/error.d.ts +9 -0
  8. package/dist/buildtime/cli/error.js +9 -0
  9. package/dist/buildtime/cli/run.d.ts +13 -0
  10. package/dist/buildtime/cli/run.js +62 -0
  11. package/dist/buildtime/{vite/git.d.ts → git.d.ts} +1 -1
  12. package/dist/buildtime/{vite/git.js → git.js} +15 -3
  13. package/dist/buildtime/paths.d.ts +12 -0
  14. package/dist/buildtime/paths.js +23 -0
  15. package/dist/buildtime/vite/collect.d.ts +25 -16
  16. package/dist/buildtime/vite/collect.js +100 -106
  17. package/dist/buildtime/vite/extract.d.ts +0 -2
  18. package/dist/buildtime/vite/extract.js +1 -1
  19. package/dist/buildtime/vite/index.d.ts +0 -2
  20. package/dist/buildtime/vite/index.js +46 -22
  21. package/dist/buildtime/vite/pages.d.ts +51 -0
  22. package/dist/buildtime/vite/pages.js +58 -0
  23. package/dist/buildtime/vite/releases.d.ts +0 -2
  24. package/dist/buildtime/vite/releases.js +1 -6
  25. package/dist/components/changelog/changelog-entry.svelte +6 -1
  26. package/dist/components/chrome/search.svelte +8 -5
  27. package/dist/components/chrome/version-banner.svelte +5 -20
  28. package/dist/components/chrome/version-banner.svelte.d.ts +3 -3
  29. package/dist/components/chrome/version-switcher.svelte +39 -49
  30. package/dist/components/chrome/version-switcher.svelte.d.ts +3 -9
  31. package/dist/components/docs-page-context.d.ts +20 -0
  32. package/dist/components/docs-page-context.js +41 -0
  33. package/dist/components/layouts/copy-page-menu.svelte +4 -2
  34. package/dist/components/layouts/docs-header.svelte +18 -28
  35. package/dist/components/layouts/docs-header.svelte.d.ts +1 -9
  36. package/dist/components/layouts/docs-mobile-header.svelte +6 -21
  37. package/dist/components/layouts/docs-mobile-header.svelte.d.ts +1 -9
  38. package/dist/components/layouts/docs-shell.svelte +29 -28
  39. package/dist/components/layouts/docs-sidebar.svelte +1 -1
  40. package/dist/components/layouts/seo-head.svelte +7 -5
  41. package/dist/core/changelog.d.ts +7 -1
  42. package/dist/core/content.d.ts +6 -1
  43. package/dist/core/docs-page.d.ts +15 -0
  44. package/dist/core/docs-page.js +22 -7
  45. package/dist/core/index.d.ts +1 -1
  46. package/dist/core/version.d.ts +36 -0
  47. package/dist/core/version.js +105 -8
  48. package/dist/generate/feed.d.ts +4 -1
  49. package/dist/generate/feed.js +7 -19
  50. package/dist/generate/llms.d.ts +4 -1
  51. package/dist/generate/llms.js +3 -2
  52. package/dist/generate/sitemap.d.ts +4 -1
  53. package/dist/generate/sitemap.js +3 -16
  54. package/dist/generate/xml.d.ts +12 -0
  55. package/dist/generate/xml.js +27 -0
  56. package/dist/search/context.svelte.d.ts +0 -2
  57. package/dist/search/context.svelte.js +0 -2
  58. package/dist/utils/url.d.ts +54 -0
  59. package/dist/utils/url.js +84 -0
  60. package/package.json +1 -1
  61. package/dist/utils/normalize-path.d.ts +0 -6
  62. package/dist/utils/normalize-path.js +0 -8
@@ -1,9 +1,51 @@
1
- import fs from 'node:fs';
1
+ /**
2
+ * The three generated indexes, each a projection of the same page list. Nothing
3
+ * here reads the filesystem or spawns a process: a build hands these functions
4
+ * pages read by `./pages.js`, and a test hands them literals. Both go down the
5
+ * same path, so what the sidebar, search and llms.txt show is decided in one
6
+ * place and can be checked without a docs root on disk.
7
+ */
2
8
  import path from 'node:path';
3
- import { listPageFiles } from './pages.js';
4
- import { parseFrontmatter } from './frontmatter.js';
9
+ import { firstSegmentUnder } from '../../utils/url.js';
10
+ import { docsBaseFrom, urlFor } from '../paths.js';
11
+ import { titleOf } from './pages.js';
5
12
  import { extractLlmsContent, extractSearchText, extractToc, readingMinutes } from './extract.js';
6
- import { lastCommitDate } from './git.js';
13
+ /**
14
+ * Resolve every titled page against the site's layout. Shared by all three
15
+ * indexes so they can never disagree about which pages exist or where they live.
16
+ */
17
+ function indexedPages(pages, options) {
18
+ const resolved = [];
19
+ const docsBase = docsBaseFrom(options.routesDir, options.contentDir);
20
+ for (const page of pages) {
21
+ const title = titleOf(page);
22
+ if (title === undefined)
23
+ continue;
24
+ const url = urlFor(options.routesDir, path.dirname(page.file));
25
+ resolved.push({ ...page, url, title, version: versionOf(url) });
26
+ }
27
+ // Stable output keeps the generated modules diff-friendly across rebuilds.
28
+ return resolved.sort((a, b) => a.url.localeCompare(b.url));
29
+ /**
30
+ * A page belongs to an archived version when the first segment of its URL below
31
+ * the docs base is that archive's id; everything else is the current version,
32
+ * which lives unprefixed at the docs base.
33
+ *
34
+ * Decided in URL space, by the same rule `activeVersion` applies to the reader's
35
+ * pathname at runtime. Deciding it from the directory instead would be a second
36
+ * rule for the same question, in a second vocabulary, free to drift from the one
37
+ * the reader is actually served by. No versions ⇒ undefined, which keeps an
38
+ * unversioned site's index byte-for-byte what it was.
39
+ */
40
+ function versionOf(url) {
41
+ const { versions } = options;
42
+ if (!versions)
43
+ return undefined;
44
+ const segment = firstSegmentUnder(url, docsBase);
45
+ const archived = versions.archived?.find((v) => v.id === segment);
46
+ return archived ? archived.id : versions.current.id;
47
+ }
48
+ }
7
49
  /**
8
50
  * Read a page's `section` frontmatter: a string is one level, an array is a
9
51
  * nested group path. Non-string array members are dropped; anything else is
@@ -33,116 +75,68 @@ function sectionKey(value) {
33
75
  return Array.isArray(section) ? section.join(' / ') : section;
34
76
  }
35
77
  /**
36
- * Walk every nav-worthy page under `contentDir` once: a page is nav-worthy when
37
- * its frontmatter has a string `title`. Yields the raw source, parsed
38
- * frontmatter, derived URL, and title so every index (nav, search, llms) can be
39
- * built from a single read of each file.
78
+ * A frontmatter field, when it holds the type the index expects. Frontmatter is
79
+ * whatever the author typed, so a field of the wrong type is dropped rather than
80
+ * carried into the index as one.
40
81
  */
41
- function* eachTitledPage(contentDir, routesDir, versions) {
42
- for (const file of listPageFiles(contentDir)) {
43
- const source = fs.readFileSync(file, 'utf-8');
44
- const front = parseFrontmatter(source, file);
45
- if (typeof front.title !== 'string')
46
- continue;
47
- const dir = path.dirname(file);
48
- const url = '/' + path.relative(routesDir, dir).split(path.sep).join('/');
49
- yield { source, front, url, title: front.title, file, version: versionOf(dir) };
50
- }
51
- /**
52
- * A page belongs to an archived version when its first directory segment under
53
- * the content dir is that archive's id; everything else is the current
54
- * version, which lives unprefixed at the docs root. No versions ⇒ undefined,
55
- * which keeps an unversioned site's index byte-for-byte what it was.
56
- */
57
- function versionOf(dir) {
58
- if (!versions)
59
- return undefined;
60
- const firstSegment = path.relative(contentDir, dir).split(path.sep)[0];
61
- const archived = versions.archived?.find((v) => v.id === firstSegment);
62
- return archived ? archived.id : versions.current.id;
63
- }
82
+ function stringField(front, key) {
83
+ return typeof front[key] === 'string' ? front[key] : undefined;
84
+ }
85
+ /** As {@link stringField}, for the numeric fields. */
86
+ function numberField(front, key) {
87
+ return typeof front[key] === 'number' ? front[key] : undefined;
64
88
  }
65
89
  /**
66
- * Scan `contentDir` for `+page.md`/`+page.svx` files and read the frontmatter
67
- * fields the sidebar needs (plus the heading list for a server-rendered TOC and
68
- * an estimated reading time), deriving each page's URL from its directory
69
- * relative to `routesDir`. Pure and synchronous so it can be unit-tested.
90
+ * The sidebar's index: the frontmatter fields navigation needs, plus the heading
91
+ * list for a server-rendered TOC and an estimated reading time. Served as the
92
+ * eagerly-imported `svelte-docsmith/content` virtual module.
93
+ *
94
+ * `lastUpdated` is read off the page rather than looked up here, so pass pages
95
+ * that have been through `withCommitDates` or the column comes out blank.
70
96
  */
71
- export function collectDocs(contentDir, routesDir, versions) {
72
- if (!fs.existsSync(contentDir)) {
73
- console.warn(`[svelte-docsmith] content directory not found: ${contentDir}\n` +
74
- ` The sidebar will be empty. Create your doc pages there, or point docsmith() at the right place with \`content\`.`);
75
- return [];
76
- }
77
- const items = [];
78
- for (const { source, front, url, title, file, version } of eachTitledPage(contentDir, routesDir, versions)) {
79
- items.push({
80
- title,
81
- path: url,
82
- description: typeof front.description === 'string' ? front.description : undefined,
83
- section: readSection(front.section),
84
- order: typeof front.order === 'number' ? front.order : undefined,
85
- sourcePath: path.relative(process.cwd(), file).split(path.sep).join('/'),
86
- // Frontmatter wins over git so an archived page keeps the date it last
87
- // really changed: archiving copies every page in one commit, which would
88
- // otherwise stamp the whole archive with the day it was created.
89
- lastUpdated: typeof front.lastUpdated === 'string' ? front.lastUpdated : lastCommitDate(file),
90
- readingTime: readingMinutes(extractSearchText(source)),
91
- toc: extractToc(source),
92
- version
93
- });
94
- }
95
- if (items.length === 0) {
96
- console.warn(`[svelte-docsmith] no doc pages found under ${contentDir}\n` +
97
- ` Add \`+page.md\` files with at least a \`title:\` in their frontmatter to populate the sidebar.`);
98
- }
99
- // Stable output keeps the generated module diff-friendly across rebuilds.
100
- return items.sort((a, b) => a.path.localeCompare(b.path));
97
+ export function contentIndex(pages, options) {
98
+ return indexedPages(pages, options).map((page) => ({
99
+ title: page.title,
100
+ path: page.url,
101
+ description: stringField(page.frontmatter, 'description'),
102
+ section: readSection(page.frontmatter.section),
103
+ order: numberField(page.frontmatter, 'order'),
104
+ sourcePath: path.relative(process.cwd(), page.file).split(path.sep).join('/'),
105
+ lastUpdated: page.lastUpdated,
106
+ readingTime: readingMinutes(extractSearchText(page.source)),
107
+ toc: extractToc(page.source),
108
+ version: page.version
109
+ }));
101
110
  }
102
111
  /**
103
- * Build the search records for every page under `contentDir`: title, section,
104
- * description, heading list, and plain-text body. Served as the lazy-loaded
105
- * `svelte-docsmith/search` virtual module so search can index bodies without
106
- * bloating the eagerly-imported nav index. The missing-directory case is
107
- * already reported by {@link collectDocs}, so this stays quiet.
112
+ * The search index: title, section, description, heading list, and plain-text
113
+ * body. Served as the lazy-loaded `svelte-docsmith/search` virtual module so
114
+ * search can index bodies without bloating the eagerly-imported content index.
108
115
  */
109
- export function collectSearchDocs(contentDir, routesDir, versions) {
110
- if (!fs.existsSync(contentDir))
111
- return [];
112
- const docs = [];
113
- for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
114
- docs.push({
115
- path: url,
116
- title,
117
- section: sectionLabel(front.section),
118
- description: typeof front.description === 'string' ? front.description : undefined,
119
- headings: extractToc(source).map((entry) => entry.title),
120
- text: extractSearchText(source),
121
- version
122
- });
123
- }
124
- return docs.sort((a, b) => a.path.localeCompare(b.path));
116
+ export function searchIndex(pages, options) {
117
+ return indexedPages(pages, options).map((page) => ({
118
+ path: page.url,
119
+ title: page.title,
120
+ section: sectionLabel(page.frontmatter.section),
121
+ description: stringField(page.frontmatter, 'description'),
122
+ headings: extractToc(page.source).map((entry) => entry.title),
123
+ text: extractSearchText(page.source),
124
+ version: page.version
125
+ }));
125
126
  }
126
127
  /**
127
- * Build the LLM records for every page: title, section, description, and the
128
- * full markdown content. Served as the `svelte-docsmith/llms` virtual module and
129
- * consumed server-side by `llms.txt` / `llms-full.txt` routes, so it never ships
130
- * to the client.
128
+ * The LLM index: title, section, description, and the full markdown content.
129
+ * Served as the `svelte-docsmith/llms` virtual module and consumed server-side
130
+ * by `llms.txt` / `llms-full.txt` routes, so it never ships to the client.
131
131
  */
132
- export function collectLlmsDocs(contentDir, routesDir, versions) {
133
- if (!fs.existsSync(contentDir))
134
- return [];
135
- const docs = [];
136
- for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
137
- docs.push({
138
- path: url,
139
- title,
140
- section: sectionKey(front.section),
141
- order: typeof front.order === 'number' ? front.order : undefined,
142
- description: typeof front.description === 'string' ? front.description : undefined,
143
- content: extractLlmsContent(source, title),
144
- version
145
- });
146
- }
147
- return docs.sort((a, b) => a.path.localeCompare(b.path));
132
+ export function llmsIndex(pages, options) {
133
+ return indexedPages(pages, options).map((page) => ({
134
+ path: page.url,
135
+ title: page.title,
136
+ section: sectionKey(page.frontmatter.section),
137
+ order: numberField(page.frontmatter, 'order'),
138
+ description: stringField(page.frontmatter, 'description'),
139
+ content: extractLlmsContent(page.source, page.title),
140
+ version: page.version
141
+ }));
148
142
  }
@@ -3,8 +3,6 @@ export type TocEntry = {
3
3
  title: string;
4
4
  depth: 2 | 3;
5
5
  };
6
- /** Strip inline markdown so a heading's TOC label is plain text. */
7
- export declare function stripInlineMarkdown(text: string): string;
8
6
  /**
9
7
  * Reduce a markdown page to plain, searchable body text: prose and heading text
10
8
  * with frontmatter, `<script>`/`<style>` blocks, fenced code, HTML/Svelte tags,
@@ -5,7 +5,7 @@ function withoutSetupBlocks(body) {
5
5
  return body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
6
6
  }
7
7
  /** Strip inline markdown so a heading's TOC label is plain text. */
8
- export function stripInlineMarkdown(text) {
8
+ function stripInlineMarkdown(text) {
9
9
  return text
10
10
  .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
11
11
  .replace(/`([^`]+)`/g, '$1')
@@ -1,7 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import { type DocsVersions } from '../../core/version.js';
3
- export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
4
- export { collectReleases } from './releases.js';
5
3
  export interface DocsmithViteOptions {
6
4
  /** Directory scanned for doc pages. Default: `'src/routes/docs'`. */
7
5
  content?: string;
@@ -13,18 +13,19 @@
13
13
  * 3. The **`?source` transform** powering `LiveExample`: importing
14
14
  * `Component.svelte?source` yields that file's Shiki-highlighted source.
15
15
  *
16
- * The per-index scanning lives in `./collect.js`; this file owns the two Vite
17
- * plugins and re-exports the collectors for consumers and tests.
16
+ * Reading the docs root lives in `./pages.js` and building each index from what
17
+ * it read lives in `./collect.js`; this file owns the two Vite plugins, and is
18
+ * the only layer that reports to the console.
18
19
  */
19
20
  import fs from 'node:fs';
20
21
  import path from 'node:path';
21
22
  import { DEFAULT_THEMES, lazyHighlighter } from '../highlight.js';
22
- import { resolveVersions } from '../../core/version.js';
23
- import { isPageFile, listPageFiles } from './pages.js';
24
- import { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
23
+ import { checkVersions, resolveVersions } from '../../core/version.js';
24
+ import { ARCHIVE_MARKER, discoverArchives } from '../archives.js';
25
+ import { docsBaseFrom } from '../paths.js';
26
+ import { isPageFile, readSourcePages, withCommitDates } from './pages.js';
27
+ import { contentIndex, llmsIndex, searchIndex } from './collect.js';
25
28
  import { collectReleases } from './releases.js';
26
- export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
27
- export { collectReleases } from './releases.js';
28
29
  /**
29
30
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
30
31
  *
@@ -45,16 +46,30 @@ const LLMS_SPECIFIER = 'svelte-docsmith/llms';
45
46
  const VIRTUAL_LLMS_ID = '\0svelte-docsmith:llms';
46
47
  const CHANGELOG_SPECIFIER = 'svelte-docsmith/changelog';
47
48
  const VIRTUAL_CHANGELOG_ID = '\0svelte-docsmith:changelog';
49
+ /**
50
+ * Tell the author when their docs root produced nothing, and why. An empty
51
+ * sidebar is otherwise silent: the build succeeds and the site renders, so
52
+ * without this the first sign of a misconfigured `content` is a missing nav.
53
+ */
54
+ function warnAboutDocsRoot(contentDir, exists, indexed) {
55
+ if (!exists) {
56
+ console.warn(`[svelte-docsmith] content directory not found: ${contentDir}\n` +
57
+ ` The sidebar will be empty. Create your doc pages there, or point docsmith() at the right place with \`content\`.`);
58
+ }
59
+ else if (indexed === 0) {
60
+ console.warn(`[svelte-docsmith] no doc pages found under ${contentDir}\n` +
61
+ ` Add \`+page.md\` files with at least a \`title:\` in their frontmatter to populate the sidebar.`);
62
+ }
63
+ }
48
64
  function contentIndexPlugin(options) {
49
65
  const contentDir = path.resolve(options.content ?? 'src/routes/docs');
50
66
  const routesDir = path.resolve(options.routes ?? 'src/routes');
51
67
  const versions = options.versions;
52
- // The docs URL base, e.g. `/docs`, derived from the content dir's location
53
- // under the routes dir — the same mapping `collect.ts` uses for page URLs.
54
- const docsBase = '/' + path.relative(routesDir, contentDir).split(path.sep).join('/');
68
+ const docsBase = docsBaseFrom(routesDir, contentDir);
55
69
  const changelogFile = options.changelog === false ? undefined : path.resolve(options.changelog ?? 'CHANGELOG.md');
56
70
  const changelogRoute = options.changelogPath ?? '/changelog';
57
- const changelogOverrides = path.join(routesDir, changelogRoute.replace(/^\//, ''));
71
+ const changelogOverrides = path.join(routesDir, changelogRoute);
72
+ const indexOptions = { contentDir, routesDir, versions };
58
73
  return {
59
74
  name: 'docsmith-content',
60
75
  enforce: 'pre',
@@ -73,25 +88,34 @@ function contentIndexPlugin(options) {
73
88
  // body re-runs this load. A directory here is treated as an
74
89
  // unresolvable import by vite:import-analysis; new/removed pages are
75
90
  // handled by the watcher in configureServer.
91
+ const watch = (pages) => {
92
+ for (const page of pages)
93
+ this.addWatchFile(page.file);
94
+ };
76
95
  if (id === VIRTUAL_CONTENT_ID) {
77
- for (const file of listPageFiles(contentDir))
78
- this.addWatchFile(file);
79
- const docs = collectDocs(contentDir, routesDir, versions);
96
+ // Before anything is read: a config that disagrees with the docs root
97
+ // produces an index that is wrong rather than empty, so it has to fail
98
+ // here rather than render. Re-runs on invalidation, so archiving during
99
+ // `dev` reports immediately.
100
+ checkVersions(versions, discoverArchives(contentDir), ARCHIVE_MARKER);
101
+ const { pages, exists } = readSourcePages(contentDir);
102
+ watch(pages);
103
+ // Only this index has a date field, so only this one pays for git.
104
+ const docs = contentIndex(withCommitDates(pages), indexOptions);
105
+ warnAboutDocsRoot(contentDir, exists, docs.length);
80
106
  const resolved = resolveVersions(versions, docsBase, docs);
81
107
  return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
82
108
  `export const versions = ${JSON.stringify(resolved, null, 2)};\n`);
83
109
  }
84
110
  if (id === VIRTUAL_SEARCH_ID) {
85
- for (const file of listPageFiles(contentDir))
86
- this.addWatchFile(file);
87
- const docs = collectSearchDocs(contentDir, routesDir, versions);
88
- return `export const docs = ${JSON.stringify(docs)};\n`;
111
+ const { pages } = readSourcePages(contentDir);
112
+ watch(pages);
113
+ return `export const docs = ${JSON.stringify(searchIndex(pages, indexOptions))};\n`;
89
114
  }
90
115
  if (id === VIRTUAL_LLMS_ID) {
91
- for (const file of listPageFiles(contentDir))
92
- this.addWatchFile(file);
93
- const docs = collectLlmsDocs(contentDir, routesDir, versions);
94
- return `export const docs = ${JSON.stringify(docs)};\n`;
116
+ const { pages } = readSourcePages(contentDir);
117
+ watch(pages);
118
+ return `export const docs = ${JSON.stringify(llmsIndex(pages, indexOptions))};\n`;
95
119
  }
96
120
  if (id === VIRTUAL_CHANGELOG_ID) {
97
121
  if (!changelogFile)
@@ -1,5 +1,56 @@
1
+ /**
2
+ * One doc page as authored on disk, before any index-specific derivation. Its
3
+ * URL, version, headings and search text are all derived from these fields, so
4
+ * everything downstream of the read is pure.
5
+ */
6
+ export type SourcePage = {
7
+ /** Absolute path to the page file. */
8
+ file: string;
9
+ /** The page's raw markdown, frontmatter included. */
10
+ source: string;
11
+ /** The page's parsed frontmatter, `{}` when it has none. */
12
+ frontmatter: Record<string, unknown>;
13
+ /**
14
+ * The day (`YYYY-MM-DD`) the page last changed. Only {@link withCommitDates}
15
+ * fills this in, so it is absent on a page list that never went through it.
16
+ */
17
+ lastUpdated?: string;
18
+ };
1
19
  /** Whether a filename is a doc page the plugin should index. */
2
20
  export declare function isPageFile(file: string): boolean;
21
+ /**
22
+ * Turn a page's path and raw markdown into a {@link SourcePage}. Pure, and
23
+ * shared with tests so an in-memory page is built exactly the way a read one is.
24
+ */
25
+ export declare function toPage(file: string, source: string): SourcePage;
26
+ /**
27
+ * Read every doc page under `contentDir`. `exists` distinguishes a missing docs
28
+ * root from an empty one, which the page list alone cannot: both give no pages,
29
+ * but only one of them is a misconfiguration worth reporting.
30
+ */
31
+ export declare function readSourcePages(contentDir: string): {
32
+ pages: SourcePage[];
33
+ exists: boolean;
34
+ };
35
+ /**
36
+ * A page's title, when it has one. A page whose frontmatter has no string
37
+ * `title` is a stub: no index carries it, so nothing is derived for it and
38
+ * nothing is looked up about it.
39
+ */
40
+ export declare function titleOf(page: SourcePage): string | undefined;
41
+ /**
42
+ * Date each page from the commit that last touched it, skipping the lookup for
43
+ * pages that carry their own `lastUpdated`. Archiving copies every page in one
44
+ * commit, so an archive's pages are written with the date they last really
45
+ * changed and must keep it.
46
+ *
47
+ * Deliberately a second pass rather than part of {@link readSourcePages}: it
48
+ * spawns a git process per page, and only the content index has a date field.
49
+ * Folding it into the read would make the search and llms indexes pay for a
50
+ * value neither of them has anywhere to put. For the same reason it skips
51
+ * untitled pages, which no index carries.
52
+ */
53
+ export declare function withCommitDates(pages: SourcePage[]): SourcePage[];
3
54
  /**
4
55
  * List the absolute paths of every `+page.md`/`+page.svx` under `contentDir`.
5
56
  */
@@ -1,10 +1,68 @@
1
+ /**
2
+ * The docs root, and what a page is before any index has looked at it. Reading
3
+ * stops at a {@link SourcePage} list, which is the only value the index builders
4
+ * in `./collect.js` see — so the same builders run against a real docs root in
5
+ * the plugin and against an array of literals in tests.
6
+ */
1
7
  import fs from 'node:fs';
2
8
  import path from 'node:path';
9
+ import { parseFrontmatter } from './frontmatter.js';
10
+ import { lastCommitDate } from '../git.js';
3
11
  const PAGE_NAMES = ['+page.md', '+page.svx'];
4
12
  /** Whether a filename is a doc page the plugin should index. */
5
13
  export function isPageFile(file) {
6
14
  return PAGE_NAMES.some((name) => file.endsWith(name));
7
15
  }
16
+ /**
17
+ * Turn a page's path and raw markdown into a {@link SourcePage}. Pure, and
18
+ * shared with tests so an in-memory page is built exactly the way a read one is.
19
+ */
20
+ export function toPage(file, source) {
21
+ return { file, source, frontmatter: parseFrontmatter(source, file) };
22
+ }
23
+ /**
24
+ * Read every doc page under `contentDir`. `exists` distinguishes a missing docs
25
+ * root from an empty one, which the page list alone cannot: both give no pages,
26
+ * but only one of them is a misconfiguration worth reporting.
27
+ */
28
+ export function readSourcePages(contentDir) {
29
+ if (!fs.existsSync(contentDir))
30
+ return { pages: [], exists: false };
31
+ const pages = listPageFiles(contentDir).map((file) => toPage(file, fs.readFileSync(file, 'utf-8')));
32
+ return { pages, exists: true };
33
+ }
34
+ /**
35
+ * A page's title, when it has one. A page whose frontmatter has no string
36
+ * `title` is a stub: no index carries it, so nothing is derived for it and
37
+ * nothing is looked up about it.
38
+ */
39
+ export function titleOf(page) {
40
+ return typeof page.frontmatter.title === 'string' ? page.frontmatter.title : undefined;
41
+ }
42
+ /**
43
+ * Date each page from the commit that last touched it, skipping the lookup for
44
+ * pages that carry their own `lastUpdated`. Archiving copies every page in one
45
+ * commit, so an archive's pages are written with the date they last really
46
+ * changed and must keep it.
47
+ *
48
+ * Deliberately a second pass rather than part of {@link readSourcePages}: it
49
+ * spawns a git process per page, and only the content index has a date field.
50
+ * Folding it into the read would make the search and llms indexes pay for a
51
+ * value neither of them has anywhere to put. For the same reason it skips
52
+ * untitled pages, which no index carries.
53
+ */
54
+ export function withCommitDates(pages) {
55
+ return pages.map((page) => {
56
+ if (titleOf(page) === undefined)
57
+ return page;
58
+ return {
59
+ ...page,
60
+ lastUpdated: typeof page.frontmatter.lastUpdated === 'string'
61
+ ? page.frontmatter.lastUpdated
62
+ : lastCommitDate(page.file)
63
+ };
64
+ });
65
+ }
8
66
  /**
9
67
  * List the absolute paths of every `+page.md`/`+page.svx` under `contentDir`.
10
68
  */
@@ -8,5 +8,3 @@ import type { ChangelogRelease } from '../../core/changelog.js';
8
8
  * have one while the rest stay generated.
9
9
  */
10
10
  export declare function collectReleases(changelogFile: string, overridesDir?: string, routePath?: string): ChangelogRelease[];
11
- /** Default location of a monorepo package's changelog, relative to the app. */
12
- export declare function defaultChangelogPath(cwd?: string): string;
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { marked } from 'marked';
4
3
  import { parseChangelog } from '../changelog.js';
5
- import { changelogDates } from './git.js';
4
+ import { changelogDates } from '../git.js';
6
5
  /**
7
6
  * Render an entry's markdown to HTML at build time. Changesets entries use
8
7
  * inline code, emphasis, links and nested lists, so shipping the raw markdown
@@ -51,7 +50,3 @@ export function collectReleases(changelogFile, overridesDir, routePath = '/chang
51
50
  };
52
51
  });
53
52
  }
54
- /** Default location of a monorepo package's changelog, relative to the app. */
55
- export function defaultChangelogPath(cwd = process.cwd()) {
56
- return path.resolve(cwd, 'CHANGELOG.md');
57
- }
@@ -3,12 +3,17 @@
3
3
 
4
4
  const { release }: { release: ChangelogRelease } = $props();
5
5
 
6
+ // `timeZone: 'UTC'` is not cosmetic. `release.date` is a calendar day, which
7
+ // parses to UTC midnight, so formatting in the ambient zone shifts it a day
8
+ // west of UTC — and shifts it on the client but not on the server, so the
9
+ // rendered date changes under hydration.
6
10
  const formatted = $derived(
7
11
  release.date
8
12
  ? new Date(release.date).toLocaleDateString('en-US', {
9
13
  year: 'numeric',
10
14
  month: 'long',
11
- day: 'numeric'
15
+ day: 'numeric',
16
+ timeZone: 'UTC'
12
17
  })
13
18
  : undefined
14
19
  );
@@ -2,6 +2,7 @@
2
2
  import { goto } from '$app/navigation';
3
3
  import * as Command from '../shadcn/command/index.js';
4
4
  import { useSearch } from '../../search/context.svelte.js';
5
+ import { useDocsPage } from '../docs-page-context.js';
5
6
  import type { SearchDoc } from '../../core/index.js';
6
7
  import type { SearchEngine } from '../../search/create-search.js';
7
8
  import CornerDownLeft from '@lucide/svelte/icons/corner-down-left';
@@ -26,6 +27,10 @@
26
27
  } = $props();
27
28
 
28
29
  const search = useSearch();
30
+ // The version being read, straight off the shell's resolved page view, so
31
+ // results and the cached engine follow the reader across a version switch.
32
+ const docsPage = useDocsPage();
33
+ const activeVersion = $derived(docsPage.view.activeVersionId);
29
34
 
30
35
  let query = $state('');
31
36
  let engine = $state<SearchEngine | null>(null);
@@ -36,14 +41,12 @@
36
41
  let engineVersion: string | undefined;
37
42
 
38
43
  const trimmed = $derived(query.trim());
39
- // Scope to the active version when the shell set one (versioned sites).
40
- const results = $derived(
41
- engine && trimmed ? engine.search(query, undefined, search?.version) : []
42
- );
44
+ // Scope to the active version on a versioned site; `undefined` otherwise.
45
+ const results = $derived(engine && trimmed ? engine.search(query, undefined, activeVersion) : []);
43
46
 
44
47
  // Build the index the first time the palette opens; keep it for later opens.
45
48
  async function ensureEngine() {
46
- const version = search?.version;
49
+ const version = activeVersion;
47
50
  if ((engine && engineVersion === version) || status === 'loading') return;
48
51
  status = 'loading';
49
52
  try {
@@ -1,12 +1,7 @@
1
1
  <script lang="ts">
2
2
  import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
3
3
  import ArrowRight from '@lucide/svelte/icons/arrow-right';
4
- import {
5
- mapPathToVersion,
6
- scopeContent,
7
- type ResolvedVersion,
8
- type DocsContentItem
9
- } from '../../core/index.js';
4
+ import type { ResolvedVersion } from '../../core/index.js';
10
5
 
11
6
  // Rendered by DocsShell only on an archived version, to warn a reader who
12
7
  // landed there (usually from a search engine) and give them a one-click path
@@ -15,23 +10,13 @@
15
10
  const {
16
11
  active,
17
12
  current,
18
- pathname,
19
- content
13
+ href
20
14
  }: {
21
15
  active: ResolvedVersion;
22
16
  current: ResolvedVersion;
23
- pathname: string;
24
- content: DocsContentItem[];
17
+ /** This page under the current version, from the resolved page view. */
18
+ href: string;
25
19
  } = $props();
26
-
27
- const currentHref = $derived(
28
- mapPathToVersion(
29
- pathname,
30
- active,
31
- current,
32
- scopeContent(content, current.id).map((c) => c.path)
33
- )
34
- );
35
20
  </script>
36
21
 
37
22
  <div class="docsmith-version-banner" role="note">
@@ -39,7 +24,7 @@
39
24
  <p>
40
25
  You're reading the <strong>{active.label}</strong> docs. The current version is
41
26
  <strong>{current.label}</strong>.
42
- <a href={currentHref}>View this page in {current.label} <ArrowRight size={14} /></a>
27
+ <a {href}>View this page in {current.label} <ArrowRight size={14} /></a>
43
28
  </p>
44
29
  </div>
45
30
 
@@ -1,9 +1,9 @@
1
- import { type ResolvedVersion, type DocsContentItem } from '../../core/index.js';
1
+ import type { ResolvedVersion } from '../../core/index.js';
2
2
  type $$ComponentProps = {
3
3
  active: ResolvedVersion;
4
4
  current: ResolvedVersion;
5
- pathname: string;
6
- content: DocsContentItem[];
5
+ /** This page under the current version, from the resolved page view. */
6
+ href: string;
7
7
  };
8
8
  declare const VersionBanner: import("svelte").Component<$$ComponentProps, {}, "">;
9
9
  type VersionBanner = ReturnType<typeof VersionBanner>;