svelte-docsmith 0.12.1 → 0.13.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.
package/README.md CHANGED
@@ -15,8 +15,17 @@ Write a markdown file under `src/routes/docs/` and you get a styled page with
15
15
  syntax highlighting, heading anchors, a sidebar derived from your content, and a
16
16
  live table of contents. No per-page wiring, no content collection to configure.
17
17
 
18
- > **Status: pre-1.0.** Published to npm and usable today. The public API is still
19
- > stabilising, so minor releases may include breaking changes until v1.0.
18
+ > **Status: pre-1.0.** Published to npm and usable today. Minor releases may still
19
+ > include breaking changes until v1.0.
20
+ >
21
+ > **What 1.0 will lock.** Authoring components, `DocsShell`, `ErrorPage`,
22
+ > `defineConfig`, the preprocessor, the Vite plugin, the sitemap / llms / feed
23
+ > generators, the search engine, and the theming contract — ordinary semver,
24
+ > breaking changes ride a major.
25
+ >
26
+ > **What stays experimental.** Versioning: `DocsVersions`, `currentOnly`, the
27
+ > archive marker, and the `archive-version` command. They may still change in a
28
+ > minor until authors have used them for real.
20
29
 
21
30
  ## Install
22
31
 
@@ -166,6 +175,20 @@ plugin you already added:
166
175
  Graph / Twitter tags for every page from its frontmatter, no per-page wiring.
167
176
  - **Error pages.** `ErrorPage` gives a styled 404 that keeps the site chrome;
168
177
  drop it into a SvelteKit `+error.svelte`.
178
+ - **Versioned docs.** Declare `versions` on the Vite plugin, freeze a release
179
+ with `npx svelte-docsmith archive-version <id>`, and pass the generated
180
+ manifest to `DocsShell`. Scope sitemap / llms endpoints to the current
181
+ version with `currentOnly`.
182
+ - **Changelog.** Point the plugin at your `CHANGELOG.md`; import the parsed
183
+ releases from `svelte-docsmith/changelog`, render them with `Changelog`, and
184
+ wire `generateFeed` into an Atom endpoint.
185
+ - **Mermaid.** A mermaid code fence is rendered client-side (optional `mermaid`
186
+ peer dependency, loaded only on pages that need it).
187
+ - **Landing sections.** `Hero`, `FeatureGrid`, `Feature`, `CTA`, and `Action`
188
+ for the marketing page in front of your docs.
189
+ - **Generators.** Framework-agnostic helpers for `sitemap.xml`, `llms.txt` /
190
+ `llms-full.txt`, and the changelog Atom feed — drop them into `+server.ts`
191
+ routes over the generated indexes.
169
192
  - **Components.** `Callout`, `Tabs`, `Steps`, `Card`, `Accordion`, `Badge`,
170
193
  `Kbd`, `FileTree`, `PropsTable`, and more, authored right inside markdown.
171
194
  - Heading anchors, a scroll-spy table of contents, and one-click copy on every
@@ -187,15 +210,21 @@ override the tokens yourself.
187
210
 
188
211
  ## What's exported
189
212
 
190
- | Entry point | What it is |
191
- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
192
- | `svelte-docsmith` | All components (`DocsShell`, `ErrorPage`, `LiveExample`, `Callout`, `Tabs`, …), plus `defineConfig`, `createSearchEngine`, and the types |
193
- | `svelte-docsmith/preprocess` | `docsmith()`, the mdsvex/Shiki pipeline (Node, config time) |
194
- | `svelte-docsmith/vite` | `docsmith()`, the content index, search index, and `?source` transform (Node, build) |
195
- | `svelte-docsmith/content` | `docs`, the generated sidebar content index |
196
- | `svelte-docsmith/search` | `docs`, the generated full-text search index (lazy-load it) |
197
- | `svelte-docsmith/theme.css` | the base style contract |
198
- | `svelte-docsmith/themes/*.css` | the eleven theme presets |
213
+ | Entry point | What it is |
214
+ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
215
+ | `svelte-docsmith` | Components (`DocsShell`, `Changelog`, landing sections, `LiveExample`, `Callout`, …), `defineConfig`, `createSearchEngine`, `generateSitemap` / `generateLlmsTxt` / `generateLlmsFullTxt` / `generateFeed`, `currentOnly`, and the types |
216
+ | `svelte-docsmith/preprocess` | `docsmith()`, the mdsvex/Shiki pipeline (Node, config time) |
217
+ | `svelte-docsmith/vite` | `docsmith()`, the content / search / llms / changelog indexes and `?source` transform (Node, build) |
218
+ | `svelte-docsmith/content` | `docs` and `versions`, the generated sidebar content index and version manifest |
219
+ | `svelte-docsmith/search` | `docs`, the generated full-text search index (lazy-load it) |
220
+ | `svelte-docsmith/llms` | `docs`, the generated LLM content index (for `llms.txt` / `llms-full.txt` routes) |
221
+ | `svelte-docsmith/changelog` | `releases`, the generated changelog index (parsed from `CHANGELOG.md`) |
222
+ | `svelte-docsmith/mermaid` | the Mermaid diagram component (lazy-loaded by the preprocessor when a mermaid fence is present) |
223
+ | `svelte-docsmith/theme.css` | the base style contract |
224
+ | `svelte-docsmith/themes/*.css` | the eleven theme presets |
225
+
226
+ The package also ships a CLI binary: `npx svelte-docsmith archive-version <id>`
227
+ freezes the current docs into a versioned archive.
199
228
 
200
229
  The vendored shadcn primitives and internal helpers (the TOC engine, clipboard
201
230
  utility, markdown renderer map) are **not** part of the public API; they can
