@clidey/dory 1.0.3 → 1.0.6

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 (66) hide show
  1. package/bin/dist/dory.js +36 -2
  2. package/bin/dist/dory.js.map +1 -1
  3. package/index.html +0 -1
  4. package/package.json +27 -31
  5. package/scripts/check-trigger-children.mjs +60 -0
  6. package/scripts/trigger-children-lib.mjs +149 -0
  7. package/scripts/trigger-children.json +47 -0
  8. package/src/components/hooks.tsx +2 -1
  9. package/src/components/store.ts +5 -4
  10. package/src/config/mdx.js +8 -3
  11. package/src/config/mdx.ts +8 -3
  12. package/src/embed/widget-iframe.ts +10 -3
  13. package/src/entry-server.tsx +11 -7
  14. package/src/main.tsx +5 -1
  15. package/src/mdx/accordion.tsx +11 -8
  16. package/src/mdx/api.tsx +2 -352
  17. package/src/mdx/async-api.tsx +124 -295
  18. package/src/mdx/callout.tsx +22 -24
  19. package/src/mdx/card.tsx +20 -22
  20. package/src/mdx/chip.tsx +14 -0
  21. package/src/mdx/code.tsx +133 -110
  22. package/src/mdx/expandable.tsx +8 -8
  23. package/src/mdx/fence-meta.test.ts +39 -0
  24. package/src/mdx/fence-meta.ts +61 -0
  25. package/src/mdx/fence.tsx +155 -44
  26. package/src/mdx/icon-grid.tsx +29 -0
  27. package/src/mdx/icon.tsx +2 -2
  28. package/src/mdx/latex.tsx +14 -7
  29. package/src/mdx/list.tsx +3 -3
  30. package/src/mdx/mdx.tsx +7 -1
  31. package/src/mdx/mermaid.tsx +81 -11
  32. package/src/mdx/open-api.tsx +396 -239
  33. package/src/mdx/source.tsx +148 -40
  34. package/src/mdx/specimen.tsx +69 -0
  35. package/src/mdx/step.tsx +22 -30
  36. package/src/mdx/table.tsx +4 -9
  37. package/src/mdx/tabs.tsx +39 -0
  38. package/src/mdx/tag.tsx +13 -21
  39. package/src/mdx/text.tsx +17 -9
  40. package/src/mdx/type-scale.tsx +28 -0
  41. package/src/mdx/websocket-playground.tsx +221 -704
  42. package/src/plugins/code-meta.ts +23 -0
  43. package/src/plugins/frontmatter-dev-server.ts +6 -2
  44. package/src/plugins/html-files-middleware.ts +9 -2
  45. package/src/plugins/html-metadata-injector.ts +4 -3
  46. package/src/plugins/llm-txt-dev-server.ts +5 -2
  47. package/src/plugins/llm-txt-generator.ts +2 -1
  48. package/src/plugins/prerender.ts +7 -4
  49. package/src/plugins/relative-assets.test.ts +26 -0
  50. package/src/plugins/relative-assets.ts +80 -0
  51. package/src/plugins/robots-generator.ts +6 -4
  52. package/src/plugins/sanitize.test.ts +5 -0
  53. package/src/plugins/sanitize.ts +2 -0
  54. package/src/plugins/shared.ts +17 -0
  55. package/src/plugins/sitemap-generator.ts +2 -1
  56. package/src/theme.scss +282 -3
  57. package/src/types/config.ts +3 -0
  58. package/src/ui/header.tsx +4 -3
  59. package/src/ui/layout.tsx +22 -4
  60. package/src/ui/page-meta.tsx +37 -0
  61. package/src/ui/prev-next-link.tsx +4 -3
  62. package/src/ui/table-of-content.tsx +11 -3
  63. package/src/utils/base-path.test.ts +46 -0
  64. package/src/utils/base-path.ts +41 -0
  65. package/vite.config.ssr.ts +3 -0
  66. package/vite.config.ts +3 -0
