sdocs 0.0.25 → 0.0.28

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/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.28] - 2026-07-03
11
+
12
+ ### Changed
13
+
14
+ - **`sdocs dev`/`run`/`build` no longer write a `.sdocs/` directory into the
15
+ project.** The generated app is staged in a unique directory under
16
+ `node_modules/.cache/` (imports resolve exactly as before) and removed on
17
+ exit. This also fixes `sdocs build` breaking a concurrently running dev
18
+ server — they previously shared the same staging directory.
19
+
20
+ ## [0.0.27] - 2026-07-03
21
+
22
+ ### Added
23
+
24
+ - **`sdocs run`** — same as `dev`, built for `npx sdocs run`: works with no
25
+ local install. The dev server allows sdocs' own files from the npx cache,
26
+ and when the project has its own `svelte`, previews dedupe onto it, so
27
+ components and their dependencies always resolve from the project.
28
+
29
+ ### Changed
30
+
31
+ - `sdocs build` applies the same svelte dedupe as the dev server.
32
+
33
+ ## [0.0.26] - 2026-07-03
34
+
35
+ ### Fixed
36
+
37
+ - **Embedded production builds now include working previews.** The Vite
38
+ plugin emits each preview as a static page (plus your `css` stylesheets)
39
+ into the host app's client build under `previews/`, and `virtual:sdocs`
40
+ references those pages — preview iframes no longer 404 in deployed apps.
41
+ - The standalone CLI (`sdocs dev`/`sdocs build`) failed from an installed
42
+ package: the client app was staged from a wrong path, and its `ui/` styles
43
+ and fonts were not staged at all.
44
+ - The docs UI stops calling the dev-only highlight endpoint after the first
45
+ failure in production and falls back to plain code.
46
+
10
47
  ## [0.0.25] - 2026-07-02
11
48
 
12
49
  ### Added
@@ -169,13 +169,20 @@
169
169
  return generateFallbackCode(componentName, propValues, cssValues);
170
170
  });
171
171
 
172
- // Highlighted usage code via server-side Shiki
172
+ // Highlighted usage code via server-side Shiki. The endpoint is dev-server
173
+ // middleware; in a production build it doesn't exist, so after the first
174
+ // failure stop asking and fall back to plain code.
173
175
  let highlightedUsageHtml = $state('');
174
176
  let highlightTimer: ReturnType<typeof setTimeout> | undefined;
177
+ let highlightAvailable = true;
175
178
 
