svelte-docsmith 0.8.0 → 0.10.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 (82) hide show
  1. package/bin/svelte-docsmith.mjs +161 -0
  2. package/dist/buildtime/archive.d.ts +31 -0
  3. package/dist/buildtime/archive.js +50 -0
  4. package/dist/buildtime/changelog.d.ts +20 -0
  5. package/dist/buildtime/changelog.js +74 -0
  6. package/dist/buildtime/fence-meta.d.ts +20 -0
  7. package/dist/buildtime/fence-meta.js +48 -0
  8. package/dist/buildtime/markdown-layout.svelte.d.ts +3 -0
  9. package/dist/buildtime/markdown-source.d.ts +31 -0
  10. package/dist/buildtime/markdown-source.js +90 -0
  11. package/dist/buildtime/preprocess.d.ts +13 -0
  12. package/dist/buildtime/preprocess.js +77 -17
  13. package/dist/buildtime/twoslash.d.ts +26 -0
  14. package/dist/buildtime/twoslash.js +40 -0
  15. package/dist/buildtime/vite/collect.d.ts +4 -3
  16. package/dist/buildtime/vite/collect.js +31 -12
  17. package/dist/buildtime/vite/extract.js +9 -35
  18. package/dist/buildtime/vite/frontmatter.js +4 -3
  19. package/dist/buildtime/vite/git.d.ts +7 -0
  20. package/dist/buildtime/vite/git.js +32 -0
  21. package/dist/buildtime/vite/index.d.ts +22 -0
  22. package/dist/buildtime/vite/index.js +27 -4
  23. package/dist/buildtime/vite/releases.d.ts +12 -0
  24. package/dist/buildtime/vite/releases.js +57 -0
  25. package/dist/components/changelog/changelog-entry.svelte +90 -0
  26. package/dist/components/changelog/changelog-entry.svelte.d.ts +7 -0
  27. package/dist/components/changelog/changelog.svelte +56 -0
  28. package/dist/components/changelog/changelog.svelte.d.ts +14 -0
  29. package/dist/components/chrome/copy-button.svelte +3 -2
  30. package/dist/components/chrome/search.svelte +16 -4
  31. package/dist/components/chrome/search.svelte.d.ts +4 -1
  32. package/dist/components/chrome/version-banner.svelte +82 -0
  33. package/dist/components/chrome/version-banner.svelte.d.ts +10 -0
  34. package/dist/components/chrome/version-switcher.svelte +63 -0
  35. package/dist/components/chrome/version-switcher.svelte.d.ts +14 -0
  36. package/dist/components/docs/mermaid.svelte +213 -0
  37. package/dist/components/docs/mermaid.svelte.d.ts +6 -0
  38. package/dist/components/landing/action.svelte +50 -0
  39. package/dist/components/landing/action.svelte.d.ts +13 -0
  40. package/dist/components/landing/cta.svelte +52 -0
  41. package/dist/components/landing/cta.svelte.d.ts +13 -0
  42. package/dist/components/landing/feature-grid.svelte +51 -0
  43. package/dist/components/landing/feature-grid.svelte.d.ts +19 -0
  44. package/dist/components/landing/feature.svelte +33 -0
  45. package/dist/components/landing/feature.svelte.d.ts +11 -0
  46. package/dist/components/landing/hero.svelte +70 -0
  47. package/dist/components/landing/hero.svelte.d.ts +19 -0
  48. package/dist/components/layouts/docs-header.svelte +61 -5
  49. package/dist/components/layouts/docs-header.svelte.d.ts +9 -1
  50. package/dist/components/layouts/docs-mobile-header.svelte +25 -2
  51. package/dist/components/layouts/docs-mobile-header.svelte.d.ts +9 -1
  52. package/dist/components/layouts/docs-shell.svelte +84 -66
  53. package/dist/components/layouts/docs-shell.svelte.d.ts +11 -2
  54. package/dist/components/layouts/error-page.svelte +23 -3
  55. package/dist/components/layouts/error-page.svelte.d.ts +8 -2
  56. package/dist/components/layouts/seo-head.svelte +8 -1
  57. package/dist/components/layouts/seo-head.svelte.d.ts +2 -0
  58. package/dist/components/markdown/pre.svelte +238 -8
  59. package/dist/components/markdown/pre.svelte.d.ts +6 -0
  60. package/dist/core/changelog.d.ts +31 -0
  61. package/dist/core/changelog.js +7 -0
  62. package/dist/core/content.d.ts +9 -0
  63. package/dist/core/docs-page.d.ts +70 -0
  64. package/dist/core/docs-page.js +55 -0
  65. package/dist/core/index.d.ts +2 -0
  66. package/dist/core/index.js +2 -0
  67. package/dist/core/version.d.ts +80 -0
  68. package/dist/core/version.js +85 -0
  69. package/dist/fallbacks/changelog.d.ts +10 -0
  70. package/dist/fallbacks/changelog.js +3 -0
  71. package/dist/fallbacks/content.d.ts +2 -0
  72. package/dist/fallbacks/content.js +1 -0
  73. package/dist/generate/feed.d.ts +35 -0
  74. package/dist/generate/feed.js +80 -0
  75. package/dist/index.d.ts +9 -0
  76. package/dist/index.js +14 -0
  77. package/dist/search/context.svelte.d.ts +2 -0
  78. package/dist/search/context.svelte.js +2 -0
  79. package/dist/search/create-search.d.ts +8 -2
  80. package/dist/search/create-search.js +11 -3
  81. package/dist/theme.css +53 -0
  82. package/package.json +27 -3
