svelte-docsmith 0.8.0 → 0.9.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 (44) hide show
  1. package/dist/buildtime/changelog.d.ts +20 -0
  2. package/dist/buildtime/changelog.js +74 -0
  3. package/dist/buildtime/fence-meta.d.ts +20 -0
  4. package/dist/buildtime/fence-meta.js +48 -0
  5. package/dist/buildtime/markdown-layout.svelte.d.ts +3 -0
  6. package/dist/buildtime/preprocess.d.ts +13 -0
  7. package/dist/buildtime/preprocess.js +77 -17
  8. package/dist/buildtime/twoslash.d.ts +26 -0
  9. package/dist/buildtime/twoslash.js +40 -0
  10. package/dist/buildtime/vite/git.d.ts +7 -0
  11. package/dist/buildtime/vite/git.js +32 -0
  12. package/dist/buildtime/vite/index.d.ts +13 -0
  13. package/dist/buildtime/vite/index.js +16 -0
  14. package/dist/buildtime/vite/releases.d.ts +12 -0
  15. package/dist/buildtime/vite/releases.js +57 -0
  16. package/dist/components/changelog/changelog-entry.svelte +90 -0
  17. package/dist/components/changelog/changelog-entry.svelte.d.ts +7 -0
  18. package/dist/components/changelog/changelog.svelte +56 -0
  19. package/dist/components/changelog/changelog.svelte.d.ts +14 -0
  20. package/dist/components/docs/mermaid.svelte +213 -0
  21. package/dist/components/docs/mermaid.svelte.d.ts +6 -0
  22. package/dist/components/landing/action.svelte +50 -0
  23. package/dist/components/landing/action.svelte.d.ts +13 -0
  24. package/dist/components/landing/cta.svelte +52 -0
  25. package/dist/components/landing/cta.svelte.d.ts +13 -0
  26. package/dist/components/landing/feature-grid.svelte +51 -0
  27. package/dist/components/landing/feature-grid.svelte.d.ts +19 -0
  28. package/dist/components/landing/feature.svelte +33 -0
  29. package/dist/components/landing/feature.svelte.d.ts +11 -0
  30. package/dist/components/landing/hero.svelte +70 -0
  31. package/dist/components/landing/hero.svelte.d.ts +19 -0
  32. package/dist/components/layouts/docs-header.svelte +36 -3
  33. package/dist/components/markdown/pre.svelte +238 -8
  34. package/dist/components/markdown/pre.svelte.d.ts +6 -0
  35. package/dist/core/changelog.d.ts +31 -0
  36. package/dist/core/changelog.js +7 -0
  37. package/dist/fallbacks/changelog.d.ts +10 -0
  38. package/dist/fallbacks/changelog.js +3 -0
  39. package/dist/generate/feed.d.ts +35 -0
  40. package/dist/generate/feed.js +80 -0
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.js +9 -0
  43. package/dist/theme.css +32 -0
  44. package/package.json +21 -2
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Parse a Changesets-generated `CHANGELOG.md` into structured releases.
3
+ *
4
+ * The shape Changesets writes is stable:
5
+ *
6
+ * # package-name
7
+ *
8
+ * ## 0.8.0
9
+ *
10
+ * ### Minor Changes
11
+ *
12
+ * - 4a1b3d4: Add an announcement bar. …
13
+ *
14
+ * A second paragraph, indented two spaces.
15
+ *
16
+ * Parsing that rather than asking authors to write release notes twice means
17
+ * the changelog can never fall behind what actually shipped.
18
+ */
19
+ import type { ChangelogRelease } from '../core/changelog.js';
20
+ export declare function parseChangelog(markdown: string): ChangelogRelease[];
@@ -0,0 +1,74 @@
1
+ /** `- <7-or-more hex chars>: ` prefix that Changesets puts on each entry. */
2
+ const COMMIT_PREFIX = /^([-*])\s+[0-9a-f]{7,40}:\s*/;
3
+ /** Strip the leading `- ` (and any commit hash) from an entry's first line. */
4
+ function stripBullet(line) {
5
+ const withoutHash = line.replace(COMMIT_PREFIX, '');
6
+ if (withoutHash !== line)
7
+ return withoutHash;
8
+ return line.replace(/^[-*]\s+/, '');
9
+ }
10
+ /**
11
+ * De-indent an entry's continuation lines. Changesets indents them by two
12
+ * spaces to keep them inside the list item; leaving that in would render them
13
+ * as code blocks.
14
+ */
15
+ function dedent(lines) {
16
+ return lines
17
+ .map((line) => (line.startsWith(' ') ? line.slice(2) : line))
18
+ .join('\n')
19
+ .trim();
20
+ }
21
+ export function parseChangelog(markdown) {
22
+ const lines = markdown.split(/\r?\n/);
23
+ const releases = [];
24
+ let release;
25
+ let group;
26
+ let entry;
27
+ const flushEntry = () => {
28
+ if (entry && group) {
29
+ const text = dedent(entry);
30
+ if (text)
31
+ group.items.push(text);
32
+ }
33
+ entry = undefined;
34
+ };
35
+ const flushGroup = () => {
36
+ flushEntry();
37
+ if (group && release && group.items.length > 0)
38
+ release.groups.push(group);
39
+ group = undefined;
40
+ };
41
+ const flushRelease = () => {
42
+ flushGroup();
43
+ if (release)
44
+ releases.push(release);
45
+ release = undefined;
46
+ };
47
+ for (const line of lines) {
48
+ const version = /^##\s+(?!#)(.+?)\s*$/.exec(line);
49
+ if (version) {
50
+ flushRelease();
51
+ release = { version: version[1].trim(), groups: [] };
52
+ continue;
53
+ }
54
+ const heading = /^###\s+(.+?)\s*$/.exec(line);
55
+ if (heading && release) {
56
+ flushGroup();
57
+ group = { kind: heading[1].trim(), items: [] };
58
+ continue;
59
+ }
60
+ if (!group)
61
+ continue;
62
+ // A new bullet at column zero starts an entry; anything else continues the
63
+ // current one, which is how multi-paragraph changesets survive intact.
64
+ if (/^[-*]\s+/.test(line)) {
65
+ flushEntry();
66
+ entry = [stripBullet(line)];
67
+ }
68
+ else if (entry) {
69
+ entry.push(line);
70
+ }
71
+ }
72
+ flushRelease();
73
+ return releases;
74
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Parse the metadata that follows a code fence's language:
3
+ *
4
+ * ```ts title="vite.config.ts" showLineNumbers startLine=12
5
+ *
6
+ * mdsvex hands this string to the highlighter as its third argument, so no
7
+ * extra plumbing is needed to read it. Unknown entries are ignored rather than
8
+ * rejected, so a fence never fails a build over an unrecognised flag.
9
+ */
10
+ export type FenceMeta = {
11
+ /** Filename or caption shown above the block. */
12
+ title?: string;
13
+ /** Whether to number the lines. `undefined` means "use the global default". */
14
+ lineNumbers?: boolean;
15
+ /** First line's number, for a snippet lifted out of a larger file. */
16
+ startLine?: number;
17
+ };
18
+ export declare function parseFenceMeta(meta?: string | null): FenceMeta;
19
+ /** Whether a fence opts into Twoslash: ```ts twoslash */
20
+ export declare function hasTwoslash(meta?: string | null): boolean;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Parse the metadata that follows a code fence's language:
3
+ *
4
+ * ```ts title="vite.config.ts" showLineNumbers startLine=12
5
+ *
6
+ * mdsvex hands this string to the highlighter as its third argument, so no
7
+ * extra plumbing is needed to read it. Unknown entries are ignored rather than
8
+ * rejected, so a fence never fails a build over an unrecognised flag.
9
+ */
10
+ /** `title="a b"`, `title='a b'`, or `title=a` (unquoted values stop at a space). */
11
+ const KEY_VALUE = /(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g;
12
+ export function parseFenceMeta(meta) {
13
+ const result = {};
14
+ if (!meta)
15
+ return result;
16
+ for (const match of meta.matchAll(KEY_VALUE)) {
17
+ const key = match[1];
18
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
19
+ if (key === 'title')
20
+ result.title = value;
21
+ else if (key === 'startLine') {
22
+ const parsed = Number.parseInt(value, 10);
23
+ // A non-numeric or nonsensical startLine is ignored: numbering from an
24
+ // accidental NaN is worse than numbering from 1.
25
+ if (Number.isFinite(parsed) && parsed > 0)
26
+ result.startLine = parsed;
27
+ }
28
+ else if (key === 'showLineNumbers' || key === 'lineNumbers') {
29
+ result.lineNumbers = value !== 'false';
30
+ }
31
+ }
32
+ // Bare flags, e.g. ```ts showLineNumbers
33
+ const bare = meta.replace(KEY_VALUE, ' ').split(/\s+/).filter(Boolean);
34
+ if (bare.includes('showLineNumbers') || bare.includes('lineNumbers'))
35
+ result.lineNumbers = true;
36
+ if (bare.includes('noLineNumbers'))
37
+ result.lineNumbers = false;
38
+ // Numbering from a given line is meaningless without a gutter to show it in.
39
+ if (result.startLine !== undefined && result.lineNumbers === undefined)
40
+ result.lineNumbers = true;
41
+ return result;
42
+ }
43
+ /** Whether a fence opts into Twoslash: ```ts twoslash */
44
+ export function hasTwoslash(meta) {
45
+ if (!meta)
46
+ return false;
47
+ return meta.replace(KEY_VALUE, ' ').split(/\s+/).includes('twoslash');
48
+ }
@@ -1,4 +1,7 @@
1
1
  export declare const pre: import("svelte").Component<{
2
+ title?: string;
3
+ lineNumbers?: boolean;
4
+ startLine?: number;
2
5
  children: Snippet;
3
6
  }, {}, "">;