176
179
  $effect(() => {
177
180
  const code = usageCode;
178
181
  clearTimeout(highlightTimer);
182
+ if (!highlightAvailable) {
183
+ highlightedUsageHtml = '';
184
+ return;
185
+ }
179
186
  highlightTimer = setTimeout(async () => {
180
187
  try {
181
188
  const res = await fetch('/__sdocs/highlight', {
@@ -186,9 +193,13 @@
186
193
  if (res.ok) {
187
194
  const { html } = await res.json();
188
195
  highlightedUsageHtml = html;
196
+ } else {
197
+ highlightAvailable = false;
198
+ highlightedUsageHtml = '';
189
199
  }
190
200
  } catch {
191
- // Fallback: leave previous value
201
+ highlightAvailable = false;
202
+ highlightedUsageHtml = '';
192
203
  }
193
204
  }, 150);
194
205
  });
@@ -4,11 +4,12 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
4
4
  import { loadConfig } from '../server/config.js';
5
5
  import { sdocsPlugin } from '../vite.js';
6
6
  import { generateBuildFiles, cleanBuildFiles } from '../server/app-gen.js';
7
+ import { svelteDedupe } from './dev.js';
7
8
  export async function buildCommand() {
8
9
  const cwd = process.cwd();
9
10
  const config = await loadConfig(cwd);
10
11
  console.log('[sdocs] Building static site...');
11
- // Generate .sdocs/ with entry + preview HTML files
12
+ // Generate the staging directory (in the OS temp dir) with entry + preview HTML files
12
13
  const { sdocsDir, inputs } = await generateBuildFiles(config, cwd);
13
14
  const inputCount = Object.keys(inputs).length;
14
15
  console.log(`[sdocs] Generated ${inputCount} page(s) (1 main + ${inputCount - 1} previews)`);
@@ -18,6 +19,9 @@ export async function buildCommand() {
18
19
  await build({
19
20
  configFile: false,
20
21
  root: sdocsDir,
22
+ resolve: {
23
+ dedupe: svelteDedupe(cwd),
24
+ },
21
25
  plugins: [
22
26
  svelte(),
23
27
  sdocsPlugin({ ...config, include: absoluteIncludes, _buildMode: true }),
@@ -33,6 +37,6 @@ export async function buildCommand() {
33
37
  console.log(`[sdocs] Build complete → dist/`);
34
38
  }
35
39
  finally {
36
- await cleanBuildFiles(cwd);
40
+ await cleanBuildFiles(sdocsDir);
37
41
  }
38
42
  }
@@ -1 +1,9 @@
1
+ /** sdocs' own install location — under npx this is the npx cache, not the project */
2
+ export declare function sdocsPackageRoot(): string;
3
+ /**
4
+ * Prefer the project's own svelte over the copy next to sdocs when both exist
5
+ * (e.g. running via npx): previews import the project's components, and two
6
+ * svelte runtimes in one page don't mix.
7
+ */
8
+ export declare function svelteDedupe(cwd: string): string[];
1
9
  export declare function devCommand(): Promise<void>;
@@ -1,20 +1,45 @@
1
- import { resolve } from 'node:path';
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createRequire } from 'node:module';
2
4
  import { createServer } from 'vite';
3
5
  import { svelte } from '@sveltejs/vite-plugin-svelte';
4
6
  import { loadConfig } from '../server/config.js';
5
7
  import { sdocsPlugin } from '../vite.js';
6
8
  import { generateDevFiles, cleanBuildFiles } from '../server/app-gen.js';
9
+ const require = createRequire(import.meta.url);
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ /** sdocs' own install location — under npx this is the npx cache, not the project */
12
+ export function sdocsPackageRoot() {
13
+ return resolve(__dirname, '../..');
14
+ }
15
+ /**
16
+ * Prefer the project's own svelte over the copy next to sdocs when both exist
17
+ * (e.g. running via npx): previews import the project's components, and two
18
+ * svelte runtimes in one page don't mix.
19
+ */
20
+ export function svelteDedupe(cwd) {
21
+ try {
22
+ require.resolve('svelte/package.json', { paths: [cwd] });
23
+ return ['svelte'];
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
7
29
  export async function devCommand() {
8
30
  const cwd = process.cwd();
9
31
  const config = await loadConfig(cwd);
10
32
  console.log('[sdocs] Starting dev server...');
11
- // Generate .sdocs/ temp directory with entry files
33
+ // Generate the staging directory (in the OS temp dir) with entry files
12
34
  const sdocsDir = await generateDevFiles(config, cwd);
13
- // Resolve include patterns to absolute paths (relative to cwd, not .sdocs/)
35
+ // Resolve include patterns to absolute paths (relative to cwd, not the staging dir)
14
36
  const absoluteIncludes = config.include.map((p) => resolve(cwd, p));
15
37
  const server = await createServer({
16
38
  configFile: false,
17
39
  root: sdocsDir,
40
+ resolve: {
41
+ dedupe: svelteDedupe(cwd),
42
+ },
18
43
  plugins: [
19
44
  svelte(),
20
45
  sdocsPlugin({ ...config, include: absoluteIncludes }),
@@ -23,7 +48,10 @@ export async function devCommand() {
23
48
  port: config.port,
24
49
  open: config.open,
25
50
  fs: {
26
- allow: [cwd],
51
+ // The staging dir (an explicit allow list replaces Vite's implicit
52
+ // root allowance), the project, and sdocs' own dependency tree
53
+ // (the npx cache when running without a local install).
54
+ allow: [sdocsDir, cwd, resolve(sdocsPackageRoot(), '..')],
27
55
  },
28
56
  },
29
57
  });
@@ -32,7 +60,7 @@ export async function devCommand() {
32
60
  // Cleanup on exit
33
61
  const cleanup = async () => {
34
62
  await server.close();
35
- await cleanBuildFiles(cwd);
63
+ await cleanBuildFiles(sdocsDir);
36
64
  process.exit(0);
37
65
  };
38
66
  process.on('SIGINT', cleanup);
@@ -1,10 +1,10 @@
1
1
  import type { ResolvedSdocsConfig } from '../types.js';
2
- /** Generate .sdocs/ directory with entry files for dev mode */
2
+ /** Generate the staging directory with entry files for dev mode */
3
3
  export declare function generateDevFiles(config: ResolvedSdocsConfig, cwd: string): Promise<string>;
4
- /** Generate .sdocs/ directory with entry + preview HTML files for build mode */
4
+ /** Generate the staging directory with entry + preview HTML files for build mode */
5
5
  export declare function generateBuildFiles(config: ResolvedSdocsConfig, cwd: string): Promise<{
6
6
  sdocsDir: string;
7
7
  inputs: Record<string, string>;
8
8
  }>;
9
- /** Remove the .sdocs/ temp directory */
10
- export declare function cleanBuildFiles(cwd: string): Promise<void>;
9
+ /** Remove a staging directory created by generateDevFiles/generateBuildFiles */
10
+ export declare function cleanBuildFiles(stagingDir: string): Promise<void>;
@@ -1,13 +1,50 @@
1
- import { mkdir, writeFile, rm, readFile, copyFile, readdir } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, writeFile, rm, readFile, copyFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
2
4
  import { dirname, resolve, join } from 'node:path';
3
5
  import { fileURLToPath } from 'node:url';
4
6
  import { discoverDocFiles, getSdocKind } from './discovery.js';
5
7
  import { extractSnippets, hasDefaultSnippet } from './snippet-extractor.js';
6
8
  import { base64urlEncode } from './snippet-compiler.js';
7
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
- /** Source client directory in the installed package */
10
+ /** Source client directory in the installed package (dist/client, next to dist/server) */
9
11
  function getClientSourceDir() {
10
- return resolve(__dirname, 'client');
12
+ return resolve(__dirname, '../client');
13
+ }
14
+ /** Copy the client app plus the ui/ tree it imports (styles, fonts, components) */
15
+ async function copyClientApp(sdocsDir) {
16
+ await copyDir(getClientSourceDir(), resolve(sdocsDir, 'client'));
17
+ await copyDir(resolve(__dirname, '../ui'), resolve(sdocsDir, 'ui'));
18
+ }
19
+ /** Nearest node_modules walking up from dir (mirrors Node's resolution) */
20
+ function findNearestNodeModules(dir) {
21
+ let current = resolve(dir);
22
+ for (;;) {
23
+ const candidate = join(current, 'node_modules');
24
+ if (existsSync(candidate))
25
+ return candidate;
26
+ const parent = dirname(current);
27
+ if (parent === current)
28
+ return null;
29
+ current = parent;
30
+ }
31
+ }
32
+ /**
33
+ * Create a unique staging directory for the generated app. It lives inside
34
+ * node_modules/.cache — never a tracked file in the user's project — and being
35
+ * physically inside node_modules keeps the full walk-up resolution chain for
36
+ * bare imports (svelte, ...) intact, including hoisted monorepo setups. When
37
+ * no node_modules exists anywhere (running via npx in a bare folder), sdocs'
38
+ * own tree provides one; the OS temp dir is the last resort.
39
+ */
40
+ async function createStagingDir(cwd) {
41
+ const nodeModules = findNearestNodeModules(cwd) ?? findNearestNodeModules(resolve(__dirname, '..'));
42
+ if (nodeModules) {
43
+ const cacheDir = join(nodeModules, '.cache');
44
+ await mkdir(cacheDir, { recursive: true });
45
+ return mkdtemp(join(cacheDir, 'sdocs-'));
46
+ }
47
+ return mkdtemp(join(tmpdir(), 'sdocs-'));
11
48
  }
12
49
  /** Copy a directory recursively */
13
50
  async function copyDir(src, dest) {
@@ -124,22 +161,20 @@ async function discoverSnippets(config, cwd) {
124
161
  }
125
162
  return results;
126
163
  }
127
- /** Generate .sdocs/ directory with entry files for dev mode */
164
+ /** Generate the staging directory with entry files for dev mode */
128
165
  export async function generateDevFiles(config, cwd) {
129
- const sdocsDir = resolve(cwd, '.sdocs');
130
- await mkdir(sdocsDir, { recursive: true });
131
- // Copy client components into .sdocs/client/ so they're compiled outside node_modules
132
- await copyDir(getClientSourceDir(), resolve(sdocsDir, 'client'));
166
+ const sdocsDir = await createStagingDir(cwd);
167
+ // Copy client components into the staging dir so they're compiled outside node_modules
168
+ await copyClientApp(sdocsDir);
133
169
  await writeFile(resolve(sdocsDir, 'index.html'), generateIndexHtml(config.logo));
134
170
  await writeFile(resolve(sdocsDir, 'entry.js'), generateEntryJs(config));
135
171
  return sdocsDir;
136
172
  }
137
- /** Generate .sdocs/ directory with entry + preview HTML files for build mode */
173
+ /** Generate the staging directory with entry + preview HTML files for build mode */
138
174
  export async function generateBuildFiles(config, cwd) {
139
- const sdocsDir = resolve(cwd, '.sdocs');
140
- await mkdir(sdocsDir, { recursive: true });
141
- // Copy client components into .sdocs/client/
142
- await copyDir(getClientSourceDir(), resolve(sdocsDir, 'client'));
175
+ const sdocsDir = await createStagingDir(cwd);
176
+ // Copy client components into the staging dir
177
+ await copyClientApp(sdocsDir);
143
178
  await writeFile(resolve(sdocsDir, 'index.html'), generateIndexHtml(config.logo));
144
179
  await writeFile(resolve(sdocsDir, 'entry.js'), generateEntryJs(config));
145
180
  const inputs = {
@@ -161,8 +196,7 @@ export async function generateBuildFiles(config, cwd) {
161
196
  }
162
197
  return { sdocsDir, inputs };
163
198
  }
164
- /** Remove the .sdocs/ temp directory */
165
- export async function cleanBuildFiles(cwd) {
166
- const sdocsDir = resolve(cwd, '.sdocs');
167
- await rm(sdocsDir, { recursive: true, force: true });
199
+ /** Remove a staging directory created by generateDevFiles/generateBuildFiles */
200
+ export async function cleanBuildFiles(stagingDir) {
201
+ await rm(stagingDir, { recursive: true, force: true });
168
202
  }
@@ -18,7 +18,8 @@ Usage:
18
18
  sdocs <command>
19
19
 
20
20
  Commands:
21
- dev Start development server with HMR
21
+ dev Start development server with live reload
22
+ run Same as dev — works with npx and no local install
22
23
  build Build static documentation site
23
24
  preview Serve built site locally
24
25
  init Scaffold sdocs.config.js
@@ -39,7 +40,8 @@ async function main() {
39
40
  return;
40
41
  }
41
42
  switch (command) {
42
- case 'dev': {
43
+ case 'dev':
44
+ case 'run': {
43
45
  const { devCommand } = await import('../commands/dev.js');
44
46
  await devCommand();
45
47
  break;
@@ -7,14 +7,30 @@ export declare function resolveImportsToAbsolute(imports: string[], docFilePath:
7
7
  /** Generate a virtual Svelte iframe wrapper component for a snippet.
8
8
  * Includes $state for reactive prop updates via postMessage. */
9
9
  export declare function generateIframeComponent(absoluteImports: string[], snippetBody: string): string;
10
- /** Generate the HTML page served inside the iframe */
10
+ /** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
11
+ export declare function generateMountScript(iframeComponentId: string): string;
12
+ /** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
11
13
  export declare function generatePreviewHtml(iframeComponentId: string, css: string | Record<string, string> | null): string;
14
+ export interface StaticCssLink {
15
+ href: string;
16
+ name?: string;
17
+ disabled?: boolean;
18
+ }
19
+ /** Generate the HTML page for a preview emitted into a host app's build. */
20
+ export declare function generateStaticPreviewHtml(scriptSrc: string, cssLinks: StaticCssLink[]): string;
12
21
  /** Build the virtual module ID for an iframe wrapper component */
13
22
  export declare function iframeVirtualId(docFilePath: string, snippetName: string): string;
14
23
  /** Build the preview URL for an iframe HTML page (dev mode) */
15
24
  export declare function previewUrl(docFilePath: string, snippetName: string): string;
16
25
  /** Build the preview URL for static build output */
17
26
  export declare function buildPreviewUrl(docFilePath: string, snippetName: string): string;
27
+ /** Virtual module ID for a preview's mount script (embedded production builds) */
28
+ export declare function mountVirtualId(docFilePath: string, snippetName: string): string;
29
+ /** Parse a mount virtual ID back into its parts */
30
+ export declare function parseMountId(id: string): {
31
+ docFilePath: string;
32
+ snippetName: string;
33
+ } | null;
18
34
  /** Parse an iframe virtual ID back into its parts */
19
35
  export declare function parseIframeId(id: string): {
20
36
  docFilePath: string;
@@ -97,9 +97,27 @@ function generateCssLinks(css) {
97
97
  .map((name, i) => `<link rel="stylesheet" href="${normalizeCssHref(css[name])}" data-sdocs-stylesheet="${name}"${i > 0 ? ' disabled' : ''}>`)
98
98
  .join('\n\t');
99
99
  }
100
- /** Generate the HTML page served inside the iframe */
101
- export function generatePreviewHtml(iframeComponentId, css) {
102
- const cssLinks = generateCssLinks(css);
100
+ /** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
101
+ export function generateMountScript(iframeComponentId) {
102
+ return `import { mount } from 'svelte';
103
+ import App from '${iframeComponentId}';
104
+ mount(App, { target: document.getElementById('app') });
105
+
106
+ // Listen for sdocs messages from the parent frame
107
+ window.addEventListener('message', (e) => {
108
+ if (e.data?.type === 'sdocs:update-stylesheet') {
109
+ const name = e.data.name;
110
+ document.querySelectorAll('link[data-sdocs-stylesheet]').forEach((link) => {
111
+ link.disabled = link.dataset.sdocsStylesheet !== name;
112
+ });
113
+ }
114
+ if (e.data?.type === 'sdocs:scroll-to') {
115
+ const el = document.getElementById(e.data.id);
116
+ if (el) el.scrollIntoView({ behavior: 'smooth' });
117
+ }
118
+ });`;
119
+ }
120
+ function previewHtmlShell(cssLinks, script) {
103
121
  return `<!DOCTYPE html>
104
122
  <html>
105
123
  <head>
@@ -110,28 +128,26 @@ export function generatePreviewHtml(iframeComponentId, css) {
110
128
  </head>
111
129
  <body>
112
130
  <div id="app"></div>
113
- <script type="module">
114
- import { mount } from 'svelte';
115
- import App from '${iframeComponentId}';
116
- mount(App, { target: document.getElementById('app') });
117
-
118
- // Listen for sdocs messages from the parent frame
119
- window.addEventListener('message', (e) => {
120
- if (e.data?.type === 'sdocs:update-stylesheet') {
121
- const name = e.data.name;
122
- document.querySelectorAll('link[data-sdocs-stylesheet]').forEach((link) => {
123
- link.disabled = link.dataset.sdocsStylesheet !== name;
124
- });
125
- }
126
- if (e.data?.type === 'sdocs:scroll-to') {
127
- const el = document.getElementById(e.data.id);
128
- if (el) el.scrollIntoView({ behavior: 'smooth' });
129
- }
130
- });
131
- </script>
131
+ ${script}
132
132
  </body>
133
133
  </html>`;
134
134
  }
135
+ /** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
136
+ export function generatePreviewHtml(iframeComponentId, css) {
137
+ return previewHtmlShell(generateCssLinks(css), `<script type="module">
138
+ ${generateMountScript(iframeComponentId)}
139
+ </script>`);
140
+ }
141
+ /** Generate the HTML page for a preview emitted into a host app's build. */
142
+ export function generateStaticPreviewHtml(scriptSrc, cssLinks) {
143
+ const links = cssLinks
144
+ .map(({ href, name, disabled }) => {
145
+ const named = name ? ` data-sdocs-stylesheet="${name}"` : '';
146
+ return `<link rel="stylesheet" href="${href}"${named}${disabled ? ' disabled' : ''}>`;
147
+ })
148
+ .join('\n\t');
149
+ return previewHtmlShell(links, `<script type="module" src="${scriptSrc}"></script>`);
150
+ }
135
151
  /** Build the virtual module ID for an iframe wrapper component */
136
152
  export function iframeVirtualId(docFilePath, snippetName) {
137
153
  return `/@sdocs/iframe/${base64urlEncode(docFilePath)}/${snippetName}.svelte`;
@@ -144,6 +160,20 @@ export function previewUrl(docFilePath, snippetName) {
144
160
  export function buildPreviewUrl(docFilePath, snippetName) {
145
161
  return `/previews/${base64urlEncode(docFilePath)}/${snippetName}.html`;
146
162
  }
163
+ /** Virtual module ID for a preview's mount script (embedded production builds) */
164
+ export function mountVirtualId(docFilePath, snippetName) {
165
+ return `/@sdocs/mount/${base64urlEncode(docFilePath)}/${snippetName}.js`;
166
+ }
167
+ /** Parse a mount virtual ID back into its parts */
168
+ export function parseMountId(id) {
169
+ const match = id.match(/^\/@sdocs\/mount\/([^/]+)\/(\w+)\.js$/);
170
+ if (!match)
171
+ return null;
172
+ return {
173
+ docFilePath: base64urlDecode(match[1]),
174
+ snippetName: match[2],
175
+ };
176
+ }
147
177
  /** Parse an iframe virtual ID back into its parts */
148
178
  export function parseIframeId(id) {
149
179
  const match = id.match(/^\/@sdocs\/iframe\/([^/]+)\/(\w+)\.svelte$/);
package/dist/vite.js CHANGED
@@ -6,11 +6,12 @@ import { parseComponent } from './server/prop-parser.js';
6
6
  import { extractSnippets, extractMarkupBody, hasDefaultSnippet, generateAutoDefault, } from './server/snippet-extractor.js';
7
7
  import { highlight, disposeHighlighter } from './server/highlighter.js';
8
8
  import { extractTocFromHtml } from './server/toc-extractor.js';
9
- import { parseIframeId, parsePreviewUrl, resolveImportsToAbsolute, generateIframeComponent, generatePreviewHtml, iframeVirtualId, previewUrl, buildPreviewUrl, } from './server/snippet-compiler.js';
9
+ import { parseIframeId, parseMountId, parsePreviewUrl, resolveImportsToAbsolute, generateIframeComponent, generateMountScript, generatePreviewHtml, generateStaticPreviewHtml, iframeVirtualId, mountVirtualId, previewUrl, buildPreviewUrl, base64urlEncode, } from './server/snippet-compiler.js';
10
10
  const VIRTUAL_MODULE_ID = 'virtual:sdocs';
11
11
  const RESOLVED_VIRTUAL_ID = '\0virtual:sdocs';
12
12
  const IFRAME_PREFIX = '/@sdocs/iframe/';
13
13
  const PREVIEW_PREFIX = '/@sdocs/preview/';
14
+ const MOUNT_PREFIX = '/@sdocs/mount/';
14
15
  export function sdocsPlugin(userConfig) {
15
16
  let config;
16
17
  let root;
@@ -18,10 +19,17 @@ export function sdocsPlugin(userConfig) {
18
19
  let docEntries = new Map();
19
20
  let docImportsCache = new Map();
20
21
  const buildMode = userConfig?._buildMode ?? false;
22
+ let isBuild = false;
23
+ let isSsrBuild = false;
24
+ // Previews planned for emission into a host app's build (embedded mode)
25
+ let plannedPreviews = [];
26
+ let emittedCssLinks = [];
21
27
  return {
22
28
  name: 'sdocs',
23
29
  async configResolved(resolvedConfig) {
24
30
  root = resolvedConfig.root;
31
+ isBuild = resolvedConfig.command === 'build';
32
+ isSsrBuild = !!resolvedConfig.build?.ssr;
25
33
  const fileConfig = await loadRawConfig(root);
26
34
  const merged = { ...fileConfig, ...userConfig };
27
35
  config = resolveAndFinalize(merged, root);
@@ -125,12 +133,85 @@ export function sdocsPlugin(userConfig) {
125
133
  server.watcher.add(dir);
126
134
  }
127
135
  }
136
+ // Embedded production build: emit each preview as its own chunk so the
137
+ // host app's build output contains working static preview pages. (The
138
+ // standalone CLI build passes _buildMode and provides HTML inputs itself;
139
+ // the SSR half of an app build has no use for browser preview pages.)
140
+ if (isBuild && !buildMode && !isSsrBuild) {
141
+ plannedPreviews = [];
142
+ emittedCssLinks = [];
143
+ const css = config.css;
144
+ if (typeof css === 'string') {
145
+ emittedCssLinks.push({ href: await emitCssAsset(this, css, 'preview') });
146
+ }
147
+ else if (css) {
148
+ for (const [i, name] of Object.keys(css).entries()) {
149
+ emittedCssLinks.push({
150
+ href: await emitCssAsset(this, css[name], name),
151
+ name,
152
+ disabled: i > 0,
153
+ });
154
+ }
155
+ }
156
+ for (const entry of docEntries.values()) {
157
+ const encoded = base64urlEncode(entry.filePath);
158
+ for (const snippet of entry.snippets) {
159
+ const jsFileName = `previews/${encoded}/${snippet.name}.js`;
160
+ this.emitFile({
161
+ type: 'chunk',
162
+ id: mountVirtualId(entry.filePath, snippet.name),
163
+ fileName: jsFileName,
164
+ });
165
+ plannedPreviews.push({
166
+ jsFileName,
167
+ htmlFileName: `previews/${encoded}/${snippet.name}.html`,
168
+ });
169
+ }
170
+ }
171
+ console.log(`[sdocs] Emitting ${plannedPreviews.length} static preview page(s)`);
172
+ }
173
+ },
174
+ generateBundle(_options, bundle) {
175
+ for (const preview of plannedPreviews) {
176
+ const chunk = bundle[preview.jsFileName];
177
+ if (!chunk || chunk.type !== 'chunk')
178
+ continue;
179
+ // Collect CSS emitted for this chunk and everything it imports.
180
+ const cssFiles = new Set();
181
+ const queue = [preview.jsFileName];
182
+ const seen = new Set();
183
+ while (queue.length) {
184
+ const fileName = queue.pop();
185
+ if (seen.has(fileName))
186
+ continue;
187
+ seen.add(fileName);
188
+ const mod = bundle[fileName];
189
+ if (!mod || mod.type !== 'chunk')
190
+ continue;
191
+ for (const css of mod.viteMetadata?.importedCss ?? [])
192
+ cssFiles.add(css);
193
+ queue.push(...mod.imports);
194
+ }
195
+ // The HTML sits at previews/<doc>/<name>.html — two levels deep.
196
+ const cssLinks = [
197
+ ...emittedCssLinks,
198
+ ...[...cssFiles].map((file) => ({ href: `../../${file}` })),
199
+ ];
200
+ this.emitFile({
201
+ type: 'asset',
202
+ fileName: preview.htmlFileName,
203
+ source: generateStaticPreviewHtml(`./${preview.jsFileName.split('/').pop()}`, cssLinks),
204
+ });
205
+ }
206
+ plannedPreviews = [];
128
207
  },
129
208
  resolveId(id) {
130
209
  if (id === VIRTUAL_MODULE_ID)
131
210
  return RESOLVED_VIRTUAL_ID;
132
211
  if (id.startsWith(IFRAME_PREFIX))
133
212
  return '\0' + id;
213
+ if (id.startsWith(MOUNT_PREFIX))
214
+ return '\0' + id;
134
215
  },
135
216
  load(id) {
136
217
  if (id === RESOLVED_VIRTUAL_ID) {
@@ -151,6 +232,13 @@ export function sdocsPlugin(userConfig) {
151
232
  const absoluteImports = docImportsCache.get(parsed.docFilePath) ?? [];
152
233
  return generateIframeComponent(absoluteImports, snippet.body);
153
234
  }
235
+ // Virtual mount script for an emitted preview page
236
+ if (id.startsWith('\0' + MOUNT_PREFIX)) {
237
+ const parsed = parseMountId(id.slice(1));
238
+ if (!parsed)
239
+ return null;
240
+ return generateMountScript(iframeVirtualId(parsed.docFilePath, parsed.snippetName));
241
+ }
154
242
  },
155
243
  async buildEnd() {
156
244
  await disposeHighlighter();
@@ -232,7 +320,9 @@ export function sdocsPlugin(userConfig) {
232
320
  name: s.name,
233
321
  body: s.body,
234
322
  highlightedHtml: s.highlightedHtml,
235
- previewUrl: buildMode ? buildPreviewUrl(e.filePath, s.name) : previewUrl(e.filePath, s.name),
323
+ previewUrl: buildMode || isBuild
324
+ ? buildPreviewUrl(e.filePath, s.name)
325
+ : previewUrl(e.filePath, s.name),
236
326
  })),
237
327
  highlightedSource: e.highlightedSource,
238
328
  toc: e.toc,
@@ -269,6 +359,15 @@ export function sdocsPlugin(userConfig) {
269
359
  function isDocFile(filePath) {
270
360
  return filePath.endsWith('.sdoc');
271
361
  }
362
+ /** Emit a user stylesheet as a build asset; returns its href relative to a preview page. */
363
+ async function emitCssAsset(ctx, href, name) {
364
+ if (href.startsWith('http'))
365
+ return href;
366
+ const source = await readFile(href, 'utf-8');
367
+ const fileName = `previews/_css/${name}.css`;
368
+ ctx.emitFile({ type: 'asset', fileName, source });
369
+ return `../../${fileName}`;
370
+ }
272
371
  function isComponentReferencedByDoc(filePath) {
273
372
  for (const entry of docEntries.values()) {
274
373
  if (entry.componentPath === filePath)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs",
3
- "version": "0.0.25",
3
+ "version": "0.0.28",
4
4
  "description": "A lightweight documentation tool for Svelte 5 components",
5
5
  "type": "module",
6
6
  "license": "MIT",