@@ -29,3 +29,21 @@ export declare function rewriteDocsLinks(text: string, options: {
29
29
  * existing `lastUpdated` and any page without frontmatter alone.
30
30
  */
31
31
  export declare function freezeLastUpdated(text: string, date: string | undefined): string;
32
+ /**
33
+ * The specifiers a page's `<script>` imports from outside the freeze boundary,
34
+ * deduplicated in source order. `pageDir` is the page's directory relative to
35
+ * the docs root, which is what its relative imports resolve against.
36
+ *
37
+ * Archiving copies the docs root and nothing else, so anything reached from
38
+ * outside it keeps resolving to current code: a live example on an archived
39
+ * page demonstrates the current library rather than the version the page
40
+ * documents, with a green build and nothing said. Hence `$lib`, bare npm
41
+ * specifiers and relative paths that climb out all count, while a relative
42
+ * import landing inside the docs root is frozen along with the page.
43
+ *
44
+ * `svelte-docsmith` itself is never reported. An archived page importing the
45
+ * authoring components is the expected shape, and those are covered by the
46
+ * stability promise rather than by this notice. See
47
+ * `docs/adr/0005-an-archive-freezes-content-not-dependencies.md`.
48
+ */
49
+ export declare function boundaryCrossings(source: string, pageDir: string): string[];
@@ -8,7 +8,7 @@
8
8
  * CLI is a thin wrapper over them. See `docs/adr/0002-archives-are-rewritten-source-copies.md`.
9
9
  */
10
10
  import { atBoundary, firstSegmentUnder } from '../utils/url.js';
11
- import { outsideCodeFences, withFrontmatter } from './markdown-source.js';
11
+ import { outsideCodeFences, scriptBlocks, withFrontmatter } from './markdown-source.js';
12
12
  /** Route files at the docs root that an archive nested inside it already inherits. */
13
13
  export function isInheritedRouteFile(name) {
14
14
  return /^\+(layout|error)\./.test(name);
@@ -50,3 +50,63 @@ export function freezeLastUpdated(text, date) {
50
50
  return text;
51
51
  return withFrontmatter(text, (front) => /^lastUpdated:/m.test(front) ? front : `${front}\nlastUpdated: '${date}'`);
52
52
  }
53
+ /**
54
+ * An import's specifier, static or dynamic. The clause between `import` and
55
+ * `from` may not hold a quote or a paren, so the scan cannot run past a
56
+ * side-effect import to borrow a later `from`, and `import.meta.glob('./x')`
57
+ * matches nothing.
58
+ */
59
+ const IMPORT = /\bimport\s*\(?\s*(?:[^'"()]*?\bfrom\s*)?['"]([^'"]+)['"]/g;
60
+ /** The package an archived page is expected to import, and its subpaths. */
61
+ const LIBRARY = 'svelte-docsmith';
62
+ /**
63
+ * Whether a relative specifier climbs out of the docs root when resolved from a
64
+ * page `depth` directories inside it. Counting segments rather than resolving
65
+ * real paths keeps the rule pure, and the docs root is the only boundary that
66
+ * matters: where under it a surviving import lands is the archive's business.
67
+ */
68
+ function escapesRoot(specifier, depth) {
69
+ let level = depth;
70
+ for (const segment of specifier.split('/')) {
71
+ if (segment === '..')
72
+ level--;
73
+ else if (segment !== '.' && segment !== '')
74
+ level++;
75
+ if (level < 0)
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * The specifiers a page's `<script>` imports from outside the freeze boundary,
82
+ * deduplicated in source order. `pageDir` is the page's directory relative to
83
+ * the docs root, which is what its relative imports resolve against.
84
+ *
85
+ * Archiving copies the docs root and nothing else, so anything reached from
86
+ * outside it keeps resolving to current code: a live example on an archived
87
+ * page demonstrates the current library rather than the version the page
88
+ * documents, with a green build and nothing said. Hence `$lib`, bare npm
89
+ * specifiers and relative paths that climb out all count, while a relative
90
+ * import landing inside the docs root is frozen along with the page.
91
+ *
92
+ * `svelte-docsmith` itself is never reported. An archived page importing the
93
+ * authoring components is the expected shape, and those are covered by the
94
+ * stability promise rather than by this notice. See
95
+ * `docs/adr/0005-an-archive-freezes-content-not-dependencies.md`.
96
+ */
97
+ export function boundaryCrossings(source, pageDir) {
98
+ const depth = pageDir.split('/').filter(Boolean).length;
99
+ const found = new Set();
100
+ for (const block of scriptBlocks(source)) {
101
+ for (const [, specifier] of block.matchAll(IMPORT)) {
102
+ if (specifier === LIBRARY || specifier.startsWith(`${LIBRARY}/`))
103
+ continue;
104
+ // Only a package name can start with something other than a dot, and no
105
+ // package name starts with one, so this is the whole relative/bare split.
106
+ if (specifier.startsWith('.') && !escapesRoot(specifier, depth))
107
+ continue;
108
+ found.add(specifier);
109
+ }
110
+ }
111
+ return [...found];
112
+ }
@@ -6,6 +6,9 @@ import type { ArchivesOnDisk } from '../core/version.js';
6
6
  * are otherwise indistinguishable, since a page is assigned to a version by its
7
7
  * first directory segment. See
8
8
  * `docs/adr/0003-the-archive-marker-defines-an-archive.md`.
9
+ *
10
+ * @experimental Outside the 1.0 stability promise. Name and role may change in
11
+ * a minor until authors have used versioning for real.
9
12
  */
10
13
  export declare const ARCHIVE_MARKER = ".docsmith-archive";
11
14
  /** The text written into a new archive's marker file. */
@@ -16,6 +16,9 @@ import path from 'node:path';
16
16
  * are otherwise indistinguishable, since a page is assigned to a version by its
17
17
  * first directory segment. See
18
18
  * `docs/adr/0003-the-archive-marker-defines-an-archive.md`.
19
+ *
20
+ * @experimental Outside the 1.0 stability promise. Name and role may change in
21
+ * a minor until authors have used versioning for real.
19
22
  */
20
23
  export const ARCHIVE_MARKER = '.docsmith-archive';
21
24
  /** The text written into a new archive's marker file. */
@@ -14,5 +14,8 @@ export type ArchiveVersionOptions = {
14
14
  * Copy the docs root into `<content>/<id>/`, mark it as an archive, and rewrite
15
15
  * each copied page so it stays inside the archive and keeps its real
16
16
  * last-updated date. Returns the number of pages archived.
17
+ *
18
+ * @experimental Outside the 1.0 stability promise. May change in a minor until
19
+ * authors have used versioning for real.
17
20
  */
18
21
  export declare function archiveVersion(options: ArchiveVersionOptions, log: (line: string) => void): number;
@@ -3,15 +3,23 @@
3
3
  * archived version folder so they keep serving the release they document while
4
4
  * the docs root goes on being edited.
5
5
  *
6
+ * Freezes content, not dependencies: the prose, samples and URLs are that
7
+ * version's, while anything a page imports keeps resolving to whatever the app
8
+ * currently has installed. See
9
+ * `docs/adr/0005-an-archive-freezes-content-not-dependencies.md`.
10
+ *
6
11
  * The pure text transforms live in `../archive.js`; this is the filesystem work
7
12
  * around them. It takes its working directory and its output sink rather than
8
13
  * reading `process.cwd()` and calling `console.log`, so the whole command runs
9
14
  * in-process under test. See `docs/adr/0002-archives-are-rewritten-source-copies.md`.
15
+ *
16
+ * @experimental Outside the 1.0 stability promise. Flags and behaviour may
17
+ * change in a minor until authors have used versioning for real.
10
18
  */
11
19
  import fs from 'node:fs';
12
20
  import path from 'node:path';
13
21
  import { assertValidVersionId } from '../../core/version.js';
14
- import { freezeLastUpdated, isInheritedRouteFile, rewriteDocsLinks } from '../archive.js';
22
+ import { boundaryCrossings, freezeLastUpdated, isInheritedRouteFile, rewriteDocsLinks } from '../archive.js';
15
23
  import { ARCHIVE_MARKER, discoverArchives, markerContents } from '../archives.js';
16
24
  import { commitDates } from '../git.js';
17
25
  import { docsBaseFrom } from '../paths.js';
@@ -21,6 +29,9 @@ import { CliError } from './error.js';
21
29
  * Copy the docs root into `<content>/<id>/`, mark it as an archive, and rewrite
22
30
  * each copied page so it stays inside the archive and keeps its real
23
31
  * last-updated date. Returns the number of pages archived.
32
+ *
33
+ * @experimental Outside the 1.0 stability promise. May change in a minor until
34
+ * authors have used versioning for real.
24
35
  */
25
36
  export function archiveVersion(options, log) {
26
37
  const cwd = options.cwd ?? process.cwd();
@@ -84,17 +95,23 @@ export function archiveVersion(options, log) {
84
95
  // be read: an archive is written once and never edited again, so a page that
85
96
  // misses its date here keeps the wrong one for good.
86
97
  const dates = commitDates(contentDir, (reason) => {
87
- log(`\n! Could not read commit dates: ${reason}`);
98
+ log(`\n! Skipping git-derived page dates: ${reason}`);
88
99
  log(' Archived pages will keep no last-updated date.\n');
89
100
  });
90
101
  copyCurrent(contentDir, toDir);
91
102
  fs.writeFileSync(path.join(toDir, ARCHIVE_MARKER), markerContents(id));
92
103
  let pages = 0;
104
+ /** One `path: specifiers` line per page that imports across the freeze boundary. */
105
+ const crossings = [];
93
106
  for (const { file, source } of eachCopiedFile()) {
94
107
  if (!isPageFile(path.basename(file)))
95
108
  continue;
96
109
  pages++;
97
110
  const text = fs.readFileSync(file, 'utf-8');
111
+ const pageDir = path.relative(toDir, path.dirname(file)).split(path.sep).join('/');
112
+ const crossed = boundaryCrossings(text, pageDir);
113
+ if (crossed.length)
114
+ crossings.push(` ${rel(file)}: ${crossed.join(', ')}`);
98
115
  fs.writeFileSync(file, freezeLastUpdated(rewriteDocsLinks(text, { docsBase, versionId: id, archivedIds }), dates.get(source)));
99
116
  }
100
117
  log(`\n✓ Archived the current docs into ${rel(toDir)} (${pages} pages)\n`);
@@ -102,6 +119,17 @@ export function archiveVersion(options, log) {
102
119
  // Only claim the dates were kept when the walk actually produced some.
103
120
  if (dates.size)
104
121
  log('Each page kept its real last-updated date.');
122
+ // Informational: the archive is correct as content, and there is nothing to
123
+ // fix here. Said out loud because the alternative is a green build hiding a
124
+ // v1 page that demos v2, which nothing downstream can notice.
125
+ if (crossings.length) {
126
+ const count = crossings.length === 1 ? '1 page' : `${crossings.length} pages`;
127
+ log(`\n! ${count} import across the freeze boundary:`);
128
+ crossings.sort().forEach(log);
129
+ log(' Archiving copies the docs root, not what it imports, so these keep');
130
+ log(' resolving to current code. Their examples will demonstrate the');
131
+ log(' current library rather than the version this archive documents.\n');
132
+ }
105
133
  log('Review the diff, then update docsmith({ versions }) so the archive');
106
134
  log('is served:\n');
107
135
  log(' versions: {');
@@ -7,6 +7,9 @@
7
7
  * message and an exit code. Keeping the process boundary out here means the
8
8
  * command is typechecked, and testable in-process against a temporary directory
9
9
  * rather than by spawning node and parsing stdout.
10
+ *
11
+ * @experimental `archive-version` sits outside the 1.0 stability promise and
12
+ * may change in a minor until authors have used versioning for real.
10
13
  */
11
14
  import { archiveVersion } from './archive-version.js';
12
15
  import { CliError } from './error.js';
@@ -14,12 +14,19 @@
14
14
  * all-or-nothing: a truncated read would date some pages and not others, which
15
15
  * looks like real data and is not.
16
16
  *
17
- * Only a truncated read calls `onFail`, because it is the one failure that would
18
- * otherwise date some pages and not others. A directory outside a repository, a
19
- * missing directory, or a machine without git all yield no dates, which every
20
- * caller already handles and which the plugin already reports where it matters.
21
- * Reporting is the caller's to do, which is why this takes a callback rather
22
- * than writing to the console itself.
17
+ * A shallow clone also yields no dates. Its single commit introduces every
18
+ * tracked file, so a walk would stamp every page with the same day — silent,
19
+ * plausible, and wrong for both page footers and sitemap `<lastmod>`. Omitting
20
+ * the date is legitimate; a uniform wrong date is not. Callers that need real
21
+ * dates should build from a full clone (`fetch-depth: 0` in CI, unshallow on
22
+ * the host).
23
+ *
24
+ * `onFail` fires for a truncated read or a shallow repository: those are the
25
+ * cases that would otherwise produce dates that look real and are not. A
26
+ * directory outside a repository, a missing directory, or a machine without git
27
+ * all yield no dates quietly, which every caller already handles. Reporting is
28
+ * the caller's to do, which is why this takes a callback rather than writing to
29
+ * the console itself.
23
30
  */
24
31
  export declare function commitDates(dir: string, onFail?: (reason: string) => void,
25
32
  /**
@@ -28,12 +28,19 @@ import path from 'node:path';
28
28
  * all-or-nothing: a truncated read would date some pages and not others, which
29
29
  * looks like real data and is not.
30
30
  *
31
- * Only a truncated read calls `onFail`, because it is the one failure that would
32
- * otherwise date some pages and not others. A directory outside a repository, a
33
- * missing directory, or a machine without git all yield no dates, which every
34
- * caller already handles and which the plugin already reports where it matters.
35
- * Reporting is the caller's to do, which is why this takes a callback rather
36
- * than writing to the console itself.
31
+ * A shallow clone also yields no dates. Its single commit introduces every
32
+ * tracked file, so a walk would stamp every page with the same day — silent,
33
+ * plausible, and wrong for both page footers and sitemap `<lastmod>`. Omitting
34
+ * the date is legitimate; a uniform wrong date is not. Callers that need real
35
+ * dates should build from a full clone (`fetch-depth: 0` in CI, unshallow on
36
+ * the host).
37
+ *
38
+ * `onFail` fires for a truncated read or a shallow repository: those are the
39
+ * cases that would otherwise produce dates that look real and are not. A
40
+ * directory outside a repository, a missing directory, or a machine without git
41
+ * all yield no dates quietly, which every caller already handles. Reporting is
42
+ * the caller's to do, which is why this takes a callback rather than writing to
43
+ * the console itself.
37
44
  */
38
45
  export function commitDates(dir, onFail,
39
46
  /**
@@ -43,6 +50,13 @@ export function commitDates(dir, onFail,
43
50
  */
44
51
  maxBuffer = 256 * 1024 * 1024) {
45
52
  const dates = new Map();
53
+ // One cheap probe before the walk. On a shallow clone the walk would "work"
54
+ // and return one day for every file — worse than failing, so refuse first.
55
+ if (isShallowRepository(dir)) {
56
+ onFail?.('shallow clone: git history is incomplete, so commit dates would be wrong. ' +
57
+ 'Build from a full clone (e.g. actions/checkout fetch-depth: 0), or set lastUpdated in frontmatter.');
58
+ return dates;
59
+ }
46
60
  const res = spawnSync('git', ['log', '--format=%cs', '--name-only', '--relative', '--no-renames', '--', '.'],
47
61
  // The 1 MB default would truncate rather than throw on a large history,
48
62
  // which is why the cap is raised and the result checked below.
@@ -71,6 +85,17 @@ maxBuffer = 256 * 1024 * 1024) {
71
85
  }
72
86
  return dates;
73
87
  }
88
+ /**
89
+ * Whether `dir` sits in a shallow clone. Outside a repository, or when git is
90
+ * missing, returns false so the walk's own failure path still owns that case.
91
+ */
92
+ function isShallowRepository(dir) {
93
+ const res = spawnSync('git', ['rev-parse', '--is-shallow-repository'], {
94
+ cwd: dir,
95
+ encoding: 'utf-8'
96
+ });
97
+ return res.status === 0 && res.stdout.trim() === 'true';
98
+ }
74
99
  /**
75
100
  * Release dates keyed by version, read from the commit that introduced each
76
101
  * `## <version>` heading in a changelog. Changesets does not write dates, and a
@@ -29,3 +29,12 @@ export declare function withFrontmatter(source: string, transform: (frontmatter:
29
29
  export declare function outsideCodeFences(text: string, transform: (line: string) => string): string;
30
30
  /** A page's prose lines, in order, with fenced code and its markers dropped. */
31
31
  export declare function proseLines(text: string): Generator<string>;
32
+ /**
33
+ * The code inside each `<script>` block a page actually runs, in order.
34
+ *
35
+ * Fenced blocks are skipped, which is the whole reason this reads the page
36
+ * through {@link proseLines} rather than scanning the raw text: a docs page
37
+ * about authoring is mostly ```svelte samples that open with a `<script>`, and
38
+ * those are code being displayed, not code the page runs.
39
+ */
40
+ export declare function scriptBlocks(text: string): Generator<string>;
@@ -88,3 +88,25 @@ export function* proseLines(text) {
88
88
  yield line;
89
89
  }
90
90
  }
91
+ /**
92
+ * A `<script>` element with any attributes, through its closing tag. The open
93
+ * tag has to start a line, indented no further than CommonMark lets HTML be:
94
+ * four spaces make an indented code block, and mid-line means a sentence about
95
+ * a script tag rather than one. Authoring prose writes `` `<script>` `` in
96
+ * inline code constantly, and a page that only talks about a script block has
97
+ * no script block.
98
+ */
99
+ const SCRIPT_BLOCK = /^ {0,3}<script\b[^>]*>([\s\S]*?)<\/script>/gim;
100
+ /**
101
+ * The code inside each `<script>` block a page actually runs, in order.
102
+ *
103
+ * Fenced blocks are skipped, which is the whole reason this reads the page
104
+ * through {@link proseLines} rather than scanning the raw text: a docs page
105
+ * about authoring is mostly ```svelte samples that open with a `<script>`, and
106
+ * those are code being displayed, not code the page runs.
107
+ */
108
+ export function* scriptBlocks(text) {
109
+ const prose = [...proseLines(splitFrontmatter(text).body)].join('\n');
110
+ for (const match of prose.matchAll(SCRIPT_BLOCK))
111
+ yield match[1];
112
+ }
@@ -31,6 +31,10 @@ export interface DocsmithViteOptions {
31
31
  * keep their unprefixed URLs. Each archived version's pages live under
32
32
  * `<content>/<id>/` and are served at `/docs/<id>/…`. Omit for a single,
33
33
  * unversioned docs tree (the default).
34
+ *
35
+ * @experimental Outside the 1.0 stability promise. May change in a minor
36
+ * until authors have used versioning for real. An archive freezes content,
37
+ * not dependencies.
34
38
  */
35
39
  versions?: DocsVersions;
36
40
  }
@@ -78,7 +78,10 @@ function contentIndexPlugin(options) {
78
78
  // `dev` leaves the dates as they were until the server restarts.
79
79
  let dates;
80
80
  const trackedDates = () => (dates ??= commitDates(contentDir, (reason) => {
81
- console.warn(`[svelte-docsmith] could not read commit dates under ${contentDir}\n` +
81
+ // Truncation and shallow clones both refuse dates deliberately; the
82
+ // reason line says which. "Could not read" would misframe a shallow
83
+ // skip as a hard failure.
84
+ console.warn(`[svelte-docsmith] skipping git-derived page dates under ${contentDir}\n` +
82
85
  ` ${reason}\n` +
83
86
  ` Pages will render without a last-updated date, and sitemap entries without \`lastmod\`.`);
84
87
  }));
@@ -98,7 +98,7 @@
98
98
  {/if}
99
99
 
100
100
  <pre
101
- class="not-prose shiki max-h-[32rem] flex shrink"
101
+ class="docsmith-code not-prose shiki max-h-[32rem] flex shrink"
102
102
  class:docsmith-line-numbers={lineNumbers}
103
103
  style={lineNumbers && startLine !== 1 ? `--docsmith-line-start: ${startLine - 1}` : undefined}
104
104
  use:clipboard.readText>
@@ -113,8 +113,17 @@
113
113
  * published package, where the consumer's Tailwind theme context isn't
114
114
  * available to resolve @apply (Tailwind v4 compiles scoped styles in
115
115
  * isolation).
116
+ *
117
+ * Shiki's markup arrives through `{@html}`, so Svelte cannot scope these for
118
+ * us and they have to be global. Every one is therefore anchored on
119
+ * `pre.docsmith-code`, the class this component puts on its own element.
120
+ * Anchoring on the bare element instead would restyle every code block on
121
+ * the page, including ones this component never rendered — and since a
122
+ * route's CSS only arrives when that route loads (SvelteKit preloads on
123
+ * link hover), the restyle would land mid-session and shift the layout
124
+ * under the pointer.
116
125
  */
117
- :global(pre code) {
126
+ :global(pre.docsmith-code code) {
118
127
  display: block;
119
128
  flex-grow: 1;
120
129
  height: 100%;
@@ -122,7 +131,7 @@
122
131
  padding-block: 1rem;
123
132
  }
124
133
 
125
- :global(pre span.line) {
134
+ :global(pre.docsmith-code span.line) {
126
135
  display: inline-block;
127
136
  width: 100%;
128
137
  padding-inline: 1rem;
@@ -149,15 +158,15 @@
149
158
  A CSS counter on Shiki's existing per-line spans, so numbering adds no DOM
150
159
  and stays out of the text the copy button reads. `--docsmith-line-start`
151
160
  offsets the counter for a snippet lifted out of a larger file. */
152
- :global(pre.docsmith-line-numbers code) {
161
+ :global(pre.docsmith-code.docsmith-line-numbers code) {
153
162
  counter-reset: docsmith-line var(--docsmith-line-start, 0);
154
163
  }
155
- :global(pre.docsmith-line-numbers span.line) {
164
+ :global(pre.docsmith-code.docsmith-line-numbers span.line) {
156
165
  counter-increment: docsmith-line;
157
166
  position: relative;
158
167
  padding-inline-start: 3.2rem;
159
168
  }
160
- :global(pre.docsmith-line-numbers span.line::before) {
169
+ :global(pre.docsmith-code.docsmith-line-numbers span.line::before) {
161
170
  content: counter(docsmith-line);
162
171
  position: absolute;
163
172
  left: 0;
@@ -169,19 +178,19 @@
169
178
  user-select: none;
170
179
  }
171
180
  /* The diff gutter glyph would collide with the number, so shift it over. */
172
- :global(pre.docsmith-line-numbers span.line.diff::before) {
181
+ :global(pre.docsmith-code.docsmith-line-numbers span.line.diff::before) {
173
182
  content: counter(docsmith-line);
174
183
  }
175
- :global(pre.docsmith-line-numbers span.line.diff::after) {
184
+ :global(pre.docsmith-code.docsmith-line-numbers span.line.diff::after) {
176
185
  position: absolute;
177
186
  left: 2.7rem;
178
187
  font-weight: 600;
179
188
  }
180
- :global(pre.docsmith-line-numbers span.line.diff.add::after) {
189
+ :global(pre.docsmith-code.docsmith-line-numbers span.line.diff.add::after) {
181
190
  content: '+';
182
191
  color: oklch(0.6 0.15 150);
183
192
  }
184
- :global(pre.docsmith-line-numbers span.line.diff.remove::after) {
193
+ :global(pre.docsmith-code.docsmith-line-numbers span.line.diff.remove::after) {
185
194
  content: '−';
186
195
  color: oklch(0.58 0.19 25);
187
196
  }
@@ -193,76 +202,76 @@
193
202
  the code background via color-mix. */
194
203
 
195
204
  /* --- Line highlight (// [!code highlight] and {1,3} meta) --- */
196
- :global(pre span.line.highlighted) {
205
+ :global(pre.docsmith-code span.line.highlighted) {
197
206
  background: color-mix(in oklch, var(--primary) 12%, transparent);
198
207
  }
199
- :global(.dark pre span.line.highlighted) {
208
+ :global(.dark pre.docsmith-code span.line.highlighted) {
200
209
  background: color-mix(in oklch, var(--primary) 20%, transparent);
201
210
  }
202
211
 
203
212
  /* --- Diff (// [!code ++] / [!code --]) with +/- gutter glyphs --- */
204
- :global(pre span.line.diff) {
213
+ :global(pre.docsmith-code span.line.diff) {
205
214
  position: relative;
206
215
  }
207
- :global(pre span.line.diff.add) {
216
+ :global(pre.docsmith-code span.line.diff.add) {
208
217
  background: color-mix(in oklch, oklch(0.7 0.16 150) 15%, transparent);
209
218
  }
210
- :global(pre span.line.diff.remove) {
219
+ :global(pre.docsmith-code span.line.diff.remove) {
211
220
  background: color-mix(in oklch, oklch(0.62 0.2 25) 14%, transparent);
212
221
  }
213
- :global(.dark pre span.line.diff.add) {
222
+ :global(.dark pre.docsmith-code span.line.diff.add) {
214
223
  background: color-mix(in oklch, oklch(0.7 0.16 150) 22%, transparent);
215
224
  }
216
- :global(.dark pre span.line.diff.remove) {
225
+ :global(.dark pre.docsmith-code span.line.diff.remove) {
217
226
  background: color-mix(in oklch, oklch(0.62 0.2 25) 22%, transparent);
218
227
  }
219
- :global(pre span.line.diff::before) {
228
+ :global(pre.docsmith-code span.line.diff::before) {
220
229
  position: absolute;
221
230
  left: 0.45rem;
222
231
  font-weight: 600;
223
232
  }
224
- :global(pre span.line.diff.add::before) {
233
+ :global(pre.docsmith-code span.line.diff.add::before) {
225
234
  content: '+';
226
235
  color: oklch(0.6 0.15 150);
227
236
  }
228
- :global(pre span.line.diff.remove::before) {
237
+ :global(pre.docsmith-code span.line.diff.remove::before) {
229
238
  content: '−';
230
239
  color: oklch(0.58 0.19 25);
231
240
  }
232
- :global(.dark pre span.line.diff.add::before) {
241
+ :global(.dark pre.docsmith-code span.line.diff.add::before) {
233
242
  color: oklch(0.78 0.17 150);
234
243
  }
235
- :global(.dark pre span.line.diff.remove::before) {
244
+ :global(.dark pre.docsmith-code span.line.diff.remove::before) {
236
245
  color: oklch(0.72 0.18 25);
237
246
  }
238
247
 
239
248
  /* --- Error / warning severity (// [!code error] / [!code warning]).
240
249
  No gutter glyph, so they stay distinct from the +/- diff lines. --- */
241
- :global(pre span.line.highlighted.error) {
250
+ :global(pre.docsmith-code span.line.highlighted.error) {
242
251
  background: color-mix(in oklch, oklch(0.62 0.2 25) 18%, transparent);
243
252
  }
244
- :global(pre span.line.highlighted.warning) {
253
+ :global(pre.docsmith-code span.line.highlighted.warning) {
245
254
  background: color-mix(in oklch, oklch(0.8 0.13 85) 22%, transparent);
246
255
  }
247
- :global(.dark pre span.line.highlighted.error) {
256
+ :global(.dark pre.docsmith-code span.line.highlighted.error) {
248
257
  background: color-mix(in oklch, oklch(0.62 0.2 25) 26%, transparent);
249
258
  }
250
- :global(.dark pre span.line.highlighted.warning) {
259
+ :global(.dark pre.docsmith-code span.line.highlighted.warning) {
251
260
  background: color-mix(in oklch, oklch(0.8 0.13 85) 20%, transparent);
252
261
  }
253
262
 
254
263
  /* --- Focus (// [!code focus]): dim the other lines and restore on hover.
255
264
  A clean opacity fade reads calmer than a muddy blur. `:has` recreates
256
265
  Shiki's stripped container flag from the line classes alone. --- */
257
- :global(pre code:has(span.line.focused) span.line:not(.focused)) {
266
+ :global(pre.docsmith-code code:has(span.line.focused) span.line:not(.focused)) {
258
267
  opacity: 0.42;
259
268
  transition: opacity 0.25s ease;
260
269
  }
261
- :global(pre:hover code:has(span.line.focused) span.line:not(.focused)) {
270
+ :global(pre.docsmith-code:hover code:has(span.line.focused) span.line:not(.focused)) {
262
271
  opacity: 1;
263
272
  }
264
273
  @media (prefers-reduced-motion: reduce) {
265
- :global(pre code:has(span.line.focused) span.line:not(.focused)) {
274
+ :global(pre.docsmith-code code:has(span.line.focused) span.line:not(.focused)) {
266
275
  transition: none;
267
276
  }
268
277
  }
@@ -273,11 +282,11 @@
273
282
  inspectable; the popup is a hidden sibling revealed on hover. `unclipPopups`
274
283
  then promotes it to fixed positioning so the code block's scrolling does not
275
284
  clip it — see the comment there. */
276
- :global(pre .twoslash-hover) {
285
+ :global(pre.docsmith-code .twoslash-hover) {
277
286
  position: relative;
278
287
  border-bottom: 1px dotted color-mix(in oklch, var(--muted-foreground) 55%, transparent);
279
288
  }
280
- :global(pre .twoslash-popup-container) {
289
+ :global(pre.docsmith-code .twoslash-popup-container) {
281
290
  position: absolute;
282
291
  z-index: 40;
283
292
  display: none;
@@ -298,11 +307,11 @@
298
307
  font-size: 0.85em;
299
308
  line-height: 1.5;
300
309
  }
301
- :global(pre .twoslash-hover:hover .twoslash-popup-container) {
310
+ :global(pre.docsmith-code .twoslash-hover:hover .twoslash-popup-container) {
302
311
  display: block;
303
312
  }
304
313
  /* The signature keeps Shiki's own colours and monospace. */
305
- :global(pre .twoslash-popup-code) {
314
+ :global(pre.docsmith-code .twoslash-popup-code) {
306
315
  display: block;
307
316
  font-family: var(--font-mono, ui-monospace, monospace);
308
317
  white-space: pre-wrap;
@@ -311,7 +320,7 @@
311
320
  /* Prose, by contrast, is prose. Inside a <pre> it would otherwise inherit
312
321
  monospace and whatever token colour the hovered span happened to carry,
313
322
  which reads as more code rather than an explanation of it. */
314
- :global(pre .twoslash-popup-docs) {
323
+ :global(pre.docsmith-code .twoslash-popup-docs) {
315
324
  /* The dark-theme swap repaints every span inside `.shiki` with
316
325
  `--shiki-dark`, which these spans inherit from whichever token was
317
326
  hovered — so docs took on the colour of the word above them. Redefining
@@ -330,20 +339,20 @@
330
339
 
331
340
  /* Each @param is its own line: the tags are inline spans, so without this
332
341
  they run together into one paragraph. */
333
- :global(pre .twoslash-popup-docs-tags) {
342
+ :global(pre.docsmith-code .twoslash-popup-docs-tags) {
334
343
  display: flex;
335
344
  flex-direction: column;
336
345
  gap: 0.35rem;
337
346
  }
338
- :global(pre .twoslash-popup-docs-tag) {
347
+ :global(pre.docsmith-code .twoslash-popup-docs-tag) {
339
348
  display: block;
340
349
  }
341
- :global(pre .twoslash-popup-docs-tag-value) {
350
+ :global(pre.docsmith-code .twoslash-popup-docs-tag-value) {
342
351
  --shiki-dark: var(--muted-foreground);
343
352
  color: var(--muted-foreground);
344
353
  }
345
354
  /* And the tag name needs a gap, or it reads as "@paramcallbackfn". */
346
- :global(pre .twoslash-popup-docs-tag-name) {
355
+ :global(pre.docsmith-code .twoslash-popup-docs-tag-name) {
347
356
  --shiki-dark: var(--primary);
348
357
  margin-right: 0.4em;
349
358
  font-family: var(--font-mono, ui-monospace, monospace);
@@ -351,18 +360,18 @@
351
360
  color: var(--primary);
352
361
  }
353
362
  /* A snippet that deliberately shows an error (// @errors:) marks the line. */
354
- :global(pre .twoslash-error) {
363
+ :global(pre.docsmith-code .twoslash-error) {
355
364
  background: color-mix(in oklch, oklch(0.62 0.2 25) 14%, transparent);
356
365
  }
357
366
 
358
367
  /* --- Word highlight (// [!code word:name]): a primary-tinted inline pill --- */
359
- :global(pre .highlighted-word) {
368
+ :global(pre.docsmith-code .highlighted-word) {
360
369
  border-radius: 0.3rem;
361
370
  padding: 0.1rem 0.3rem;
362
371
  background: color-mix(in oklch, var(--primary) 16%, transparent);
363
372
  box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 35%, transparent);
364
373
  }
365
- :global(.dark pre .highlighted-word) {
374
+ :global(.dark pre.docsmith-code .highlighted-word) {
366
375
  background: color-mix(in oklch, var(--primary) 24%, transparent);
367
376
  }
368
377
  </style>
@@ -25,9 +25,11 @@ export type DocsContentItem = {
25
25
  sourcePath?: string;
26
26
  /**
27
27
  * The day (`YYYY-MM-DD`) the page's source file last changed, from git, or
28
- * from the page's own `lastUpdated` frontmatter when it has one. A calendar
29
- * day rather than a timestamp: it is rendered as a UTC day, so it reads the
30
- * same for every reader and matches on the server and the client.
28
+ * from the page's own `lastUpdated` frontmatter when it has one. Absent when
29
+ * neither source supplies a day — including on a shallow clone, where git
30
+ * dates would be wrong. A calendar day rather than a timestamp: it is
31
+ * rendered as a UTC day, so it reads the same for every reader and matches
32
+ * on the server and the client.
31
33
  */
32
34
  lastUpdated?: string;
33
35
  /** Estimated reading time in whole minutes (at ~200 wpm), min 1. */
@@ -7,9 +7,18 @@
7
7
  * anyone edits; superseded releases are frozen copies under their own prefix.
8
8
  * See `docs/adr/0001-unprefixed-current-docs.md`. All of it is a no-op when a
9
9
  * site declares no versions.
10
+ *
11
+ * **Experimental.** The versioning API sits outside the 1.0 stability promise
12
+ * and may change in a minor until authors have used it for real. An archive
13
+ * freezes content, not dependencies — see
14
+ * `docs/adr/0005-an-archive-freezes-content-not-dependencies.md`.
10
15
  */
11
16
  import type { DocsContentItem } from './content.js';
12
- /** One documentation version, declared in the `docsmith()` vite plugin. */
17
+ /**
18
+ * One documentation version, declared in the `docsmith()` vite plugin.
19
+ *
20
+ * @experimental Outside the 1.0 stability promise. May change in a minor.
21
+ */
13
22
  export type DocsVersion = {
14
23
  /** Stable id. Also the URL/directory segment for an archived version. */
15
24
  id: string;
@@ -23,12 +32,22 @@ export type DocsVersion = {
23
32
  * the docs root, is served unprefixed, and is the folder you edit. `archived`
24
33
  * holds frozen copies of superseded releases, each in `<docs root>/<id>/`, in
25
34
  * the order they should appear in the switcher (newest first).
35
+ *
36
+ * Freezes content, not dependencies: prose, samples and URLs stay that
37
+ * version's, while imports keep resolving to whatever the app has installed.
38
+ *
39
+ * @experimental Outside the 1.0 stability promise. May change in a minor until
40
+ * authors have used versioning for real.
26
41
  */
27
42
  export type DocsVersions = {
28
43
  current: DocsVersion;
29
44
  archived?: DocsVersion[];
30
45
  };
31
- /** A {@link DocsVersion} with the URL fields the runtime needs, computed at build time. */
46
+ /**
47
+ * A {@link DocsVersion} with the URL fields the runtime needs, computed at build time.
48
+ *
49
+ * @experimental Outside the 1.0 stability promise. May change in a minor.
50
+ */
32
51
  export type ResolvedVersion = DocsVersion & {
33
52
  /** Absolute URL base: the docs root for `current`, `/docs/<id>` for an archive. */
34
53
  basePath: string;
@@ -98,6 +117,9 @@ export declare function currentVersion(versions: ResolvedVersion[]): ResolvedVer
98
117
  * Keep only the current version's pages, for `sitemap.xml` and `llms.txt`, so
99
118
  * search engines and LLMs index one canonical set. A no-op when the site
100
119
  * declares no versions. Works over any record carrying `version`.
120
+ *
121
+ * @experimental Outside the 1.0 stability promise. May change in a minor until
122
+ * authors have used versioning for real.
101
123
  */
102
124
  export declare function currentOnly<T extends {
103
125
  version?: string;
@@ -153,6 +153,9 @@ export function currentVersion(versions) {
153
153
  * Keep only the current version's pages, for `sitemap.xml` and `llms.txt`, so
154
154
  * search engines and LLMs index one canonical set. A no-op when the site
155
155
  * declares no versions. Works over any record carrying `version`.
156
+ *
157
+ * @experimental Outside the 1.0 stability promise. May change in a minor until
158
+ * authors have used versioning for real.
156
159
  */
157
160
  export function currentOnly(items, versions) {
158
161
  const current = currentVersion(versions);
package/dist/index.js CHANGED
@@ -31,10 +31,12 @@ export { default as CTA } from './components/landing/cta.svelte';
31
31
  export { default as Action } from './components/landing/action.svelte';
32
32
  // Config
33
33
  export { defineConfig } from './core/index.js';
34
- // Versioned docs. Declare `versions` in the `docsmith()` vite plugin; the
35
- // resolved manifest is exported from `svelte-docsmith/content` and passed to
36
- // `DocsShell`. `currentOnly` scopes sitemap.xml / llms.txt to the current
37
- // version, so archived releases don't compete with it in search results.
34
+ // Versioned docs (experimental — outside the 1.0 stability promise). Declare
35
+ // `versions` in the `docsmith()` vite plugin; the resolved manifest is exported
36
+ // from `svelte-docsmith/content` and passed to `DocsShell`. `currentOnly`
37
+ // scopes sitemap.xml / llms.txt to the current version, so archived releases
38
+ // don't compete with it in search results. An archive freezes content, not
39
+ // dependencies.
38
40
  export { currentOnly } from './core/index.js';
39
41
  // Search engine (framework-agnostic; for building a custom search UI over the
40
42
  // generated `svelte-docsmith/search` index)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-docsmith",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "A framework for building beautiful documentation sites with Svelte.",
5
5
  "author": {
6
6
  "name": "George Daskalakis",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "repository": {
68
68
  "type": "git",
69
- "url": "https://github.com/geodask/svelte-docsmith.git"
69
+ "url": "git+https://github.com/geodask/svelte-docsmith.git"
70
70
  },
71
71
  "license": "MIT",
72
72
  "peerDependencies": {