4
7
  export declare const code: import("svelte").Component<{
@@ -22,6 +22,19 @@ export interface DocsmithPreprocessOptions {
22
22
  remarkPlugins?: MdsvexOptions['remarkPlugins'];
23
23
  /** Extra rehype plugins, appended after docsmith's own (slug, sectionize). */
24
24
  rehypePlugins?: MdsvexOptions['rehypePlugins'];
25
+ /**
26
+ * Number every code block by default. Per-fence `showLineNumbers` /
27
+ * `noLineNumbers` override it either way. Default: `false`.
28
+ */
29
+ lineNumbers?: boolean;
30
+ /**
31
+ * Enable Twoslash on fences that ask for it with ```ts twoslash, adding real
32
+ * hover types from the TypeScript compiler. Requires the optional peer
33
+ * dependencies `@shikijs/twoslash`, `twoslash-svelte`, and `typescript`.
34
+ * A snippet that fails to typecheck falls back to a plain highlight with a
35
+ * build warning rather than failing the build. Default: `false`.
36
+ */
37
+ twoslash?: boolean;
25
38
  }
26
39
  /**
27
40
  * Build the svelte-docsmith preprocessor: mdsvex with Shiki highlighting (via
@@ -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,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,6 @@
1
1
  import type { Plugin } from 'vite';
2
2
  export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
3
+ export { collectReleases } from './releases.js';
3
4
  export interface DocsmithViteOptions {
4
5
  /** Directory scanned for doc pages. Default: `'src/routes/docs'`. */
5
6
  content?: string;
@@ -13,6 +14,18 @@ export interface DocsmithViteOptions {
13
14
  light: string;
14
15
  dark: string;
15
16
  };
17
+ /**
18
+ * Path to the `CHANGELOG.md` that feeds the generated
19
+ * `svelte-docsmith/changelog` index. Default: `'CHANGELOG.md'` in the app
20
+ * directory; point it at the package whose releases you publish. Set `false`
21
+ * to skip the changelog entirely.
22
+ */
23
+ changelog?: string | false;
24
+ /**
25
+ * Route the changelog is served at, used to build feed links and to find
26
+ * hand-written per-release pages. Default: `'/changelog'`.
27
+ */
28
+ changelogPath?: string;
16
29
  }