@@ -6,11 +6,13 @@
6
6
  * doesn't care where it's registered). It must never import component code.
7
7
  */
8
8
  import rehypeSectionize from '@hbsnow/rehype-sectionize';
9
- import { transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from '@shikijs/transformers';
9
+ import { transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight, transformerMetaHighlight } from '@shikijs/transformers';
10
10
  import { escapeSvelte, mdsvex } from 'mdsvex';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import rehypeSlug from 'rehype-slug';
13
13
  import { DEFAULT_LANGS, DEFAULT_THEMES, lazyHighlighter } from './highlight.js';
14
+ import { parseFenceMeta, hasTwoslash } from './fence-meta.js';
15
+ import { twoslashTransformer, twoslashFailure } from './twoslash.js';
14
16
  /**
15
17
  * Build the svelte-docsmith preprocessor: mdsvex with Shiki highlighting (via
16
18
  * the raw-code `highlight` hook, the correct mdsvex integration point), heading
@@ -31,25 +33,75 @@ export function docsmith(options = {}) {
31
33
  extensions: options.extensions ?? ['.md'],
32
34
  layout,
33
35
  highlight: {
34
- highlighter: async (code, lang) => {
36
+ // mdsvex passes the fence's trailing metadata as the third argument,
37
+ // which is where `title=` and the line-number flags come from.
38
+ highlighter: async (code, lang, meta) => {
39
+ const fence = parseFenceMeta(meta);
40
+ // A ```mermaid fence renders as a diagram, not highlighted code. The
41
+ // component is dynamic-imported so `mermaid` (an optional peer dep) is
42
+ // only pulled into pages that actually use it; JSON.stringify safely
43
+ // carries the multi-line source into the attribute.
44
+ if (lang === 'mermaid') {
45
+ // The pending branch renders server-side, reserving the diagram's
46
+ // space at first paint so it doesn't shift the page in on load.
47
+ // The client component reuses the same skeleton class until the
48
+ // diagram is ready, so the two never disagree on height.
49
+ return (`{#await import('svelte-docsmith/mermaid')}` +
50
+ `<div class="docsmith-mermaid-skeleton not-prose" aria-hidden="true"></div>` +
51
+ `{:then { default: Mermaid }}` +
52
+ `<Mermaid code={${JSON.stringify(code)}} />` +
53
+ `{/await}`);
54
+ }
35
55
  const highlighter = await getHighlighter();
36
56
  const language = lang && highlighter.getLoadedLanguages().includes(lang) ? lang : 'text';
37
- // Shiki highlights the raw code; escapeSvelte makes the result safe
38
- // to embed in a Svelte component.
39
- const html = escapeSvelte(highlighter.codeToHtml(code, {
57
+ // Comment-driven annotations authors write inside the fence:
58
+ // line highlight, diff (++/--), focus, error/warning, and
59
+ // word highlight. Each strips its own marker comment.
60
+ const notation = [
61
+ // Line ranges from the fence meta (```svelte {4,7-9}). The only way
62
+ // to highlight a line in markup: Shiki's comment markers are not
63
+ // recognised inside a Svelte template region, where an HTML comment
64
+ // is stripped without applying anything.
65
+ transformerMetaHighlight(),
66
+ transformerNotationHighlight(),
67
+ transformerNotationDiff(),
68
+ transformerNotationFocus(),
69
+ transformerNotationErrorLevel(),
70
+ transformerNotationWordHighlight()
71
+ ];
72
+ const render = (extra = []) => highlighter.codeToHtml(code, {
40
73
  lang: language,
41
74
  themes,
42
- // Comment-driven annotations authors write inside the fence:
43
- // line highlight, diff (++/--), focus, error/warning, and
44
- // word highlight. Each strips its own marker comment.
45
- transformers: [
46
- transformerNotationHighlight(),
47
- transformerNotationDiff(),
48
- transformerNotationFocus(),
49
- transformerNotationErrorLevel(),
50
- transformerNotationWordHighlight()
51
- ]
52
- }));
75
+ // transformerMetaHighlight reads the raw fence meta from here.
76
+ meta: { __raw: meta ?? '' },
77
+ transformers: [...extra, ...notation]
78
+ });
79
+ let rendered;
80
+ if (options.twoslash && hasTwoslash(meta)) {
81
+ const loaded = await twoslashTransformer();
82
+ if ('error' in loaded) {
83
+ console.warn(`[svelte-docsmith] ${loaded.error}`);
84
+ rendered = render();
85
+ }
86
+ else {
87
+ try {
88
+ rendered = render([loaded.transformer]);
89
+ }
90
+ catch (error) {
91
+ // One snippet that doesn't typecheck must not take the site
92
+ // down; fall back to a plain highlight and say which it was.
93
+ console.warn(`[svelte-docsmith] twoslash could not annotate a ${language} block: ` +
94
+ twoslashFailure(error));
95
+ rendered = render();
96
+ }
97
+ }
98
+ }
99
+ else {
100
+ rendered = render();
101
+ }
102
+ // escapeSvelte makes the highlighted result safe to embed in a
103
+ // Svelte component.
104
+ const html = escapeSvelte(rendered);
53
105
  // Without a layout there is no `Components.pre` to render through —
54
106
  // keep Shiki's own <pre> as-is.
55
107
  if (layout === undefined)
@@ -60,7 +112,15 @@ export function docsmith(options = {}) {
60
112
  .replace(/^<pre[^>]*>/, '')
61
113
  .replace(/<\/pre>\s*$/, '')
62
114
  .trim();
63
- return `<Components.pre>{@html \`${inner}\`}</Components.pre>`;
115
+ const showNumbers = fence.lineNumbers ?? options.lineNumbers ?? false;
116
+ const attrs = [
117
+ fence.title ? `title=${JSON.stringify(fence.title)}` : '',
118
+ showNumbers ? 'lineNumbers' : '',
119
+ showNumbers && fence.startLine !== undefined ? `startLine={${fence.startLine}}` : ''
120
+ ]
121
+ .filter(Boolean)
122
+ .join(' ');
123
+ return `<Components.pre${attrs ? ' ' + attrs : ''}>{@html \`${inner}\`}</Components.pre>`;
64
124
  }
65
125
  },
66
126
  remarkPlugins: options.remarkPlugins,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Twoslash support for code fences that opt in with ```ts twoslash.
3
+ *
4
+ * Twoslash runs the snippet through the TypeScript compiler and annotates it
5
+ * with the real inferred types, which is what makes a hover show `string`
6
+ * rather than a guess. The cost is that it **throws** on anything that does not
7
+ * typecheck: a missing import, a type error, an incomplete fragment.
8
+ *
9
+ * That strictness is the point, but it must not be able to take a whole site
10
+ * down. {@link highlightWithTwoslash} therefore falls back to an ordinary
11
+ * highlight and warns, naming the file, rather than failing the build over one
12
+ * bad snippet.
13
+ *
14
+ * The packages are optional peer dependencies, loaded only when a fence asks
15
+ * for them, so sites that never use Twoslash pay nothing for it.
16
+ */
17
+ import type { ShikiTransformer } from 'shiki';
18
+ type Loaded = {
19
+ transformer: ShikiTransformer;
20
+ } | {
21
+ error: string;
22
+ };
23
+ export declare function twoslashTransformer(): Promise<Loaded>;
24
+ /** One-line reason a snippet could not be annotated, for the build warning. */
25
+ export declare function twoslashFailure(error: unknown): string;
26
+ export {};
@@ -0,0 +1,40 @@
1
+ let cached;
2
+ /**
3
+ * Build the Twoslash transformer once per process. `langs` must include
4
+ * `svelte`, or Svelte fences are silently passed through unannotated: the
5
+ * transformer's default allowlist is TypeScript only.
6
+ */
7
+ async function loadTransformer() {
8
+ try {
9
+ const [{ transformerTwoslash }, { createTwoslasher }] = await Promise.all([
10
+ import('@shikijs/twoslash'),
11
+ import('twoslash-svelte')
12
+ ]);
13
+ return {
14
+ transformer: transformerTwoslash({
15
+ // The fence's meta is parsed by docsmith, which decides per block
16
+ // whether to include this transformer at all.
17
+ explicitTrigger: false,
18
+ langs: ['ts', 'tsx', 'js', 'svelte'],
19
+ twoslasher: createTwoslasher()
20
+ })
21
+ };
22
+ }
23
+ catch {
24
+ return {
25
+ error: "'twoslash' requires its optional peer dependencies. " +
26
+ 'Install them with: npm i -D @shikijs/twoslash twoslash-svelte typescript'
27
+ };
28
+ }
29
+ }
30
+ export function twoslashTransformer() {
31
+ cached ??= loadTransformer();
32
+ return cached;
33
+ }
34
+ /** One-line reason a snippet could not be annotated, for the build warning. */
35
+ export function twoslashFailure(error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ // Twoslash errors are multi-line with a code frame; the first line carries
38
+ // the actual diagnostic.
39
+ return message.split('\n').find((line) => line.trim()) ?? 'unknown error';
40
+ }
@@ -1,11 +1,12 @@
1
1
  import type { DocsContentItem, LlmsDoc, SearchDoc } from '../../core/content.js';
2
+ import type { DocsVersions } from '../../core/version.js';
2
3
  /**
3
4
  * Scan `contentDir` for `+page.md`/`+page.svx` files and read the frontmatter
4
5
  * fields the sidebar needs (plus the heading list for a server-rendered TOC and
5
6
  * an estimated reading time), deriving each page's URL from its directory
6
7
  * relative to `routesDir`. Pure and synchronous so it can be unit-tested.
7
8
  */
8
- export declare function collectDocs(contentDir: string, routesDir: string): DocsContentItem[];
9
+ export declare function collectDocs(contentDir: string, routesDir: string, versions?: DocsVersions): DocsContentItem[];
9
10
  /**
10
11
  * Build the search records for every page under `contentDir`: title, section,
11
12
  * description, heading list, and plain-text body. Served as the lazy-loaded
@@ -13,11 +14,11 @@ export declare function collectDocs(contentDir: string, routesDir: string): Docs
13
14
  * bloating the eagerly-imported nav index. The missing-directory case is
14
15
  * already reported by {@link collectDocs}, so this stays quiet.
15
16
  */
16
- export declare function collectSearchDocs(contentDir: string, routesDir: string): SearchDoc[];
17
+ export declare function collectSearchDocs(contentDir: string, routesDir: string, versions?: DocsVersions): SearchDoc[];
17
18
  /**
18
19
  * Build the LLM records for every page: title, section, description, and the
19
20
  * full markdown content. Served as the `svelte-docsmith/llms` virtual module and
20
21
  * consumed server-side by `llms.txt` / `llms-full.txt` routes, so it never ships
21
22
  * to the client.
22
23
  */
23
- export declare function collectLlmsDocs(contentDir: string, routesDir: string): LlmsDoc[];
24
+ export declare function collectLlmsDocs(contentDir: string, routesDir: string, versions?: DocsVersions): LlmsDoc[];
@@ -38,7 +38,7 @@ function sectionKey(value) {
38
38
  * frontmatter, derived URL, and title so every index (nav, search, llms) can be
39
39
  * built from a single read of each file.
40
40
  */
41
- function* eachTitledPage(contentDir, routesDir) {
41
+ function* eachTitledPage(contentDir, routesDir, versions) {
42
42
  for (const file of listPageFiles(contentDir)) {
43
43
  const source = fs.readFileSync(file, 'utf-8');
44
44
  const front = parseFrontmatter(source, file);
@@ -46,7 +46,20 @@ function* eachTitledPage(contentDir, routesDir) {
46
46
  continue;
47
47
  const dir = path.dirname(file);
48
48
  const url = '/' + path.relative(routesDir, dir).split(path.sep).join('/');
49
- yield { source, front, url, title: front.title, file };
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;
50
63
  }
51
64
  }
52
65
  /**
@@ -55,14 +68,14 @@ function* eachTitledPage(contentDir, routesDir) {
55
68
  * an estimated reading time), deriving each page's URL from its directory
56
69
  * relative to `routesDir`. Pure and synchronous so it can be unit-tested.
57
70
  */
58
- export function collectDocs(contentDir, routesDir) {
71
+ export function collectDocs(contentDir, routesDir, versions) {
59
72
  if (!fs.existsSync(contentDir)) {
60
73
  console.warn(`[svelte-docsmith] content directory not found: ${contentDir}\n` +
61
74
  ` The sidebar will be empty. Create your doc pages there, or point docsmith() at the right place with \`content\`.`);
62
75
  return [];
63
76
  }
64
77
  const items = [];
65
- for (const { source, front, url, title, file } of eachTitledPage(contentDir, routesDir)) {
78
+ for (const { source, front, url, title, file, version } of eachTitledPage(contentDir, routesDir, versions)) {
66
79
  items.push({
67
80
  title,
68
81
  path: url,
@@ -70,9 +83,13 @@ export function collectDocs(contentDir, routesDir) {
70
83
  section: readSection(front.section),
71
84
  order: typeof front.order === 'number' ? front.order : undefined,
72
85
  sourcePath: path.relative(process.cwd(), file).split(path.sep).join('/'),
73
- lastUpdated: lastCommitDate(file),
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),
74
90
  readingTime: readingMinutes(extractSearchText(source)),
75
- toc: extractToc(source)
91
+ toc: extractToc(source),
92
+ version
76
93
  });
77
94
  }
78
95
  if (items.length === 0) {
@@ -89,18 +106,19 @@ export function collectDocs(contentDir, routesDir) {
89
106
  * bloating the eagerly-imported nav index. The missing-directory case is
90
107
  * already reported by {@link collectDocs}, so this stays quiet.
91
108
  */
92
- export function collectSearchDocs(contentDir, routesDir) {
109
+ export function collectSearchDocs(contentDir, routesDir, versions) {
93
110
  if (!fs.existsSync(contentDir))
94
111
  return [];
95
112
  const docs = [];
96
- for (const { source, front, url, title } of eachTitledPage(contentDir, routesDir)) {
113
+ for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
97
114
  docs.push({
98
115
  path: url,
99
116
  title,
100
117
  section: sectionLabel(front.section),
101
118
  description: typeof front.description === 'string' ? front.description : undefined,
102
119
  headings: extractToc(source).map((entry) => entry.title),
103
- text: extractSearchText(source)
120
+ text: extractSearchText(source),
121
+ version
104
122
  });
105
123
  }
106
124
  return docs.sort((a, b) => a.path.localeCompare(b.path));
@@ -111,18 +129,19 @@ export function collectSearchDocs(contentDir, routesDir) {
111
129
  * consumed server-side by `llms.txt` / `llms-full.txt` routes, so it never ships
112
130
  * to the client.
113
131
  */
114
- export function collectLlmsDocs(contentDir, routesDir) {
132
+ export function collectLlmsDocs(contentDir, routesDir, versions) {
115
133
  if (!fs.existsSync(contentDir))
116
134
  return [];
117
135
  const docs = [];
118
- for (const { source, front, url, title } of eachTitledPage(contentDir, routesDir)) {
136
+ for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
119
137
  docs.push({
120
138
  path: url,
121
139
  title,
122
140
  section: sectionKey(front.section),
123
141
  order: typeof front.order === 'number' ? front.order : undefined,
124
142
  description: typeof front.description === 'string' ? front.description : undefined,
125
- content: extractLlmsContent(source, title)
143
+ content: extractLlmsContent(source, title),
144
+ version
126
145
  });
127
146
  }
128
147
  return docs.sort((a, b) => a.path.localeCompare(b.path));
@@ -1,4 +1,9 @@
1
1
  import GithubSlugger from 'github-slugger';
2
+ import { proseLines, splitFrontmatter } from '../markdown-source.js';
3
+ /** Drop a page's `<script>`/`<style>` blocks: component setup, not content. */
4
+ function withoutSetupBlocks(body) {
5
+ return body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
6
+ }
2
7
  /** Strip inline markdown so a heading's TOC label is plain text. */
3
8
  export function stripInlineMarkdown(text) {
4
9
  return text
@@ -17,23 +22,9 @@ export function stripInlineMarkdown(text) {
17
22
  * samples are intentionally dropped to keep the index small and prose-focused.
18
23
  */
19
24
  export function extractSearchText(source) {
20
- let body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
21
- // Component/setup blocks aren't prose; drop them whole before line scanning.
22
- body = body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
25
+ const body = withoutSetupBlocks(splitFrontmatter(source).body);
23
26
  const out = [];
24
- let fence = null;
25
- for (const line of body.split('\n')) {
26
- const f = /^\s*(`{3,}|~{3,})/.exec(line);
27
- if (f) {
28
- const ch = f[1][0];
29
- if (fence === null)
30
- fence = ch;
31
- else if (ch === fence)
32
- fence = null;
33
- continue;
34
- }
35
- if (fence !== null)
36
- continue;
27
+ for (const line of proseLines(body)) {
37
28
  // Skip table delimiter rows (`| --- | :--: |`) — pure structure, no words.
38
29
  if (/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
39
30
  continue;
@@ -63,22 +54,9 @@ export function readingMinutes(text) {
63
54
  * heading ids exactly, not just for common cases.
64
55
  */
65
56
  export function extractToc(source) {
66
- const body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
67
57
  const slugger = new GithubSlugger();
68
58
  const toc = [];
69
- let fence = null;
70
- for (const line of body.split('\n')) {
71
- const f = /^\s*(`{3,}|~{3,})/.exec(line);
72
- if (f) {
73
- const ch = f[1][0];
74
- if (fence === null)
75
- fence = ch;
76
- else if (ch === fence)
77
- fence = null;
78
- continue;
79
- }
80
- if (fence !== null)
81
- continue;
59
+ for (const line of proseLines(splitFrontmatter(source).body)) {
82
60
  const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
83
61
  if (!m)
84
62
  continue;
@@ -95,10 +73,6 @@ export function extractToc(source) {
95
73
  * with the frontmatter title prepended as an `h1` (pages start their body at h2).
96
74
  */
97
75
  export function extractLlmsContent(source, title) {
98
- const body = source
99
- .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
100
- .replace(/<script[\s\S]*?<\/script>/gi, '')
101
- .replace(/<style[\s\S]*?<\/style>/gi, '')
102
- .trim();
76
+ const body = withoutSetupBlocks(splitFrontmatter(source).body).trim();
103
77
  return `# ${title}\n\n${body}`;
104
78
  }
@@ -1,16 +1,17 @@
1
1
  import yaml from 'js-yaml';
2
+ import { splitFrontmatter } from '../markdown-source.js';
2
3
  /**
3
4
  * Parse a page's leading YAML frontmatter block into a plain object. Returns an
4
5
  * empty object when there is no frontmatter; throws a located error on invalid
5
6
  * YAML so a typo surfaces with its filename instead of a blank page.
6
7
  */
7
8
  export function parseFrontmatter(source, file) {
8
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
9
- if (!match)
9
+ const { frontmatter } = splitFrontmatter(source);
10
+ if (frontmatter === undefined)
10
11
  return {};
11
12
  let data;
12
13
  try {
13
- data = yaml.load(match[1]);
14
+ data = yaml.load(frontmatter);
14
15
  }
15
16
  catch (err) {
16
17
  const reason = err instanceof Error ? err.message : String(err);
@@ -1,2 +1,9 @@
1
1
  /** Last git commit date (strict ISO) for a file, or undefined outside a repo. */
2
2
  export declare function lastCommitDate(file: string): string | undefined;
3
+ /**
4
+ * Release dates keyed by version, read from the commit that introduced each
5
+ * `## <version>` heading in a changelog. Changesets does not write dates, and a
6
+ * tag lookup misses versions released before tagging was set up, so the file's
7
+ * own history is the most reliable source available.
8
+ */
9
+ export declare function changelogDates(file: string): Map<string, string>;
@@ -9,3 +9,35 @@ export function lastCommitDate(file) {
9
9
  const date = res.status === 0 ? res.stdout.trim() : '';
10
10
  return date || undefined;
11
11
  }
12
+ /**
13
+ * Release dates keyed by version, read from the commit that introduced each
14
+ * `## <version>` heading in a changelog. Changesets does not write dates, and a
15
+ * tag lookup misses versions released before tagging was set up, so the file's
16
+ * own history is the most reliable source available.
17
+ */
18
+ export function changelogDates(file) {
19
+ const dates = new Map();
20
+ // `-L` would be per-line; instead walk the file's commits newest-first and
21
+ // record the first commit in which each version heading appears.
22
+ const log = spawnSync('git', ['log', '--format=%H %cI', '--reverse', '--follow', '--', path.basename(file)], { cwd: path.dirname(file), encoding: 'utf-8' });
23
+ if (log.status !== 0)
24
+ return dates;
25
+ for (const line of log.stdout.trim().split('\n').filter(Boolean)) {
26
+ const [hash, date] = line.split(' ');
27
+ if (!hash || !date)
28
+ continue;
29
+ const show = spawnSync('git', ['show', `${hash}:./${path.basename(file)}`], {
30
+ cwd: path.dirname(file),
31
+ encoding: 'utf-8'
32
+ });
33
+ if (show.status !== 0)
34
+ continue;
35
+ for (const match of show.stdout.matchAll(/^##\s+(?!#)(.+?)\s*$/gm)) {
36
+ const version = match[1].trim();
37
+ // First commit containing this heading wins, which is its release.
38
+ if (!dates.has(version))
39
+ dates.set(version, date);
40
+ }
41
+ }
42
+ return dates;
43
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
+ import { type DocsVersions } from '../../core/version.js';
2
3
  export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
4
+ export { collectReleases } from './releases.js';
3
5
  export interface DocsmithViteOptions {
4
6
  /** Directory scanned for doc pages. Default: `'src/routes/docs'`. */
5
7
  content?: string;
@@ -13,6 +15,26 @@ export interface DocsmithViteOptions {
13
15
  light: string;
14
16
  dark: string;
15
17
  };
18
+ /**
19
+ * Path to the `CHANGELOG.md` that feeds the generated
20
+ * `svelte-docsmith/changelog` index. Default: `'CHANGELOG.md'` in the app
21
+ * directory; point it at the package whose releases you publish. Set `false`
22
+ * to skip the changelog entirely.
23
+ */
24
+ changelog?: string | false;
25
+ /**
26
+ * Route the changelog is served at, used to build feed links and to find
27
+ * hand-written per-release pages. Default: `'/changelog'`.
28
+ */
29
+ changelogPath?: string;
30
+ /**
31
+ * Declare documentation versions to enable versioned docs. `current` is the
32
+ * docs for the latest release: its pages sit directly under `<content>` and
33
+ * keep their unprefixed URLs. Each archived version's pages live under
34
+ * `<content>/<id>/` and are served at `/docs/<id>/…`. Omit for a single,
35
+ * unversioned docs tree (the default).
36
+ */
37
+ versions?: DocsVersions;
16
38
  }
17
39
  /**
18
40
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
@@ -19,9 +19,12 @@
19
19
  import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import { DEFAULT_THEMES, lazyHighlighter } from '../highlight.js';
22
+ import { resolveVersions } from '../../core/version.js';
22
23
  import { isPageFile, listPageFiles } from './pages.js';
23
24
  import { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
25
+ import { collectReleases } from './releases.js';
24
26
  export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
27
+ export { collectReleases } from './releases.js';
25
28
  /**
26
29
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
27
30
  *
@@ -40,9 +43,18 @@ const SEARCH_SPECIFIER = 'svelte-docsmith/search';
40
43
  const VIRTUAL_SEARCH_ID = '\0svelte-docsmith:search';
41
44
  const LLMS_SPECIFIER = 'svelte-docsmith/llms';
42
45
  const VIRTUAL_LLMS_ID = '\0svelte-docsmith:llms';
46
+ const CHANGELOG_SPECIFIER = 'svelte-docsmith/changelog';
47
+ const VIRTUAL_CHANGELOG_ID = '\0svelte-docsmith:changelog';
43
48
  function contentIndexPlugin(options) {
44
49
  const contentDir = path.resolve(options.content ?? 'src/routes/docs');
45
50
  const routesDir = path.resolve(options.routes ?? 'src/routes');
51
+ 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('/');
55
+ const changelogFile = options.changelog === false ? undefined : path.resolve(options.changelog ?? 'CHANGELOG.md');
56
+ const changelogRoute = options.changelogPath ?? '/changelog';
57
+ const changelogOverrides = path.join(routesDir, changelogRoute.replace(/^\//, ''));
46
58
  return {
47
59
  name: 'docsmith-content',
48
60
  enforce: 'pre',
@@ -53,6 +65,8 @@ function contentIndexPlugin(options) {
53
65
  return VIRTUAL_SEARCH_ID;
54
66
  if (id === LLMS_SPECIFIER)
55
67
  return VIRTUAL_LLMS_ID;
68
+ if (id === CHANGELOG_SPECIFIER)
69
+ return VIRTUAL_CHANGELOG_ID;
56
70
  },
57
71
  load(id) {
58
72
  // Watch each page file (not the directory) so editing frontmatter or
@@ -62,21 +76,30 @@ function contentIndexPlugin(options) {
62
76
  if (id === VIRTUAL_CONTENT_ID) {
63
77
  for (const file of listPageFiles(contentDir))
64
78
  this.addWatchFile(file);
65
- const docs = collectDocs(contentDir, routesDir);
66
- return `export const docs = ${JSON.stringify(docs, null, 2)};\n`;
79
+ const docs = collectDocs(contentDir, routesDir, versions);
80
+ const resolved = resolveVersions(versions, docsBase, docs);
81
+ return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
82
+ `export const versions = ${JSON.stringify(resolved, null, 2)};\n`);
67
83
  }
68
84
  if (id === VIRTUAL_SEARCH_ID) {
69
85
  for (const file of listPageFiles(contentDir))
70
86
  this.addWatchFile(file);
71
- const docs = collectSearchDocs(contentDir, routesDir);
87
+ const docs = collectSearchDocs(contentDir, routesDir, versions);
72
88
  return `export const docs = ${JSON.stringify(docs)};\n`;
73
89
  }
74
90
  if (id === VIRTUAL_LLMS_ID) {
75
91
  for (const file of listPageFiles(contentDir))
76
92
  this.addWatchFile(file);
77
- const docs = collectLlmsDocs(contentDir, routesDir);
93
+ const docs = collectLlmsDocs(contentDir, routesDir, versions);
78
94
  return `export const docs = ${JSON.stringify(docs)};\n`;
79
95
  }
96
+ if (id === VIRTUAL_CHANGELOG_ID) {
97
+ if (!changelogFile)
98
+ return `export const releases = [];\n`;
99
+ this.addWatchFile(changelogFile);
100
+ const releases = collectReleases(changelogFile, changelogOverrides, changelogRoute);
101
+ return `export const releases = ${JSON.stringify(releases)};\n`;
102
+ }
80
103
  },
81
104
  configureServer(server) {
82
105
  server.watcher.add(contentDir);
@@ -0,0 +1,12 @@
1
+ import type { ChangelogRelease } from '../../core/changelog.js';
2
+ /**
3
+ * Read a Changesets `CHANGELOG.md` into releases, dated from git history, and
4
+ * linked to a hand-written page where one exists.
5
+ *
6
+ * `overridesDir` is scanned for directories named after a version
7
+ * (`src/routes/changelog/0.9.0/`), so a release worth a proper write-up can
8
+ * have one while the rest stay generated.
9
+ */
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;
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { marked } from 'marked';
4
+ import { parseChangelog } from '../changelog.js';
5
+ import { changelogDates } from './git.js';
6
+ /**
7
+ * Render an entry's markdown to HTML at build time. Changesets entries use
8
+ * inline code, emphasis, links and nested lists, so shipping the raw markdown
9
+ * would mean every consuming site needed a runtime renderer.
10
+ */
11
+ function renderItem(markdown) {
12
+ return marked.parse(markdown, { async: false, gfm: true }).trim();
13
+ }
14
+ /**
15
+ * Read a Changesets `CHANGELOG.md` into releases, dated from git history, and
16
+ * linked to a hand-written page where one exists.
17
+ *
18
+ * `overridesDir` is scanned for directories named after a version
19
+ * (`src/routes/changelog/0.9.0/`), so a release worth a proper write-up can
20
+ * have one while the rest stay generated.
21
+ */
22
+ export function collectReleases(changelogFile, overridesDir, routePath = '/changelog') {
23
+ if (!fs.existsSync(changelogFile))
24
+ return [];
25
+ const releases = parseChangelog(fs.readFileSync(changelogFile, 'utf-8'));
26
+ const dates = changelogDates(changelogFile);
27
+ const overrides = new Set();
28
+ if (overridesDir && fs.existsSync(overridesDir)) {
29
+ for (const entry of fs.readdirSync(overridesDir, { withFileTypes: true })) {
30
+ if (entry.isDirectory())
31
+ overrides.add(entry.name);
32
+ }
33
+ }
34
+ return releases.map((release) => {
35
+ // Directory names can't contain the dots a version has on every platform,
36
+ // so `0.9.0` may be written as `0-9-0`.
37
+ const slug = release.version.replace(/\./g, '-');
38
+ const override = overrides.has(release.version)
39
+ ? release.version
40
+ : overrides.has(slug)
41
+ ? slug
42
+ : undefined;
43
+ return {
44
+ ...release,
45
+ groups: release.groups.map((group) => ({
46
+ ...group,
47
+ items: group.items.map(renderItem)
48
+ })),
49
+ ...(dates.get(release.version) ? { date: dates.get(release.version) } : {}),
50
+ ...(override ? { path: `${routePath}/${override}` } : {})
51
+ };
52
+ });
53
+ }
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
+ }