@clidey/dory 0.47.0 → 0.49.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 (46) hide show
  1. package/bin/dist/dory.js +103 -45
  2. package/bin/dist/dory.js.map +1 -1
  3. package/index.html +15 -19
  4. package/package.json +35 -29
  5. package/scripts/render-routes.mjs +45 -14
  6. package/src/components/routes.tsx +19 -22
  7. package/src/components/store.ts +5 -5
  8. package/src/config/mdx.js +7 -3
  9. package/src/config/mdx.ts +6 -2
  10. package/src/entry-server.tsx +53 -20
  11. package/src/main.tsx +9 -1
  12. package/src/mdx/code.tsx +8 -5
  13. package/src/mdx/fence.tsx +10 -2
  14. package/src/mdx/icon.tsx +21 -14
  15. package/src/mdx/mermaid.tsx +10 -1
  16. package/src/mdx/processor.test.ts +47 -0
  17. package/src/mdx/step.tsx +3 -3
  18. package/src/plugins/analytics-injector.ts +7 -5
  19. package/src/plugins/docs-assets.ts +5 -4
  20. package/src/plugins/frontmatter-dev-server.ts +8 -159
  21. package/src/plugins/frontmatter-generator.ts +37 -129
  22. package/src/plugins/html-files-middleware.test.ts +21 -0
  23. package/src/plugins/html-files-middleware.ts +27 -5
  24. package/src/plugins/html-metadata-injector.ts +113 -53
  25. package/src/plugins/llm-txt-dev-server.ts +25 -178
  26. package/src/plugins/llm-txt-generator.ts +94 -133
  27. package/src/plugins/prerender.ts +156 -50
  28. package/src/plugins/robots-generator.ts +15 -16
  29. package/src/plugins/sanitize.test.ts +104 -0
  30. package/src/plugins/sanitize.ts +90 -59
  31. package/src/plugins/shared.ts +139 -0
  32. package/src/plugins/sitemap-generator.ts +58 -44
  33. package/src/theme.scss +138 -15
  34. package/src/types/config.ts +42 -2
  35. package/src/types/globals.d.ts +7 -0
  36. package/src/ui/header.tsx +12 -4
  37. package/src/ui/layout.tsx +66 -47
  38. package/src/ui/table-of-content.tsx +19 -2
  39. package/src/utils/colors.ts +38 -18
  40. package/src/utils/fonts.ts +55 -12
  41. package/src/utils/theme.ts +29 -0
  42. package/vite.config.embed-loader.ts +3 -11
  43. package/vite.config.embed-widget.ts +3 -10
  44. package/vite.config.ts +11 -7
  45. package/scripts/generate-frontmatter-json.js +0 -154
  46. package/scripts/generate-llm-txt.js +0 -115
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { preprocessMdxTags, isInsideCodeBlock, isInsideCodeSpan } from './sanitize';
3
+
4
+ /** Run the preprocessor over MDX input and return the transformed code. */
5
+ function run(input: string): string {
6
+ const result = preprocessMdxTags().transform(input, 'test.mdx');
7
+ return (result as { code: string }).code;
8
+ }
9
+
10
+ describe('isInsideCodeBlock', () => {
11
+ it('detects content between line-start fences', () => {
12
+ const code = '```\nconst x = 1;\n```\nafter';
13
+ expect(isInsideCodeBlock(code, code.indexOf('const'))).toBe(true);
14
+ expect(isInsideCodeBlock(code, code.indexOf('after'))).toBe(false);
15
+ });
16
+
17
+ it('ignores inline triple backticks (not at line start)', () => {
18
+ const code = 'Use ``` to fence code blocks.\nplain text here';
19
+ expect(isInsideCodeBlock(code, code.indexOf('plain'))).toBe(false);
20
+ });
21
+
22
+ it('handles indented fences and CRLF line endings', () => {
23
+ const code = 'a\r\n ```js\r\ninside\r\n ```\r\nb';
24
+ expect(isInsideCodeBlock(code, code.indexOf('inside'))).toBe(true);
25
+ expect(isInsideCodeBlock(code, code.indexOf('b'))).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('isInsideCodeSpan', () => {
30
+ it('detects content inside a single-backtick span', () => {
31
+ const code = 'a `<div>` b';
32
+ expect(isInsideCodeSpan(code, code.indexOf('<div>'))).toBe(true);
33
+ expect(isInsideCodeSpan(code, code.indexOf('b'))).toBe(false);
34
+ });
35
+
36
+ it('pairs equal-length backtick runs (double-backtick span with inner backtick)', () => {
37
+ const code = 'A ``b ` c`` and <Unknown> tag';
38
+ expect(isInsideCodeSpan(code, code.indexOf('b `'))).toBe(true);
39
+ // The single backtick inside the double-backtick span must not
40
+ // open a new span that swallows the rest of the line.
41
+ expect(isInsideCodeSpan(code, code.indexOf('<Unknown>'))).toBe(false);
42
+ });
43
+
44
+ it('treats an unmatched backtick run as literal text', () => {
45
+ const code = 'odd ` backtick <Foo> here';
46
+ expect(isInsideCodeSpan(code, code.indexOf('<Foo>'))).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('preprocessMdxTags', () => {
51
+ it('returns undefined for non-mdx files', () => {
52
+ expect(preprocessMdxTags().transform('<Foo>', 'file.ts')).toBeUndefined();
53
+ });
54
+
55
+ it('escapes bare unknown pseudo-tags in backticks', () => {
56
+ expect(run('This <UnknownTag> is not JSX')).toBe('This `<UnknownTag>` is not JSX');
57
+ });
58
+
59
+ it('preserves known components with matched closing tags', () => {
60
+ expect(run('<Note>hello</Note>')).toBe('<Note>hello</Note>');
61
+ });
62
+
63
+ it('escapes known components without a closing tag', () => {
64
+ expect(run('<div>no closing tag')).toBe('`<div>`no closing tag');
65
+ });
66
+
67
+ it('preserves nested same-name tags without leaking an orphan closer', () => {
68
+ const input = '<div><div>inner</div></div>';
69
+ expect(run(input)).toBe(input);
70
+ });
71
+
72
+ it('leaves tags inside fenced code blocks untouched', () => {
73
+ const input = '```\n<UnknownTag>\n<div>\n```\n';
74
+ expect(run(input)).toBe(input);
75
+ });
76
+
77
+ it('handles CRLF input with fenced code blocks', () => {
78
+ const input = 'text\r\n```\r\n<UnknownTag>\r\n```\r\n';
79
+ expect(run(input)).toBe(input);
80
+ });
81
+
82
+ it('does not corrupt double-backtick code spans', () => {
83
+ const input = 'A ``b `<div>` c`` d';
84
+ expect(run(input)).toBe(input);
85
+ });
86
+
87
+ it('still escapes tags after a double-backtick span (no parity confusion)', () => {
88
+ expect(run('A ``b ` c`` and <Unknown> tag')).toBe('A ``b ` c`` and `<Unknown>` tag');
89
+ });
90
+
91
+ it('does not treat inline triple backticks as a code fence', () => {
92
+ expect(run('Use ``` to fence.\n\n<Unknown>')).toBe('Use ``` to fence.\n\n`<Unknown>`');
93
+ });
94
+
95
+ it('escapes unknown self-closing tags and preserves known ones', () => {
96
+ expect(run('<VideoPlayer src="x" />')).toBe('`<VideoPlayer src="x" />`');
97
+ expect(run('line<br/>break')).toBe('line<br/>break');
98
+ });
99
+
100
+ it('leaves tags inside single-backtick inline code untouched', () => {
101
+ const input = 'use `<UnknownTag>` in your doc';
102
+ expect(run(input)).toBe(input);
103
+ });
104
+ });
@@ -17,7 +17,7 @@ export const remarkSafeVars = () => {
17
17
  const KNOWN_COMPONENTS = [
18
18
  // Text components
19
19
  'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'Row', 'Col', 'a', 'img',
20
- // Code components
20
+ // Code components
21
21
  'code', 'CodeGroup', 'pre',
22
22
  // UI components
23
23
  'Accordion', 'AccordionGroup', 'Icon', 'Card', 'CardGroup',
@@ -33,43 +33,85 @@ const KNOWN_COMPONENTS = [
33
33
  'address', 'cite', 'q', 'dfn'
34
34
  ];
35
35
 
36
- // Helper: returns true if the given code string is inside a code block (```)
37
- function isInsideCodeBlock(code: string, idx: number): boolean {
38
- // Count the number of ``` before idx
39
- const before = code.slice(0, idx);
40
- const codeBlockMatches = before.match(/```/g);
41
- // Odd number of ``` means inside a code block
42
- return codeBlockMatches ? codeBlockMatches.length % 2 === 1 : false;
36
+ /**
37
+ * Returns true if the character at `idx` is inside a fenced code block.
38
+ * Fences only count when they start a line (optionally indented) — inline
39
+ * triple backticks in prose do not open or close a block. Offsets on a
40
+ * fence line itself are treated as inside, so fence lines are never modified.
41
+ */
42
+ export function isInsideCodeBlock(code: string, idx: number): boolean {
43
+ let inFence = false;
44
+ let pos = 0;
45
+ for (const line of code.split('\n')) {
46
+ const lineEnd = pos + line.length;
47
+ const isFenceLine = /^\s*```/.test(line);
48
+ if (idx >= pos && idx <= lineEnd) {
49
+ return inFence || isFenceLine;
50
+ }
51
+ if (isFenceLine) {
52
+ inFence = !inFence;
53
+ }
54
+ pos = lineEnd + 1;
55
+ }
56
+ return inFence;
43
57
  }
44
58
 
45
- // Helper: checks if a known component tag has a matching closing tag
46
- function hasMatchingClosingTag(code: string, tagName: string, openingTagOffset: number): boolean {
47
- // Simple approach: look for the next occurrence of either an opening or closing tag of the same type
48
- const afterOpening = code.slice(openingTagOffset + `<${tagName}>`.length);
49
-
50
- // Find the first occurrence of either opening or closing tag
51
- const nextOpeningMatch = afterOpening.match(new RegExp(`<${tagName}>`, 'i'));
52
- const nextClosingMatch = afterOpening.match(new RegExp(`</${tagName}>`, 'i'));
53
-
54
- // If no closing tag found at all
55
- if (!nextClosingMatch) {
56
- return false;
59
+ /**
60
+ * Returns true if the character at `idx` is inside an inline code span.
61
+ * Follows CommonMark pairing: a run of N backticks opens a span closed by
62
+ * the next run of exactly N backticks; unmatched runs are literal text.
63
+ * Backtick runs inside fenced code blocks are ignored.
64
+ */
65
+ export function isInsideCodeSpan(code: string, idx: number): boolean {
66
+ const runs: { index: number; length: number }[] = [];
67
+ const re = /`+/g;
68
+ let m: RegExpExecArray | null;
69
+ while ((m = re.exec(code)) !== null) {
70
+ if (!isInsideCodeBlock(code, m.index)) {
71
+ runs.push({ index: m.index, length: m[0].length });
72
+ }
57
73
  }
58
-
59
- const nextOpeningIndex = nextOpeningMatch ? nextOpeningMatch.index! : Infinity;
60
- const nextClosingIndex = nextClosingMatch ? nextClosingMatch.index! : Infinity;
61
-
62
- // If the closing tag comes before any other opening tag, this tag is properly closed
63
- if (nextClosingIndex < nextOpeningIndex) {
64
- // Check if the closing tag is inside a code block
65
- const closingTagOffset = openingTagOffset + `<${tagName}>`.length + nextClosingIndex;
66
- if (isInsideCodeBlock(code, closingTagOffset)) {
67
- return false;
74
+
75
+ let i = 0;
76
+ while (i < runs.length) {
77
+ const open = runs[i];
78
+ if (open.index >= idx) return false;
79
+
80
+ // Find the matching closer: the next run of the same length
81
+ let j = i + 1;
82
+ while (j < runs.length && runs[j].length !== open.length) j++;
83
+
84
+ if (j < runs.length) {
85
+ if (idx > open.index && idx < runs[j].index) return true;
86
+ i = j + 1;
87
+ } else {
88
+ // Unmatched run is literal text, not a span opener
89
+ i++;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ // Helper: checks if a known component tag has a matching closing tag,
96
+ // tracking nesting depth so nested same-name tags pair up correctly.
97
+ function hasMatchingClosingTag(code: string, tagName: string, openingTagOffset: number): boolean {
98
+ const re = new RegExp(`</?${tagName}>`, 'gi');
99
+ re.lastIndex = openingTagOffset + `<${tagName}>`.length;
100
+ let depth = 1;
101
+ let m: RegExpExecArray | null;
102
+ while ((m = re.exec(code)) !== null) {
103
+ // Tags inside code blocks or inline code spans are literal text
104
+ if (isInsideCodeBlock(code, m.index) || isInsideCodeSpan(code, m.index)) {
105
+ continue;
106
+ }
107
+ if (m[0].startsWith('</')) {
108
+ depth--;
109
+ if (depth === 0) return true;
110
+ } else {
111
+ depth++;
68
112
  }
69
- return true;
70
113
  }
71
-
72
- return false; // Another opening tag comes before the closing tag, so this one is not properly closed
114
+ return false;
73
115
  }
74
116
 
75
117
  // Allow any <[a-z][a-z0-9]*> tag inside code blocks to be left as-is (not parsed as JSX).
@@ -80,16 +122,18 @@ export function preprocessMdxTags() {
80
122
  transform(code: string, id: string) {
81
123
  if (!id.endsWith('.mdx')) return;
82
124
 
83
- // Remove leading whitespace from code fence blocks
84
- let processed = code.replace(/^(\s+)(```\w*)/gm, '$2');
125
+ // Remove leading whitespace from code fence blocks.
126
+ // Only horizontal whitespace: \s would also swallow the \n of CRLF
127
+ // line endings (multiline ^ matches after \r in JS).
128
+ let processed = code.replace(/^([ \t]+)(```\w*)/gm, '$2');
85
129
 
86
130
  // Handle self-closing tags first (like <VideoPlayer />)
87
131
  processed = processed.replace(/<([A-Za-z][A-Za-z0-9]*)[^>]*\/>/g, (match, tag, offset) => {
88
- // If inside a code block, do not touch
89
- if (isInsideCodeBlock(processed, offset)) {
132
+ // If inside a code block or inline code span, do not touch
133
+ if (isInsideCodeBlock(processed, offset) || isInsideCodeSpan(processed, offset)) {
90
134
  return match;
91
135
  }
92
-
136
+
93
137
  // If unknown component, treat as text
94
138
  if (!KNOWN_COMPONENTS.includes(tag)) {
95
139
  return `\`${match}\``;
@@ -100,11 +144,11 @@ export function preprocessMdxTags() {
100
144
  // Only replace unrecognized <someTag> with `"<someTag>"` if NOT inside a code block
101
145
  // This regex finds all <tag> occurrences (fixed to handle uppercase)
102
146
  processed = processed.replace(/<([A-Za-z][A-Za-z0-9]*)>/g, (match, tag, offset) => {
103
- // If inside a code block, do not touch
104
- if (isInsideCodeBlock(processed, offset)) {
147
+ // If inside a code block or inline code span, do not touch
148
+ if (isInsideCodeBlock(processed, offset) || isInsideCodeSpan(processed, offset)) {
105
149
  return match;
106
150
  }
107
-
151
+
108
152
  // If known component, check if it has a closing tag
109
153
  if (KNOWN_COMPONENTS.includes(tag)) {
110
154
  // If it doesn't have a matching closing tag, treat it as text
@@ -115,23 +159,17 @@ export function preprocessMdxTags() {
115
159
  return match;
116
160
  }
117
161
  // For unknown components, always treat as text (wrap in backticks)
118
- // If already inside backticks, do not wrap again
119
- const before = processed.slice(0, offset);
120
- const backtickMatches = before.match(/`+/g);
121
- const insideBackticks = backtickMatches ? backtickMatches.reduce((acc, s) => acc + s.length, 0) % 2 === 1 : false;
122
- if (insideBackticks) {
123
- return match;
124
- }
125
162
  return `\`<${tag}>\``;
126
163
  });
127
164
 
128
165
  // This regex finds all <tag> and </tag> occurrences (fixed to handle uppercase)
129
166
  processed = processed.replace(/<\/?([A-Za-z][A-Za-z0-9]*)>/g, (match, tag, offset) => {
130
- // If inside a code block, do not touch
131
- if (isInsideCodeBlock(processed, offset)) {
167
+ // If inside a code block or inline code span (including spans added
168
+ // by the previous pass), do not touch
169
+ if (isInsideCodeBlock(processed, offset) || isInsideCodeSpan(processed, offset)) {
132
170
  return match;
133
171
  }
134
-
172
+
135
173
  // If known component, check if it has a closing tag (only for opening tags)
136
174
  if (KNOWN_COMPONENTS.includes(tag)) {
137
175
  // Only check for matching closing tag for opening tags
@@ -144,13 +182,6 @@ export function preprocessMdxTags() {
144
182
  return match;
145
183
  }
146
184
  // For unknown components, always treat as text (wrap in backticks)
147
- // If already inside backticks, do not wrap again
148
- const before = processed.slice(0, offset);
149
- const backtickMatches = before.match(/`+/g);
150
- const insideBackticks = backtickMatches ? backtickMatches.reduce((acc, s) => acc + s.length, 0) % 2 === 1 : false;
151
- if (insideBackticks) {
152
- return match;
153
- }
154
185
  return `\`${match}\``;
155
186
  });
156
187
 
@@ -160,4 +191,4 @@ export function preprocessMdxTags() {
160
191
  };
161
192
  },
162
193
  };
163
- }
194
+ }
@@ -0,0 +1,139 @@
1
+ import matter from 'gray-matter';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ /**
6
+ * Shared helpers for build plugins that read docs/*.mdx and docs/dory.json.
7
+ * Consolidated from near-identical copies in frontmatter-generator,
8
+ * frontmatter-dev-server, llm-txt-generator, and llm-txt-dev-server.
9
+ */
10
+
11
+ /**
12
+ * Parses YAML frontmatter from MDX content using gray-matter.
13
+ * Handles CRLF line endings and real YAML (nested values, dates, quotes).
14
+ */
15
+ export function parseFrontmatter(content: string): { data: Record<string, any>; content: string } {
16
+ const parsed = matter(content);
17
+ return { data: parsed.data, content: parsed.content };
18
+ }
19
+
20
+ /**
21
+ * Reads and parses docs/dory.json. Returns null when the file is missing
22
+ * or unparseable (a parse error is logged).
23
+ */
24
+ export function readDoryConfig(docsDir?: string): Record<string, any> | null {
25
+ const doryJsonPath = path.join(docsDir || path.join(process.cwd(), 'docs'), 'dory.json');
26
+ if (!fs.existsSync(doryJsonPath)) return null;
27
+ try {
28
+ return JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
29
+ } catch (error) {
30
+ const detail = error instanceof SyntaxError ? error.message : String(error);
31
+ console.error(`Failed to parse ${doryJsonPath}: ${detail}`);
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Reads dory.json navigation and returns the flat, ordered list of page keys.
38
+ */
39
+ export function getNavigationOrder(docsDir: string): string[] {
40
+ const doryJson = readDoryConfig(docsDir);
41
+ if (!doryJson) return [];
42
+ const order: string[] = [];
43
+
44
+ function walkPages(pages: any[]) {
45
+ for (const page of pages) {
46
+ if (typeof page === 'string') {
47
+ order.push(page);
48
+ } else if (typeof page === 'object' && page.pages) {
49
+ walkPages(page.pages);
50
+ }
51
+ }
52
+ }
53
+
54
+ if (doryJson.navigation && Array.isArray(doryJson.navigation.tabs)) {
55
+ for (const tab of doryJson.navigation.tabs) {
56
+ if (tab.groups && Array.isArray(tab.groups)) {
57
+ for (const group of tab.groups) {
58
+ if (group.pages && Array.isArray(group.pages)) {
59
+ walkPages(group.pages);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return order;
66
+ }
67
+
68
+ /**
69
+ * Converts an MDX filename to the route path used by the store,
70
+ * e.g. docs/getting-started/installation.mdx -> /getting-started/installation.
71
+ */
72
+ export function pathFromFilename(filename: string, docsDir: string): string {
73
+ return filename
74
+ .replace(docsDir, '')
75
+ .replace(/\/?index\.mdx$/, '/')
76
+ .replace(/\.mdx$/, '')
77
+ .replace(/\\/g, '/')
78
+ .toLowerCase();
79
+ }
80
+
81
+ /**
82
+ * Recursively finds all MDX files under a directory, keyed by relative
83
+ * path without the .mdx extension (forward slashes).
84
+ */
85
+ export function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
86
+ const files: Record<string, string> = {};
87
+ const items = fs.readdirSync(dir, { withFileTypes: true });
88
+
89
+ for (const item of items) {
90
+ const fullPath = path.join(dir, item.name);
91
+ if (item.isDirectory()) {
92
+ Object.assign(files, findMdxFilesMap(fullPath, baseDir));
93
+ } else if (item.isFile() && item.name.endsWith('.mdx')) {
94
+ let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
95
+ if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
96
+ files[rel] = fullPath;
97
+ }
98
+ }
99
+
100
+ return files;
101
+ }
102
+
103
+ /**
104
+ * Strips MDX/JSX syntax to extract plain text (for search indexing and
105
+ * meta description fallbacks).
106
+ */
107
+ export function stripMdxToText(content: string): string {
108
+ return content
109
+ .replace(/^import\s+.*$/gm, '') // import statements
110
+ .replace(/^export\s+.*$/gm, '') // export statements
111
+ .replace(/```[\s\S]*?```/g, '') // fenced code blocks
112
+ .replace(/<[^>]+>/g, '') // JSX/HTML tags
113
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // markdown links -> text
114
+ .replace(/[#*_~`>|]/g, '') // markdown syntax chars
115
+ .replace(/\n{3,}/g, '\n\n') // collapse blank lines
116
+ .trim();
117
+ }
118
+
119
+ /**
120
+ * Escapes a string for safe use in HTML/XML text and attribute values.
121
+ */
122
+ export function escapeHtml(str: string): string {
123
+ return str
124
+ .replace(/&/g, '&amp;')
125
+ .replace(/</g, '&lt;')
126
+ .replace(/>/g, '&gt;')
127
+ .replace(/"/g, '&quot;')
128
+ .replace(/'/g, '&#39;');
129
+ }
130
+
131
+ /**
132
+ * Applies an seo.titleTemplate (e.g. "%s — Site") to a page title.
133
+ * Returns the title unchanged when no template is set or the title is
134
+ * already the bare site title.
135
+ */
136
+ export function applyTitleTemplate(title: string, template: string | undefined, siteName: string): string {
137
+ if (!template || title === siteName) return title;
138
+ return template.replace('%s', title);
139
+ }
@@ -1,72 +1,78 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readFileSync, existsSync, statSync } from 'fs';
2
+ import { readFileSync, existsSync } from 'fs';
3
3
  import { resolve } from 'path';
4
+ import {
5
+ readDoryConfig,
6
+ findMdxFilesMap,
7
+ pathFromFilename,
8
+ parseFrontmatter,
9
+ escapeHtml,
10
+ } from './shared';
4
11
 
5
12
  /**
6
- * Generates sitemap.xml from dory.json navigation structure at build time.
7
- * Uses actual file modification times for lastmod and calculates priority
8
- * based on route depth.
13
+ * Generates sitemap.xml at build time from all MDX files (not just those in
14
+ * navigation). Uses frontmatter `lastUpdated` for lastmod when present and
15
+ * calculates priority based on route depth. Skipped entirely when
16
+ * `url` is missing or `seo.noindex` is set.
9
17
  */
10
18
  export function sitemapGenerator(): Plugin {
11
19
  return {
12
20
  name: 'sitemap-generator',
13
21
  generateBundle() {
14
22
  const docsDir = resolve(process.cwd(), 'docs');
15
- const doryJsonPath = resolve(docsDir, 'dory.json');
16
- if (!existsSync(doryJsonPath)) {
23
+ const config = readDoryConfig(docsDir);
24
+ if (!config) {
17
25
  console.warn('sitemap-generator: dory.json not found, skipping');
18
26
  return;
19
27
  }
20
28
 
21
- try {
22
- const config = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
23
- const baseUrl = config.url || '';
24
-
25
- // Extract all routes from navigation
26
- const routes: string[] = [];
27
- function walkPages(pages: any[]) {
28
- for (const page of pages) {
29
- if (typeof page === 'string') {
30
- routes.push(page);
31
- } else if (typeof page === 'object' && page.pages) {
32
- walkPages(page.pages);
33
- }
34
- }
35
- }
29
+ const baseUrl: string = config.url || '';
30
+ if (!baseUrl) {
31
+ console.warn(
32
+ '⚠️ sitemap-generator: `url` is not set in dory.json — sitemap.xml will NOT be generated. Set `url` to enable it.'
33
+ );
34
+ return;
35
+ }
36
+ if (config.seo?.noindex) {
37
+ console.warn('⚠️ sitemap-generator: seo.noindex is set — sitemap.xml will NOT be generated.');
38
+ return;
39
+ }
36
40
 
37
- if (config.navigation?.tabs) {
38
- for (const tab of config.navigation.tabs) {
39
- if (tab.groups) {
40
- for (const group of tab.groups) {
41
- if (group.pages) {
42
- walkPages(group.pages);
43
- }
44
- }
45
- }
46
- }
47
- }
41
+ try {
42
+ const mdxFilesMap = existsSync(docsDir) ? findMdxFilesMap(docsDir, docsDir) : {};
48
43
 
49
- const fallbackDate = new Date().toISOString().split('T')[0];
50
44
  let sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n';
51
45
  sitemap += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
52
46
 
53
- for (const route of routes) {
54
- // Get actual file modification time
55
- const mdxPath = resolve(docsDir, `${route}.mdx`);
56
- let lastmod = fallbackDate;
57
- if (existsSync(mdxPath)) {
58
- lastmod = statSync(mdxPath).mtime.toISOString().split('T')[0];
59
- }
47
+ // Root URL first
48
+ sitemap += ' <url>\n';
49
+ sitemap += ` <loc>${escapeHtml(`${baseUrl}/`)}</loc>\n`;
50
+ sitemap += ' <priority>1.0</priority>\n';
51
+ sitemap += ' </url>\n';
52
+
53
+ let count = 1;
54
+ for (const filePath of Object.values(mdxFilesMap).sort()) {
55
+ const routePath = pathFromFilename(filePath, docsDir);
56
+ if (routePath === '/') continue;
57
+
58
+ const { data } = parseFrontmatter(readFileSync(filePath, 'utf-8'));
59
+ // Exclude pages marked noindex in frontmatter
60
+ if (typeof data.robots === 'string' && data.robots.includes('noindex')) continue;
61
+
62
+ const lastmod = toIsoDate(data.lastUpdated);
60
63
 
61
64
  // Priority based on route depth: fewer segments = higher priority
62
- const depth = route.split('/').length;
65
+ const depth = routePath.replace(/^\//, '').split('/').length;
63
66
  const priority = Math.max(0.3, 1.0 - (depth - 1) * 0.2).toFixed(1);
64
67
 
65
68
  sitemap += ' <url>\n';
66
- sitemap += ` <loc>${baseUrl}/${route}</loc>\n`;
67
- sitemap += ` <lastmod>${lastmod}</lastmod>\n`;
69
+ sitemap += ` <loc>${escapeHtml(`${baseUrl}${routePath}`)}</loc>\n`;
70
+ if (lastmod) {
71
+ sitemap += ` <lastmod>${lastmod}</lastmod>\n`;
72
+ }
68
73
  sitemap += ` <priority>${priority}</priority>\n`;
69
74
  sitemap += ' </url>\n';
75
+ count++;
70
76
  }
71
77
 
72
78
  sitemap += '</urlset>';
@@ -77,7 +83,7 @@ export function sitemapGenerator(): Plugin {
77
83
  source: sitemap
78
84
  });
79
85
 
80
- console.log(`✅ Generated sitemap.xml with ${routes.length} URLs`);
86
+ console.log(`✅ Generated sitemap.xml with ${count} URLs`);
81
87
  } catch (error) {
82
88
  const detail = error instanceof SyntaxError ? error.message : String(error);
83
89
  console.error(`Failed to generate sitemap: ${detail}`);
@@ -85,3 +91,11 @@ export function sitemapGenerator(): Plugin {
85
91
  }
86
92
  };
87
93
  }
94
+
95
+ /** Normalizes a frontmatter lastUpdated value (string or Date) to an ISO date. */
96
+ function toIsoDate(value: unknown): string {
97
+ if (!value) return '';
98
+ const date = new Date(value as string | number | Date);
99
+ if (isNaN(date.getTime())) return '';
100
+ return date.toISOString().split('T')[0];
101
+ }