17
30
  /**
18
31
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
@@ -21,7 +21,9 @@ import path from 'node:path';
21
21
  import { DEFAULT_THEMES, lazyHighlighter } from '../highlight.js';
22
22
  import { isPageFile, listPageFiles } from './pages.js';
23
23
  import { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
24
+ import { collectReleases } from './releases.js';
24
25
  export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
26
+ export { collectReleases } from './releases.js';
25
27
  /**
26
28
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
27
29
  *
@@ -40,9 +42,14 @@ const SEARCH_SPECIFIER = 'svelte-docsmith/search';
40
42
  const VIRTUAL_SEARCH_ID = '\0svelte-docsmith:search';
41
43
  const LLMS_SPECIFIER = 'svelte-docsmith/llms';
42
44
  const VIRTUAL_LLMS_ID = '\0svelte-docsmith:llms';
45
+ const CHANGELOG_SPECIFIER = 'svelte-docsmith/changelog';
46
+ const VIRTUAL_CHANGELOG_ID = '\0svelte-docsmith:changelog';
43
47
  function contentIndexPlugin(options) {
44
48
  const contentDir = path.resolve(options.content ?? 'src/routes/docs');
45
49
  const routesDir = path.resolve(options.routes ?? 'src/routes');
50
+ const changelogFile = options.changelog === false ? undefined : path.resolve(options.changelog ?? 'CHANGELOG.md');
51
+ const changelogRoute = options.changelogPath ?? '/changelog';
52
+ const changelogOverrides = path.join(routesDir, changelogRoute.replace(/^\//, ''));
46
53
  return {
47
54
  name: 'docsmith-content',
48
55
  enforce: 'pre',
@@ -53,6 +60,8 @@ function contentIndexPlugin(options) {
53
60
  return VIRTUAL_SEARCH_ID;
54
61
  if (id === LLMS_SPECIFIER)
55
62
  return VIRTUAL_LLMS_ID;
63
+ if (id === CHANGELOG_SPECIFIER)
64
+ return VIRTUAL_CHANGELOG_ID;
56
65
  },
57
66
  load(id) {
58
67
  // Watch each page file (not the directory) so editing frontmatter or
@@ -77,6 +86,13 @@ function contentIndexPlugin(options) {
77
86
  const docs = collectLlmsDocs(contentDir, routesDir);
78
87
  return `export const docs = ${JSON.stringify(docs)};\n`;
79
88
  }
89
+ if (id === VIRTUAL_CHANGELOG_ID) {
90
+ if (!changelogFile)
91
+ return `export const releases = [];\n`;
92
+ this.addWatchFile(changelogFile);
93
+ const releases = collectReleases(changelogFile, changelogOverrides, changelogRoute);
94
+ return `export const releases = ${JSON.stringify(releases)};\n`;
95
+ }
80
96
  },
81
97
  configureServer(server) {
82
98
  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
+ }