@@ -0,0 +1,23 @@
1
+ import { visit } from 'unist-util-visit';
2
+
3
+ interface CodeElement {
4
+ tagName?: string;
5
+ data?: { meta?: string };
6
+ properties?: Record<string, unknown>;
7
+ }
8
+
9
+ /**
10
+ * Rehype plugin that keeps a code fence's meta string (everything after the
11
+ * language, e.g. ```json dory.json {4-6}). MDX drops it by default; this
12
+ * exposes it to the `code` component as the `metastring` prop.
13
+ */
14
+ export function rehypeCodeMeta() {
15
+ return (tree: Parameters<typeof visit>[0]) => {
16
+ visit(tree, 'element', (node: CodeElement) => {
17
+ const meta = node.data?.meta;
18
+ if (node.tagName === 'code' && meta) {
19
+ node.properties = { ...node.properties, metastring: meta };
20
+ }
21
+ });
22
+ };
23
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import path from 'path';
3
3
  import { generateFrontmatterJson, generateSearchContentJson } from './frontmatter-generator';
4
+ import { getBasePath, readDoryConfig } from './shared';
4
5
 
5
6
  /**
6
7
  * Dev server plugin that serves frontmatter.json and search-content.json
@@ -10,8 +11,11 @@ export function frontmatterDevServer(): Plugin {
10
11
  return {
11
12
  name: 'frontmatter-dev-server',
12
13
  configureServer(server) {
14
+ // Plugin middlewares run before Vite strips `base`, so mount under it.
15
+ const basePath = getBasePath(readDoryConfig());
16
+
13
17
  // Serve search content for client-side indexing
14
- server.middlewares.use('/search-content.json', (req, res, next) => {
18
+ server.middlewares.use(`${basePath}/search-content.json`, (req, res, next) => {
15
19
  if (req.method !== 'GET') return next();
16
20
  try {
17
21
  const docsDir = path.join(process.cwd(), 'docs');
@@ -27,7 +31,7 @@ export function frontmatterDevServer(): Plugin {
27
31
  }
28
32
  });
29
33
 
30
- server.middlewares.use('/frontmatter.json', (req, res, next) => {
34
+ server.middlewares.use(`${basePath}/frontmatter.json`, (req, res, next) => {
31
35
  if (req.method !== 'GET') {
32
36
  return next();
33
37
  }
@@ -1,6 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import { readFileSync, existsSync, statSync } from 'fs';
3
3
  import { resolve, extname, sep } from 'path';
4
+ import { getBasePath, readDoryConfig } from './shared';
4
5
 
5
6
  /**
6
7
  * Returns true if `target` is the `root` path itself or is contained within it.
@@ -50,14 +51,20 @@ export function htmlFilesMiddleware(): Plugin {
50
51
  return {
51
52
  name: 'html-files-middleware',
52
53
  configureServer(server) {
54
+ // Plugin middlewares run before Vite strips `base`, so strip it here.
55
+ const basePath = getBasePath(readDoryConfig());
56
+
53
57
  server.middlewares.use((req, res, next) => {
54
58
  if (!req.url) {
55
59
  next();
56
60
  return;
57
61
  }
58
62
 
59
- // Remove query params
60
- const url = req.url.split('?')[0];
63
+ // Remove query params and the base path prefix
64
+ let url = req.url.split('?')[0];
65
+ if (basePath && (url === basePath || url.startsWith(`${basePath}/`))) {
66
+ url = url.slice(basePath.length) || '/';
67
+ }
61
68
 
62
69
  // Check if the request is for a file in the dist directory
63
70
  if (url.startsWith('/dist/')) {
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readDoryConfig, escapeHtml, applyTitleTemplate } from './shared';
2
+ import { readDoryConfig, escapeHtml, applyTitleTemplate, getBasePath, getSiteUrl } from './shared';
3
3
 
4
4
  /**
5
5
  * Vite plugin that injects site metadata from dory.json into index.html.
@@ -20,7 +20,8 @@ export function htmlMetadataInjector(): Plugin {
20
20
  const siteName: string = config.name || 'Documentation';
21
21
  const defaultTitle = applyTitleTemplate(config.title || siteName, seo.titleTemplate, siteName);
22
22
  const defaultDescription: string = config.description || `${siteName} - Technical Documentation`;
23
- const siteUrl: string = config.url || '';
23
+ const siteUrl: string = getSiteUrl(config);
24
+ const basePath = getBasePath(config);
24
25
  const twitterHandle: string = config.twitter || '';
25
26
 
26
27
  // Resolve image to absolute URL
@@ -33,7 +34,7 @@ export function htmlMetadataInjector(): Plugin {
33
34
  // fall back to the logo so sites without a dedicated favicon still
34
35
  // get an icon instead of a broken link.
35
36
  const rawFavicon: string = config.favicon || config.logo?.light || config.logo?.dark || '';
36
- const faviconHref = rawFavicon ? `/${rawFavicon.replace(/^\.?\/*/, '')}` : '';
37
+ const faviconHref = rawFavicon ? `${basePath}/${rawFavicon.replace(/^\.?\/*/, '')}` : '';
37
38
 
38
39
  // Function replacements everywhere so `$&` etc. in config values
39
40
  // cannot corrupt the output.
@@ -1,6 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import path from 'path';
3
3
  import { generateLlmsIndex, generateLlmContent } from './llm-txt-generator';
4
+ import { getBasePath, readDoryConfig } from './shared';
4
5
 
5
6
  /**
6
7
  * Dev server plugin that serves llms.txt and llms-full.txt on demand,
@@ -30,8 +31,10 @@ export function llmTxtDevServer(): Plugin {
30
31
  return {
31
32
  name: 'llm-txt-dev-server',
32
33
  configureServer(server) {
33
- server.middlewares.use('/llms.txt', serveText(generateLlmsIndex));
34
- server.middlewares.use('/llms-full.txt', serveText(generateLlmContent));
34
+ // Plugin middlewares run before Vite strips `base`, so mount under it.
35
+ const basePath = getBasePath(readDoryConfig());
36
+ server.middlewares.use(`${basePath}/llms.txt`, serveText(generateLlmsIndex));
37
+ server.middlewares.use(`${basePath}/llms-full.txt`, serveText(generateLlmContent));
35
38
  }
36
39
  };
37
40
  }
@@ -7,6 +7,7 @@ import {
7
7
  parseFrontmatter,
8
8
  findMdxFilesMap,
9
9
  pathFromFilename,
10
+ getSiteUrl,
10
11
  } from './shared';
11
12
 
12
13
  interface LlmTxtGeneratorOptions {
@@ -41,7 +42,7 @@ function resolveFileKey(navKey: string, mdxFilesMap: Record<string, string>): st
41
42
  export function generateLlmsIndex(docsDir: string): string {
42
43
  const config = readDoryConfig(docsDir) || {};
43
44
  const siteName: string = config.name || 'Documentation';
44
- const baseUrl: string = config.url || '';
45
+ const baseUrl: string = getSiteUrl(config);
45
46
  const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
46
47
 
47
48
  let output = `# ${siteName}\n`;
@@ -9,6 +9,8 @@ import {
9
9
  stripMdxToText,
10
10
  escapeHtml,
11
11
  applyTitleTemplate,
12
+ getBasePath,
13
+ getSiteUrl,
12
14
  } from './shared';
13
15
 
14
16
  /**
@@ -41,7 +43,7 @@ export function prerender(): Plugin {
41
43
 
42
44
  const siteConfig = readDoryConfig() || {};
43
45
  const seo = siteConfig.seo || {};
44
- const baseUrl: string = siteConfig.url || '';
46
+ const baseUrl: string = getSiteUrl(siteConfig);
45
47
  const siteName: string = siteConfig.name || 'Documentation';
46
48
 
47
49
  if (!baseUrl) {
@@ -209,7 +211,7 @@ export function prerender(): Plugin {
209
211
  );
210
212
  }
211
213
 
212
- write404(outDir, baseHtml, siteName, frontmatter);
214
+ write404(outDir, baseHtml, siteName, frontmatter, getBasePath(siteConfig));
213
215
 
214
216
  console.log(`✅ Prerendered ${count} routes with page-specific meta tags`);
215
217
  } catch (error) {
@@ -241,10 +243,11 @@ function write404(
241
243
  outDir: string,
242
244
  baseHtml: string,
243
245
  siteName: string,
244
- frontmatter: Array<Record<string, any>>
246
+ frontmatter: Array<Record<string, any>>,
247
+ basePath: string
245
248
  ): void {
246
249
  const firstPage = frontmatter.find(page => page.path && page.path !== '/');
247
- const firstHref: string = firstPage?.path || '/';
250
+ const firstHref: string = `${basePath}${firstPage?.path || '/'}`;
248
251
  const firstTitle: string = firstPage?.title || siteName;
249
252
 
250
253
  let html = baseHtml;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveAssetUrl } from './relative-assets';
3
+
4
+ const docsDir = '/site/docs';
5
+
6
+ describe('resolveAssetUrl', () => {
7
+ it('resolves against the MDX file directory', () => {
8
+ expect(resolveAssetUrl('./assets/a.png', '/site/docs/features.mdx', docsDir)).toBe('/assets/a.png');
9
+ expect(resolveAssetUrl('../assets/a.png', '/site/docs/components/images.mdx', docsDir)).toBe('/assets/a.png');
10
+ expect(resolveAssetUrl('shots/b.png', '/site/docs/guides/intro.mdx', docsDir)).toBe('/guides/shots/b.png');
11
+ });
12
+
13
+ it('keeps query strings and fragments', () => {
14
+ expect(resolveAssetUrl('./a.svg?v=2#icon', '/site/docs/page.mdx', docsDir)).toBe('/a.svg?v=2#icon');
15
+ });
16
+
17
+ it('leaves absolute, protocol and anchor URLs alone', () => {
18
+ for (const url of ['/assets/a.png', 'https://x.dev/a.png', '//cdn.dev/a.png', 'data:image/png;base64,AA', '#top']) {
19
+ expect(resolveAssetUrl(url, '/site/docs/page.mdx', docsDir)).toBeNull();
20
+ }
21
+ });
22
+
23
+ it('leaves paths that escape the docs directory alone', () => {
24
+ expect(resolveAssetUrl('../../outside.png', '/site/docs/components/x.mdx', docsDir)).toBeNull();
25
+ });
26
+ });
@@ -0,0 +1,80 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, relative, resolve, sep } from 'node:path';
3
+ import { visit } from 'unist-util-visit';
4
+
5
+ interface ImageNode {
6
+ type: 'image';
7
+ url: string;
8
+ }
9
+
10
+ interface JsxElementNode {
11
+ type: 'mdxJsxFlowElement' | 'mdxJsxTextElement';
12
+ name: string | null;
13
+ attributes: Array<{ type: string; name?: string; value?: unknown }>;
14
+ }
15
+
16
+ // Absolute paths, protocol URLs (https:, data:, mailto:), protocol-relative URLs and anchors stay as written.
17
+ const notRelative = /^(?:[a-z][a-z\d+.-]*:|\/|#)/i;
18
+
19
+ /**
20
+ * Resolves a relative asset URL written in `mdxFile` to a root path on the
21
+ * built site (docs/ maps to /). Returns null for URLs that are not relative
22
+ * or that point outside the docs directory.
23
+ */
24
+ export function resolveAssetUrl(url: string, mdxFile: string, docsDir: string): string | null {
25
+ if (!url || notRelative.test(url)) return null;
26
+
27
+ const suffixIndex = url.search(/[?#]/);
28
+ const pathPart = suffixIndex === -1 ? url : url.slice(0, suffixIndex);
29
+ const suffix = suffixIndex === -1 ? '' : url.slice(suffixIndex);
30
+
31
+ const target = resolve(dirname(mdxFile), pathPart);
32
+ const fromDocs = relative(docsDir, target);
33
+ if (fromDocs.startsWith('..') || fromDocs === '') return null;
34
+
35
+ return `/${fromDocs.split(sep).join('/')}${suffix}`;
36
+ }
37
+
38
+ /**
39
+ * Remark plugin that rewrites relative image paths to root paths, resolved
40
+ * against the MDX file's own directory. Pages are served as directories
41
+ * (/features/ after a trailing-slash redirect), so a relative path left in
42
+ * the HTML would resolve against the page URL instead of the file.
43
+ * Covers Markdown images and <img src="..."> in MDX, and warns when the
44
+ * target file does not exist.
45
+ */
46
+ export function remarkRelativeAssets(options: { docsDir?: string } = {}) {
47
+ const docsDir = options.docsDir ?? resolve(process.cwd(), 'docs');
48
+
49
+ return (tree: Parameters<typeof visit>[0], file: { path?: string }) => {
50
+ const mdxFile = file.path;
51
+ if (!mdxFile) return;
52
+
53
+ const rewrite = (url: string): string | null => {
54
+ const resolved = resolveAssetUrl(url, mdxFile, docsDir);
55
+ if (resolved === null) return null;
56
+ const onDisk = resolve(docsDir, decodeURI(resolved.replace(/[?#].*$/, '')).slice(1));
57
+ if (!existsSync(onDisk)) {
58
+ console.warn(`⚠️ ${relative(docsDir, mdxFile)}: image "${url}" not found at ${relative(process.cwd(), onDisk)}`);
59
+ }
60
+ return resolved;
61
+ };
62
+
63
+ visit(tree, (node) => {
64
+ if (node.type === 'image') {
65
+ const image = node as unknown as ImageNode;
66
+ image.url = rewrite(image.url) ?? image.url;
67
+ return;
68
+ }
69
+
70
+ if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
71
+ const element = node as unknown as JsxElementNode;
72
+ if (element.name !== 'img') return;
73
+ const src = element.attributes.find((attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src');
74
+ if (src && typeof src.value === 'string') {
75
+ src.value = rewrite(src.value) ?? src.value;
76
+ }
77
+ }
78
+ });
79
+ };
80
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readDoryConfig } from './shared';
2
+ import { readDoryConfig, getBasePath, getSiteUrl } from './shared';
3
3
 
4
4
  /**
5
5
  * Generates robots.txt at build time. Emits `Disallow: /` when seo.noindex
@@ -13,15 +13,17 @@ export function robotsGenerator(): Plugin {
13
13
  const config = readDoryConfig() || {};
14
14
  const seo = config.seo || {};
15
15
 
16
+ const basePath = getBasePath(config);
16
17
  const lines = ['User-agent: *'];
17
- lines.push(seo.noindex ? 'Disallow: /' : 'Allow: /');
18
+ lines.push(seo.noindex ? `Disallow: ${basePath}/` : `Allow: ${basePath}/`);
18
19
 
19
20
  if (Array.isArray(seo.robotsTxt) && seo.robotsTxt.length > 0) {
20
21
  lines.push('', ...seo.robotsTxt);
21
22
  }
22
23
 
23
- if (config.url && !seo.noindex) {
24
- lines.push('', `Sitemap: ${config.url}/sitemap.xml`);
24
+ const siteUrl = getSiteUrl(config);
25
+ if (siteUrl && !seo.noindex) {
26
+ lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`);
25
27
  }
26
28
 
27
29
  this.emitFile({
@@ -60,6 +60,11 @@ describe('preprocessMdxTags', () => {
60
60
  expect(run('<Note>hello</Note>')).toBe('<Note>hello</Note>');
61
61
  });
62
62
 
63
+ it('preserves explicit list components', () => {
64
+ const input = '<UnorderedList>\n <ListItem>One</ListItem>\n</UnorderedList>\n<OrderedList><ListItem>Two</ListItem></OrderedList>';
65
+ expect(run(input)).toBe(input);
66
+ });
67
+
63
68
  it('escapes known components without a closing tag', () => {
64
69
  expect(run('<div>no closing tag')).toBe('`<div>`no closing tag');
65
70
  });
@@ -24,7 +24,9 @@ const KNOWN_COMPONENTS = [
24
24
  'Callout', 'Note', 'Warning', 'Info', 'Tip', 'Check',
25
25
  'Latex', 'API', 'Expandable', 'ResponseField', 'Properties', 'Property',
26
26
  'Steps', 'Step', 'table', 'th', 'td', 'ul', 'ol', 'li',
27
+ 'UnorderedList', 'OrderedList', 'ListItem',
27
28
  'APIPlayground', 'WebSocketPlayground', 'AsyncAPI', 'Source', 'Tag',
29
+ 'Specimen', 'SpecimenProps', 'Chip', 'ChipGroup', 'Tabs', 'Tab', 'IconGrid', 'TypeScale',
28
30
  // Common HTML elements that should be preserved
29
31
  'div', 'span', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's',
30
32
  'blockquote', 'nav', 'section', 'article', 'header', 'footer', 'main',
@@ -1,6 +1,7 @@
1
1
  import matter from 'gray-matter';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { normalizeBasePath } from '../utils/base-path';
4
5
 
5
6
  /**
6
7
  * Shared helpers for build plugins that read docs/*.mdx and docs/dory.json.
@@ -33,6 +34,22 @@ export function readDoryConfig(docsDir?: string): Record<string, any> | null {
33
34
  }
34
35
  }
35
36
 
37
+ /**
38
+ * The base path the site is served under ("" or "/docs"), from `basePath`.
39
+ */
40
+ export function getBasePath(config: Record<string, any> | null): string {
41
+ return normalizeBasePath(config?.basePath);
42
+ }
43
+
44
+ /**
45
+ * Absolute site URL including the base path, e.g. "https://x.dev/docs", for
46
+ * canonical, sitemap and llms.txt links. "" when `url` is not configured.
47
+ */
48
+ export function getSiteUrl(config: Record<string, any> | null): string {
49
+ const url: string = typeof config?.url === 'string' ? config.url.trim().replace(/\/+$/, '') : '';
50
+ return url ? `${url}${getBasePath(config)}` : '';
51
+ }
52
+
36
53
  /**
37
54
  * Reads dory.json navigation and returns the flat, ordered list of page keys.
38
55
  */
@@ -7,6 +7,7 @@ import {
7
7
  pathFromFilename,
8
8
  parseFrontmatter,
9
9
  escapeHtml,
10
+ getSiteUrl,
10
11
  } from './shared';
11
12
 
12
13
  /**
@@ -26,7 +27,7 @@ export function sitemapGenerator(): Plugin {
26
27
  return;
27
28
  }
28
29
 
29
- const baseUrl: string = config.url || '';
30
+ const baseUrl: string = getSiteUrl(config);
30
31
  if (!baseUrl) {
31
32
  console.warn(
32
33
  '⚠️ sitemap-generator: `url` is not set in dory.json — sitemap.xml will NOT be generated. Set `url` to enable it.'