@dominikcz/greg 0.9.27

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 (183) hide show
  1. package/README.md +397 -0
  2. package/bin/greg.js +241 -0
  3. package/bin/init.js +351 -0
  4. package/bin/templates/docs/getting-started.md +47 -0
  5. package/bin/templates/docs/index.md +11 -0
  6. package/bin/templates/greg.config.js +39 -0
  7. package/bin/templates/greg.config.ts +38 -0
  8. package/bin/templates/index.html +16 -0
  9. package/bin/templates/src/App.svelte +5 -0
  10. package/bin/templates/src/app.css +20 -0
  11. package/bin/templates/src/main.js +9 -0
  12. package/bin/templates/svelte.config.js +1 -0
  13. package/bin/templates/tsconfig.json +21 -0
  14. package/bin/templates/vite.config.js +23 -0
  15. package/docs/__partials/markdown/examples/basic.md +4 -0
  16. package/docs/__partials/markdown/examples/diff.md +10 -0
  17. package/docs/__partials/markdown/examples/focus.md +5 -0
  18. package/docs/__partials/markdown/examples/language-title.md +3 -0
  19. package/docs/__partials/markdown/examples/line-highlighting.md +5 -0
  20. package/docs/__partials/markdown/examples/line-numbers.md +5 -0
  21. package/docs/__partials/note.md +4 -0
  22. package/docs/guide/__shared-warning.md +4 -0
  23. package/docs/guide/asset-handling.md +88 -0
  24. package/docs/guide/deploying.md +162 -0
  25. package/docs/guide/getting-started.md +334 -0
  26. package/docs/guide/index.md +23 -0
  27. package/docs/guide/localization.md +290 -0
  28. package/docs/guide/markdown/code.md +95 -0
  29. package/docs/guide/markdown/components-and-mermaid.md +43 -0
  30. package/docs/guide/markdown/containers.md +110 -0
  31. package/docs/guide/markdown/header-anchors.md +34 -0
  32. package/docs/guide/markdown/includes.md +84 -0
  33. package/docs/guide/markdown/index.md +20 -0
  34. package/docs/guide/markdown/inline-attributes.md +21 -0
  35. package/docs/guide/markdown/links-and-toc.md +64 -0
  36. package/docs/guide/markdown/math.md +54 -0
  37. package/docs/guide/markdown/syntax-highlighting.md +75 -0
  38. package/docs/guide/routing.md +150 -0
  39. package/docs/guide/using-svelte.md +88 -0
  40. package/docs/guide/versioning.md +281 -0
  41. package/docs/incompatibilities.md +48 -0
  42. package/docs/index.md +43 -0
  43. package/docs/reference/badge.md +100 -0
  44. package/docs/reference/carbon-ads.md +46 -0
  45. package/docs/reference/code-group.md +126 -0
  46. package/docs/reference/home-page.md +232 -0
  47. package/docs/reference/index.md +18 -0
  48. package/docs/reference/markdowndocs.md +275 -0
  49. package/docs/reference/outline.md +79 -0
  50. package/docs/reference/search.md +263 -0
  51. package/docs/reference/steps.md +200 -0
  52. package/docs/reference/team-page.md +189 -0
  53. package/docs/reference/theme.md +150 -0
  54. package/fakeDocsGenerator/generate_docs.js +310 -0
  55. package/package.json +92 -0
  56. package/scripts/build-versions.js +609 -0
  57. package/scripts/generate-static.js +79 -0
  58. package/scripts/render-markdown.js +420 -0
  59. package/src/lib/MarkdownDocs/AiChat.svelte +936 -0
  60. package/src/lib/MarkdownDocs/BackToTop.svelte +68 -0
  61. package/src/lib/MarkdownDocs/Breadcrumb.svelte +68 -0
  62. package/src/lib/MarkdownDocs/DocsNavigation.svelte +149 -0
  63. package/src/lib/MarkdownDocs/DocsSiteHeader.svelte +758 -0
  64. package/src/lib/MarkdownDocs/DocsVersionSwitcher.svelte +103 -0
  65. package/src/lib/MarkdownDocs/MarkdownDocs.svelte +2115 -0
  66. package/src/lib/MarkdownDocs/MarkdownRenderer.svelte +487 -0
  67. package/src/lib/MarkdownDocs/Outline.svelte +238 -0
  68. package/src/lib/MarkdownDocs/PrevNext.svelte +115 -0
  69. package/src/lib/MarkdownDocs/SearchModal.svelte +1241 -0
  70. package/src/lib/MarkdownDocs/TreeView.svelte +32 -0
  71. package/src/lib/MarkdownDocs/TreeViewItem.svelte +219 -0
  72. package/src/lib/MarkdownDocs/VersionOutdatedNotice.svelte +72 -0
  73. package/src/lib/MarkdownDocs/__tests__/codeDirectives.test.js +54 -0
  74. package/src/lib/MarkdownDocs/__tests__/common.test.js +41 -0
  75. package/src/lib/MarkdownDocs/__tests__/docsExamplesLint.test.js +77 -0
  76. package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/__partial-basic.md +3 -0
  77. package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/snippet.js +9 -0
  78. package/src/lib/MarkdownDocs/__tests__/fixtures/includes/part.md +11 -0
  79. package/src/lib/MarkdownDocs/__tests__/fixtures/includes/wrapper.md +5 -0
  80. package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.js +8 -0
  81. package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.md +5 -0
  82. package/src/lib/MarkdownDocs/__tests__/helpers.js +67 -0
  83. package/src/lib/MarkdownDocs/__tests__/localeUtils.test.js +204 -0
  84. package/src/lib/MarkdownDocs/__tests__/markdown.test.js +704 -0
  85. package/src/lib/MarkdownDocs/__tests__/markdownRendererRuntime.test.js +65 -0
  86. package/src/lib/MarkdownDocs/__tests__/searchIndexBuilder.test.js +117 -0
  87. package/src/lib/MarkdownDocs/__tests__/sqliteStore.test.js +202 -0
  88. package/src/lib/MarkdownDocs/__tests__/useRouter.test.js +16 -0
  89. package/src/lib/MarkdownDocs/ai/adapters/customAdapter.js +14 -0
  90. package/src/lib/MarkdownDocs/ai/adapters/customAdapter.ts +43 -0
  91. package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.js +81 -0
  92. package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.ts +116 -0
  93. package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.js +92 -0
  94. package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.ts +137 -0
  95. package/src/lib/MarkdownDocs/ai/aiProvider.ts +31 -0
  96. package/src/lib/MarkdownDocs/ai/characters.js +52 -0
  97. package/src/lib/MarkdownDocs/ai/characters.ts +69 -0
  98. package/src/lib/MarkdownDocs/ai/chunkStore.ts +25 -0
  99. package/src/lib/MarkdownDocs/ai/chunker.js +85 -0
  100. package/src/lib/MarkdownDocs/ai/chunker.ts +135 -0
  101. package/src/lib/MarkdownDocs/ai/docLinker.js +26 -0
  102. package/src/lib/MarkdownDocs/ai/docLinker.ts +36 -0
  103. package/src/lib/MarkdownDocs/ai/promptBuilder.js +33 -0
  104. package/src/lib/MarkdownDocs/ai/promptBuilder.ts +53 -0
  105. package/src/lib/MarkdownDocs/ai/ragPipeline.js +54 -0
  106. package/src/lib/MarkdownDocs/ai/ragPipeline.ts +106 -0
  107. package/src/lib/MarkdownDocs/ai/stores/memoryStore.js +88 -0
  108. package/src/lib/MarkdownDocs/ai/stores/memoryStore.ts +112 -0
  109. package/src/lib/MarkdownDocs/ai/stores/sqliteStore.ts +372 -0
  110. package/src/lib/MarkdownDocs/ai/types.ts +71 -0
  111. package/src/lib/MarkdownDocs/aiServer.js +288 -0
  112. package/src/lib/MarkdownDocs/codeDirectives.js +191 -0
  113. package/src/lib/MarkdownDocs/codeFenceInfo.js +45 -0
  114. package/src/lib/MarkdownDocs/codeGroup.ts +46 -0
  115. package/src/lib/MarkdownDocs/common.ts +47 -0
  116. package/src/lib/MarkdownDocs/docsUtils.js +281 -0
  117. package/src/lib/MarkdownDocs/index.plugins.js +22 -0
  118. package/src/lib/MarkdownDocs/layouts/LayoutDoc.svelte +8 -0
  119. package/src/lib/MarkdownDocs/layouts/LayoutHome.svelte +58 -0
  120. package/src/lib/MarkdownDocs/layouts/LayoutPage.svelte +9 -0
  121. package/src/lib/MarkdownDocs/loadGregConfig.js +82 -0
  122. package/src/lib/MarkdownDocs/localeUtils.ts +682 -0
  123. package/src/lib/MarkdownDocs/markdownRendererRuntime.ts +314 -0
  124. package/src/lib/MarkdownDocs/mermaidThemes.js +319 -0
  125. package/src/lib/MarkdownDocs/navigationUtils.js +22 -0
  126. package/src/lib/MarkdownDocs/rehypeCodeGroup.js +326 -0
  127. package/src/lib/MarkdownDocs/rehypeCodeTitle.js +96 -0
  128. package/src/lib/MarkdownDocs/rehypeToc.js +170 -0
  129. package/src/lib/MarkdownDocs/remarkCodeMeta.js +22 -0
  130. package/src/lib/MarkdownDocs/remarkContainers.js +329 -0
  131. package/src/lib/MarkdownDocs/remarkCustomAnchors.js +42 -0
  132. package/src/lib/MarkdownDocs/remarkEscapeSvelte.js +33 -0
  133. package/src/lib/MarkdownDocs/remarkGlobalComponents.js +65 -0
  134. package/src/lib/MarkdownDocs/remarkImports.js +461 -0
  135. package/src/lib/MarkdownDocs/remarkImportsBrowser.js +349 -0
  136. package/src/lib/MarkdownDocs/remarkInlineAttrs.js +95 -0
  137. package/src/lib/MarkdownDocs/remarkMathToHtml.js +138 -0
  138. package/src/lib/MarkdownDocs/searchIndexBuilder.js +497 -0
  139. package/src/lib/MarkdownDocs/searchServer.js +263 -0
  140. package/src/lib/MarkdownDocs/treeViewTypes.ts +11 -0
  141. package/src/lib/MarkdownDocs/useRouter.svelte.ts +114 -0
  142. package/src/lib/MarkdownDocs/useSplitter.svelte.ts +33 -0
  143. package/src/lib/MarkdownDocs/versioningDefaults.js +20 -0
  144. package/src/lib/MarkdownDocs/vitePluginAiServer.js +204 -0
  145. package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +153 -0
  146. package/src/lib/MarkdownDocs/vitePluginFrontmatter.js +109 -0
  147. package/src/lib/MarkdownDocs/vitePluginGregConfig.js +108 -0
  148. package/src/lib/MarkdownDocs/vitePluginSearchIndex.js +57 -0
  149. package/src/lib/MarkdownDocs/vitePluginSearchServer.js +190 -0
  150. package/src/lib/components/Badge.svelte +59 -0
  151. package/src/lib/components/Button.svelte +138 -0
  152. package/src/lib/components/CarbonAds.svelte +99 -0
  153. package/src/lib/components/CodeGroup.svelte +102 -0
  154. package/src/lib/components/Feature.svelte +209 -0
  155. package/src/lib/components/Features.svelte +123 -0
  156. package/src/lib/components/Hero.svelte +399 -0
  157. package/src/lib/components/Image.svelte +128 -0
  158. package/src/lib/components/Link.svelte +105 -0
  159. package/src/lib/components/SocialLink.svelte +84 -0
  160. package/src/lib/components/SocialLinks.svelte +33 -0
  161. package/src/lib/components/Steps.svelte +143 -0
  162. package/src/lib/components/TeamMember.svelte +273 -0
  163. package/src/lib/components/TeamMembers.svelte +81 -0
  164. package/src/lib/components/TeamPage.svelte +65 -0
  165. package/src/lib/components/TeamPageSection.svelte +108 -0
  166. package/src/lib/components/TeamPageTitle.svelte +89 -0
  167. package/src/lib/components/index.js +24 -0
  168. package/src/lib/portal/context.js +12 -0
  169. package/src/lib/portal/index.js +3 -0
  170. package/src/lib/portal/portal.svelte +14 -0
  171. package/src/lib/portal/slot.svelte +8 -0
  172. package/src/lib/scss/__code.scss +128 -0
  173. package/src/lib/scss/__containers.scss +99 -0
  174. package/src/lib/scss/__markdown.scss +447 -0
  175. package/src/lib/scss/__scrollbar.scss +60 -0
  176. package/src/lib/scss/__steps.scss +100 -0
  177. package/src/lib/scss/__theme.scss +238 -0
  178. package/src/lib/scss/__toc.scss +55 -0
  179. package/src/lib/scss/__utilities.scss +7 -0
  180. package/src/lib/scss/greg.scss +9 -0
  181. package/src/lib/spinner/spinner.svelte +42 -0
  182. package/svelte.config.js +146 -0
  183. package/types/index.d.ts +456 -0
@@ -0,0 +1,704 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import path from 'node:path';
3
+ import { process as processMarkdown } from './helpers.js';
4
+ import { unified } from 'unified';
5
+ import remarkParse from 'remark-parse';
6
+ import remarkRehype from 'remark-rehype';
7
+ import rehypeStringify from 'rehype-stringify';
8
+ import { remarkImportsBrowser } from '../remarkImportsBrowser.js';
9
+
10
+ const importsFixtureOptions = {
11
+ sourceRoot: path.join(process.cwd(), 'src/lib/MarkdownDocs/__tests__/fixtures'),
12
+ docsDir: 'docs',
13
+ };
14
+
15
+ // ─── rehype-slug ───────────────────────────────────────────────────────────────
16
+
17
+ describe('rehype-slug', () => {
18
+ it('adds id to h1', async () => {
19
+ const html = await processMarkdown('# Hello World');
20
+ expect(html).toContain('id="hello-world"');
21
+ });
22
+
23
+ it('adds id to h2', async () => {
24
+ const html = await processMarkdown('## Section One');
25
+ expect(html).toContain('id="section-one"');
26
+ });
27
+
28
+ it('adds id to h3', async () => {
29
+ const html = await processMarkdown('### Sub Section');
30
+ expect(html).toContain('id="sub-section"');
31
+ });
32
+
33
+ it('slugifies special characters', async () => {
34
+ const html = await processMarkdown('## C++ & Rust');
35
+ // rehype-slug keeps consecutive punctuation: C++ becomes c-- (each + → -)
36
+ expect(html).toMatch(/id="c-[^"]*rust"/);
37
+ });
38
+
39
+ it('handles duplicate headings with suffix', async () => {
40
+ const html = await processMarkdown('## Dup\n\n## Dup');
41
+ expect(html).toContain('id="dup"');
42
+ expect(html).toContain('id="dup-1"');
43
+ });
44
+ });
45
+
46
+ // ─── rehype-autolink-headings ──────────────────────────────────────────────────
47
+
48
+ describe('rehype-autolink-headings (behavior: prepend)', () => {
49
+ it('prepends a # anchor before heading text', async () => {
50
+ const html = await processMarkdown('## My Section');
51
+ // behavior:'prepend' → <h2 id="..."><a class="header-anchor" ...>#</a>My Section</h2>
52
+ expect(html).toMatch(/<h2 id="my-section"><a class="header-anchor"[^>]*href="#my-section"[^>]*>#<\/a>My Section<\/h2>/);
53
+ });
54
+
55
+ it('link href matches heading id', async () => {
56
+ const html = await processMarkdown('### Deep Link');
57
+ expect(html).toContain('href="#deep-link"');
58
+ expect(html).toContain('id="deep-link"');
59
+ });
60
+ });
61
+
62
+ // ─── [[TOC]] placeholder ───────────────────────────────────────────────────────
63
+
64
+ describe('rehypeTocPlaceholder — [[TOC]]', () => {
65
+ const md = `
66
+ [[TOC]]
67
+
68
+ ## First
69
+
70
+ ### First Sub
71
+
72
+ ## Second
73
+ `.trim();
74
+
75
+ it('replaces [[TOC]] with nav.table-of-contents', async () => {
76
+ const html = await processMarkdown(md);
77
+ expect(html).toContain('<nav class="table-of-contents">');
78
+ });
79
+
80
+ it('uses <ul> by default (not <ol>)', async () => {
81
+ const html = await processMarkdown(md);
82
+ expect(html).toContain('<ul>');
83
+ expect(html).not.toContain('<ol>');
84
+ });
85
+
86
+ it('contains links to h2 headings', async () => {
87
+ const html = await processMarkdown(md);
88
+ expect(html).toContain('href="#first"');
89
+ expect(html).toContain('href="#second"');
90
+ });
91
+
92
+ it('nests h3 inside h2 <li>', async () => {
93
+ const html = await processMarkdown(md);
94
+ // h3 link must appear after h2 link, inside a nested <ul>
95
+ expect(html).toMatch(/href="#first"[\s\S]*href="#first-sub"/);
96
+ });
97
+
98
+ it('does NOT include h1 by default', async () => {
99
+ const html = await processMarkdown('[[TOC]]\n\n# Title\n\n## Section');
100
+ // Extract only the TOC nav block to check it doesn't contain h1 link
101
+ const tocMatch = html.match(/<nav[^>]*>([\s\S]*?)<\/nav>/);
102
+ expect(tocMatch).not.toBeNull();
103
+ const toc = tocMatch[0];
104
+ expect(toc).not.toContain('href="#title"');
105
+ expect(toc).toContain('href="#section"');
106
+ });
107
+
108
+ it('respects custom level option', async () => {
109
+ const html = await processMarkdown(md, { toc: { level: [3] } });
110
+ const tocMatch = html.match(/<nav[^>]*>([\s\S]*?)<\/nav>/);
111
+ expect(tocMatch).not.toBeNull();
112
+ const toc = tocMatch[0];
113
+ expect(toc).toContain('href="#first-sub"');
114
+ expect(toc).not.toContain('href="#first"');
115
+ });
116
+
117
+ it('respects custom containerClass', async () => {
118
+ const html = await processMarkdown(md, { toc: { containerClass: 'my-toc' } });
119
+ expect(html).toContain('class="my-toc"');
120
+ });
121
+
122
+ it('respects listTag: ol', async () => {
123
+ const html = await processMarkdown(md, { toc: { listTag: 'ol' } });
124
+ expect(html).toContain('<ol>');
125
+ });
126
+
127
+ it('applies custom format function', async () => {
128
+ const html = await processMarkdown(md, { toc: { format: (t) => t.toUpperCase() } });
129
+ expect(html).toContain('>FIRST<');
130
+ });
131
+
132
+ it('does not replace text that only partially matches [[toc]]', async () => {
133
+ const html = await processMarkdown('See also [[toc]] here\n\n## H');
134
+ expect(html).not.toContain('<nav');
135
+ });
136
+
137
+ it('is case-insensitive: [[toc]], [[Toc]], [[TOC]] all work', async () => {
138
+ for (const placeholder of ['[[toc]]', '[[Toc]]', '[[TOC]]']) {
139
+ const html = await processMarkdown(`${placeholder}\n\n## Section`);
140
+ expect(html).toContain('<nav class="table-of-contents">');
141
+ }
142
+ });
143
+
144
+ it('renders no TOC when there are no matching headings', async () => {
145
+ const html = await processMarkdown('[[TOC]]\n\nJust text, no headings');
146
+ expect(html).not.toContain('<nav');
147
+ });
148
+ });
149
+
150
+ // ─── Custom Containers ─────────────────────────────────────────────────────────
151
+
152
+ describe('remarkContainers — ::: info', () => {
153
+ it('renders info container with default title', async () => {
154
+ const html = await processMarkdown('::: info\nContent\n:::');
155
+ expect(html).toContain('class="custom-block info"');
156
+ expect(html).toContain('class="custom-block-title"');
157
+ expect(html).toContain('INFO');
158
+ expect(html).toContain('Content');
159
+ expect(html).not.toContain(':::');
160
+ });
161
+
162
+ it('renders info with custom title', async () => {
163
+ const html = await processMarkdown('::: info Custom Title\nBody\n:::');
164
+ expect(html).toContain('Custom Title');
165
+ expect(html).not.toContain('>INFO<');
166
+ });
167
+ });
168
+
169
+ describe('remarkContainers — ::: tip', () => {
170
+ it('renders tip container with default title', async () => {
171
+ const html = await processMarkdown('::: tip\nA tip\n:::');
172
+ expect(html).toContain('class="custom-block tip"');
173
+ expect(html).toContain('TIP');
174
+ });
175
+ });
176
+
177
+ describe('remarkContainers — ::: warning', () => {
178
+ it('renders warning container with default title', async () => {
179
+ const html = await processMarkdown('::: warning\nWatch out\n:::');
180
+ expect(html).toContain('class="custom-block warning"');
181
+ expect(html).toContain('WARNING');
182
+ });
183
+ });
184
+
185
+ describe('remarkContainers — ::: danger', () => {
186
+ it('renders danger container with default title', async () => {
187
+ const html = await processMarkdown('::: danger\nDangerous\n:::');
188
+ expect(html).toContain('class="custom-block danger"');
189
+ expect(html).toContain('DANGER');
190
+ });
191
+ });
192
+
193
+ describe('remarkContainers — ::: details', () => {
194
+ it('renders details as <details> element', async () => {
195
+ const html = await processMarkdown('::: details\nHidden\n:::');
196
+ expect(html).toContain('<details');
197
+ expect(html).toContain('class="custom-block details"');
198
+ expect(html).toContain('<summary');
199
+ expect(html).toContain('class="custom-block-title"');
200
+ expect(html).toContain('Details');
201
+ expect(html).toContain('Hidden');
202
+ });
203
+
204
+ it('details without {open} has no open attribute', async () => {
205
+ const html = await processMarkdown('::: details\nHidden\n:::');
206
+ expect(html).not.toContain('open');
207
+ });
208
+
209
+ it('details with {open} attribute renders as open', async () => {
210
+ const html = await processMarkdown('::: details Click me {open}\nContent\n:::');
211
+ expect(html).toContain('<details');
212
+ expect(html).toMatch(/open/);
213
+ });
214
+
215
+ it('strips {open} from displayed title', async () => {
216
+ const html = await processMarkdown('::: details Click me {open}\nContent\n:::');
217
+ expect(html).not.toContain('{open}');
218
+ expect(html).toContain('Click me');
219
+ });
220
+
221
+ it('custom title without {open}', async () => {
222
+ const html = await processMarkdown('::: details My Summary\nBody\n:::');
223
+ expect(html).toContain('My Summary');
224
+ expect(html).not.toContain('Details');
225
+ });
226
+ });
227
+
228
+ describe('remarkContainers — custom labels', () => {
229
+ it('respects infoLabel option', async () => {
230
+ const html = await processMarkdown('::: info\nMsg\n:::', { containers: { infoLabel: 'UWAGA' } });
231
+ expect(html).toContain('UWAGA');
232
+ expect(html).not.toContain('>INFO<');
233
+ });
234
+
235
+ it('respects tipLabel option', async () => {
236
+ const html = await processMarkdown('::: tip\nMsg\n:::', { containers: { tipLabel: 'WSKAZÓWKA' } });
237
+ expect(html).toContain('WSKAZÓWKA');
238
+ });
239
+ });
240
+
241
+ describe('remarkContainers — unknown type is ignored', () => {
242
+ it('leaves unknown ::: blocks as-is (not transformed)', async () => {
243
+ const html = await processMarkdown('::: custom-unknown\nContent\n:::');
244
+ expect(html).not.toContain('custom-block');
245
+ expect(html).toContain('custom-unknown');
246
+ });
247
+ });
248
+
249
+ describe('rehype-code-group', () => {
250
+ it('renders ::: code-group labels into tabbed group', async () => {
251
+ const html = await processMarkdown('::: code-group labels=[npm, pnpm]\n\n```bash\nnpm i\n```\n\n```bash\npnpm add\n```\n\n:::');
252
+ expect(html).toContain('<codegroup');
253
+ expect(html).toContain('rehype-code-group');
254
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*npm[^"]*pnpm[^"]*\]"/);
255
+ expect(html).toContain('rcg-block');
256
+ expect(html).not.toContain('<head>');
257
+ expect(html).not.toContain('<script');
258
+ });
259
+
260
+ it('infers labels from code block language when labels are omitted', async () => {
261
+ const html = await processMarkdown('::: code-group\n\n```javascript\nconsole.log(1)\n```\n\n```typescript\nconst n: number = 1\n```\n\n:::');
262
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*js[^"]*ts[^"]*\]"/);
263
+ });
264
+
265
+ it('prefers title metadata for labels when available', async () => {
266
+ const html = await processMarkdown('::: code-group\n\n<pre data-code-lang="js" data-code-title="config.js"><code>a</code></pre>\n\n<pre data-code-lang="ts" data-code-title="config.ts"><code>b</code></pre>\n\n:::');
267
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*config\.js[^"]*config\.ts[^"]*\]"/);
268
+ });
269
+
270
+ it('uses bracket title from fenced code metastring when labels are omitted', async () => {
271
+ const html = await processMarkdown('::: code-group\n\n```js [config.js]\nexport default {}\n```\n\n```ts [config.ts]\nexport default {}\n```\n\n:::');
272
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*config\.js[^"]*config\.ts[^"]*\]"/);
273
+ });
274
+
275
+ it('keeps raw html blocks inside code-group', async () => {
276
+ const html = await processMarkdown('::: code-group labels=[npm, pnpm]\n\n<pre><code>npm i</code></pre>\n\n<pre><code>pnpm add</code></pre>\n\n:::');
277
+ expect(html).toContain('rehype-code-group');
278
+ expect(html).toContain('rcg-block');
279
+ expect(html).toContain('npm i');
280
+ expect(html).toContain('pnpm add');
281
+ });
282
+
283
+ it('does not spill into following sections when closing delimiter is malformed around html', async () => {
284
+ const md = [
285
+ '::: code-group labels=[markdown, output]',
286
+ '',
287
+ '```md',
288
+ '<Features features={[{ icon: \'docs\', title: \'Docs\' }]} />',
289
+ '```',
290
+ '',
291
+ '<Features features={[{ icon: \'docs\', title: \'Docs\' }]} />',
292
+ ':::',
293
+ '',
294
+ '### Direct component usage (`.svelte`)',
295
+ '',
296
+ '::: code-group labels=[svelte, output]',
297
+ '',
298
+ '```svelte',
299
+ '<script>\n import Features from \'$components/Features.svelte\';\n</script>',
300
+ '```',
301
+ '',
302
+ ':::',
303
+ ].join('\n');
304
+
305
+ const html = await processMarkdown(md);
306
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*markdown[^"]*output[^"]*\]"/);
307
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*svelte[^"]*\]"/);
308
+ expect(html).not.toContain('Tab 3');
309
+ expect(html).not.toContain('Tab 4');
310
+ });
311
+ });
312
+
313
+ describe('rehype-code-title', () => {
314
+ it('renders title bar for fenced code block with [title]', async () => {
315
+ const html = await processMarkdown('```js [example.js]\nexport const answer = 42;\n```');
316
+ expect(html).toContain('code-block-with-title');
317
+ expect(html).toContain('code-block-title');
318
+ expect(html).toContain('>example.js<');
319
+ });
320
+
321
+ it('does not render duplicate title bars inside code-group', async () => {
322
+ const html = await processMarkdown('::: code-group\n\n```js [config.js]\nexport default {}\n```\n\n```ts [config.ts]\nexport default {}\n```\n\n:::');
323
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*config\.js[^"]*config\.ts[^"]*\]"/);
324
+ expect(html).not.toContain('code-block-with-title');
325
+ });
326
+ });
327
+
328
+ describe('remarkContainers — content', () => {
329
+ it('renders markdown content inside containers', async () => {
330
+ const html = await processMarkdown('::: info\n**bold** and `code`\n:::');
331
+ expect(html).toContain('<strong>bold</strong>');
332
+ expect(html).toContain('<code>code</code>');
333
+ });
334
+
335
+ it('renders ::: tip nested inside ordered list items (Steps-like content)', async () => {
336
+ const md = [
337
+ '1. **Install**',
338
+ '',
339
+ ' ::: tip Requires Node.js 18+',
340
+ ' Check version.',
341
+ ' :::',
342
+ '',
343
+ '2. **Done**',
344
+ ].join('\n');
345
+
346
+ const html = await processMarkdown(md);
347
+ expect(html).toContain('<ol>');
348
+ expect(html).toContain('class="custom-block tip"');
349
+ expect(html).toContain('Requires Node.js 18+');
350
+ expect(html).toContain('Check version.');
351
+ });
352
+
353
+ it('renders code block inside details', async () => {
354
+ const html = await processMarkdown(`::: details Click me {open}\n\`\`\`js\nconsole.log('hi')\n\`\`\`\n:::`);
355
+ expect(html).toContain('<details');
356
+ expect(html).toContain('<code');
357
+ });
358
+ });
359
+
360
+ // ─── docsUtils — __partial filter ─────────────────────────────────────────────
361
+
362
+ describe('docsUtils — prepareMenu', async () => {
363
+ const { prepareMenu, parseSidebarConfig } = await import('../docsUtils.js');
364
+ const { handleSectionClick } = await import('../navigationUtils.js');
365
+
366
+ it('excludes files starting with __', () => {
367
+ const modules = {
368
+ '/docs/index.md': {},
369
+ '/docs/folder1/__partial.md': {},
370
+ '/docs/folder1/index.md': {},
371
+ '/docs/__hidden.md': {},
372
+ };
373
+ const tree = prepareMenu(modules, '/docs');
374
+ const flatten = JSON.stringify(tree);
375
+ expect(flatten).not.toContain('__partial');
376
+ expect(flatten).not.toContain('__hidden');
377
+ });
378
+
379
+ it('includes normal files', () => {
380
+ const modules = {
381
+ '/docs/index.md': {},
382
+ '/docs/guide.md': {},
383
+ };
384
+ const tree = prepareMenu(modules, '/docs');
385
+ const flatten = JSON.stringify(tree);
386
+ expect(flatten).toContain('guide');
387
+ });
388
+
389
+ it('applies order across folders and files', () => {
390
+ const modules = {
391
+ '/docs/index.md': {},
392
+ '/docs/alpha.md': {},
393
+ '/docs/guide/index.md': {},
394
+ };
395
+ const frontmatters = {
396
+ '/docs/alpha.md': { order: -100 },
397
+ };
398
+ const tree = prepareMenu(modules, '/docs', frontmatters).filter((x) => x.link !== '/docs');
399
+ expect(tree[0].link).toBe('/docs/alpha');
400
+ expect(tree[1].link).toBe('/docs/guide');
401
+ });
402
+
403
+ it('sorts by order first, then folder/file tie-break, then alphabetically', () => {
404
+ const modules = {
405
+ '/docs/index.md': {},
406
+ '/docs/zeta/index.md': {},
407
+ '/docs/beta/index.md': {},
408
+ '/docs/alpha/index.md': {},
409
+ '/docs/page.md': {},
410
+ };
411
+ const frontmatters = {
412
+ '/docs/zeta/index.md': { order: 20 },
413
+ '/docs/beta/index.md': { order: 10 },
414
+ '/docs/page.md': { order: 15 },
415
+ };
416
+ const tree = prepareMenu(modules, '/docs', frontmatters).filter((x) => x.link !== '/docs');
417
+ expect(tree.map((x) => x.link)).toEqual([
418
+ '/docs/beta',
419
+ '/docs/page',
420
+ '/docs/zeta',
421
+ '/docs/alpha',
422
+ ]);
423
+ });
424
+
425
+ it('does not add folder index as a separate child item', () => {
426
+ const modules = {
427
+ '/docs/index.md': {},
428
+ '/docs/guide/index.md': {},
429
+ '/docs/guide/intro.md': {},
430
+ };
431
+ const tree = prepareMenu(modules, '/docs');
432
+ const guide = tree.find((x) => x.link === '/docs/guide');
433
+ expect(guide).toBeTruthy();
434
+ expect(guide.type).toBe('md');
435
+ expect(guide.children.some((x) => x.link === '/docs/guide')).toBe(false);
436
+ expect(guide.children.map((x) => x.link)).toEqual(['/docs/guide/intro']);
437
+ });
438
+
439
+ it('uses sidebar text first, then index frontmatter title, then folder name for auto labels', () => {
440
+ const frontmatters = {
441
+ '/docs/guide/index.md': { title: 'Guide Title' },
442
+ '/docs/guide/intro.md': {},
443
+ '/docs/api/index.md': {},
444
+ '/docs/api/ref.md': {},
445
+ };
446
+
447
+ const withSidebarText = parseSidebarConfig([
448
+ { text: 'Guide From Sidebar', auto: '/guide' },
449
+ ], frontmatters, '/docs');
450
+ expect(withSidebarText[0].label).toBe('Guide From Sidebar');
451
+
452
+ const withoutSidebarText = parseSidebarConfig([
453
+ { auto: '/guide' },
454
+ ], frontmatters, '/docs');
455
+ expect(withoutSidebarText[0].label).toBe('Guide Title');
456
+
457
+ const withoutSidebarAndTitle = parseSidebarConfig([
458
+ { auto: '/api' },
459
+ ], frontmatters, '/docs');
460
+ expect(withoutSidebarAndTitle[0].label).toBe('Api');
461
+ });
462
+
463
+ it('creates expandable sections for auto sidebar entries', () => {
464
+ const frontmatters = {
465
+ '/docs/guide/index.md': { title: 'Guide' },
466
+ '/docs/guide/getting-started.md': {},
467
+ '/docs/reference/index.md': { title: 'Reference' },
468
+ '/docs/reference/config.md': {},
469
+ };
470
+
471
+ const sidebar = parseSidebarConfig([
472
+ { text: 'Guide', auto: '/guide' },
473
+ { text: 'Reference', auto: '/reference' },
474
+ ], frontmatters, '/docs');
475
+
476
+ expect(sidebar[0].children.length).toBeGreaterThan(0);
477
+ expect(sidebar[0].children.map((x) => x.link)).toContain('/docs/guide/getting-started');
478
+ expect(sidebar[1].children.length).toBeGreaterThan(0);
479
+ expect(sidebar[1].children.map((x) => x.link)).toContain('/docs/reference/config');
480
+ });
481
+
482
+ it('orders guide pages before section folders when order is lower', () => {
483
+ const modules = {
484
+ '/docs/guide/index.md': {},
485
+ '/docs/guide/getting-started.md': {},
486
+ '/docs/guide/markdown/index.md': {},
487
+ '/docs/guide/markdown/code.md': {},
488
+ };
489
+ const frontmatters = {
490
+ '/docs/guide/getting-started.md': { order: 1, title: 'Getting Started' },
491
+ '/docs/guide/markdown/index.md': { order: 2, title: 'Markdown extensions' },
492
+ };
493
+
494
+ const tree = prepareMenu(modules, '/docs/guide', frontmatters).filter((x) => x.link !== '/docs/guide');
495
+ expect(tree.map((x) => x.link)).toEqual([
496
+ '/docs/guide/getting-started',
497
+ '/docs/guide/markdown',
498
+ ]);
499
+ });
500
+
501
+ it('handleSectionClick toggles section and navigates only when index exists', () => {
502
+ const toggleSection = vi.fn();
503
+ const navigate = vi.fn();
504
+
505
+ handleSectionClick({ type: 'md', link: '/docs/guide' }, '/docs/guide', toggleSection, navigate);
506
+ expect(toggleSection).toHaveBeenCalledWith('/docs/guide');
507
+ expect(navigate).toHaveBeenCalledWith('/docs/guide');
508
+
509
+ toggleSection.mockClear();
510
+ navigate.mockClear();
511
+
512
+ handleSectionClick({ type: 'folder', link: '/docs/api' }, '/docs/api', toggleSection, navigate);
513
+ expect(toggleSection).toHaveBeenCalledWith('/docs/api');
514
+ expect(navigate).not.toHaveBeenCalled();
515
+ });
516
+ });
517
+
518
+ describe('imports — snippets and markdown includes', () => {
519
+ const testFile = path.join(process.cwd(), 'src/lib/MarkdownDocs/__tests__/fixtures/test.md');
520
+
521
+ it('imports code snippet with <<< as code block (no markdown parsing inside)', async () => {
522
+ const html = await processMarkdown('<<< ./snippets/sample.md', { filename: testFile });
523
+ expect(html).toContain('class="language-md"');
524
+ expect(html).toContain('Snippet heading');
525
+ expect(html).not.toContain('<h2 id="snippet-heading">');
526
+ expect(html).not.toContain('custom-block info');
527
+ });
528
+
529
+ it('supports snippet title from trailing [title]', async () => {
530
+ const html = await processMarkdown('::: code-group\n\n<<< ./snippets/sample.js [sample.js]\n\n<<< ./snippets/sample.js [copy.js]\n\n:::', { filename: testFile });
531
+ expect(html).toMatch(/data-codegroup-tabs="\[[^"]*sample\.js[^"]*copy\.js[^"]*\]"/);
532
+ });
533
+
534
+ it('supports snippet region and line range selection', async () => {
535
+ const html = await processMarkdown('<<< ./snippets/sample.js#demo{1,1}', { filename: testFile });
536
+ expect(html).toContain('region line 1');
537
+ expect(html).not.toContain('region line 2');
538
+ });
539
+
540
+ it('supports snippet import with @ alias without slash', async () => {
541
+ const html = await processMarkdown('<<< @docs/markdown/snippet.js#snippet{2,2}', {
542
+ filename: testFile,
543
+ imports: importsFixtureOptions,
544
+ });
545
+ expect(html).toContain('..');
546
+ expect(html).not.toContain('export default foo');
547
+ });
548
+
549
+ it('resolves relative snippet path from /docs/... vfile path', async () => {
550
+ const html = await processMarkdown('<<< ./snippet.js', {
551
+ filename: '/docs/markdown/includes.md',
552
+ imports: importsFixtureOptions,
553
+ });
554
+ expect(html).toContain('function foo()');
555
+ expect(html).toContain('export default foo');
556
+ });
557
+
558
+ it('resolves relative snippet path from extensionless /docs/... route path', async () => {
559
+ const html = await processMarkdown('<<< ./snippet.js', {
560
+ filename: '/docs/markdown/includes',
561
+ imports: importsFixtureOptions,
562
+ });
563
+ expect(html).toContain('function foo()');
564
+ expect(html).toContain('export default foo');
565
+ });
566
+
567
+ it('resolves relative snippet path from transformed docs filename', async () => {
568
+ const html = await processMarkdown('<<< ./snippet.js', {
569
+ filename: '/docs/markdown/includes.md.svelte',
570
+ imports: importsFixtureOptions,
571
+ });
572
+ expect(html).toContain('function foo()');
573
+ expect(html).toContain('export default foo');
574
+ });
575
+
576
+ it('resolves snippet path with absolute / prefix from docsRoot', async () => {
577
+ const html = await processMarkdown('<<< /markdown/snippet.js', {
578
+ filename: testFile,
579
+ imports: importsFixtureOptions,
580
+ });
581
+ expect(html).toContain('function foo()');
582
+ expect(html).toContain('export default foo');
583
+ });
584
+
585
+ it('throws on import path that escapes source root', async () => {
586
+ await expect(
587
+ processMarkdown('<<< ../../../etc/passwd', {
588
+ filename: '/docs/markdown/includes.md',
589
+ imports: importsFixtureOptions,
590
+ })
591
+ ).rejects.toThrow('escapes the source root');
592
+ });
593
+
594
+ it('imports markdown via <!--@include: ...--> and processes it as normal markdown', async () => {
595
+ const html = await processMarkdown('<!--@include: ./includes/part.md-->', { filename: testFile });
596
+ expect(html).toContain('<h2 id="included-section">');
597
+ expect(html).toContain('<strong>bold</strong>');
598
+ expect(html).toContain('custom-block info');
599
+ });
600
+
601
+ it('supports include by heading anchor', async () => {
602
+ const html = await processMarkdown('<!--@include: ./includes/part.md#nested-part-->', { filename: testFile });
603
+ expect(html).toContain('<h3 id="nested-part">');
604
+ expect(html).toContain('Nested content.');
605
+ expect(html).not.toContain('Included section');
606
+ });
607
+
608
+ it('resolves relative markdown include path from /docs/... vfile path', async () => {
609
+ const html = await processMarkdown('<!--@include: ./__partial-basic.md-->', {
610
+ filename: '/docs/markdown/includes.md',
611
+ imports: importsFixtureOptions,
612
+ });
613
+ expect(html).toContain('<h3 id="configuration">');
614
+ expect(html).toContain('Can be created using <code>.foorc.json</code>.');
615
+ });
616
+
617
+ it('supports overriding docsDir for relative imports from virtual absolute paths', async () => {
618
+ const html = await processMarkdown('<<< ./snippet.js', {
619
+ filename: '/markdown/includes.md',
620
+ imports: importsFixtureOptions,
621
+ });
622
+ expect(html).toContain('function foo()');
623
+ expect(html).toContain('export default foo');
624
+ });
625
+
626
+ it('supports markdown include with @ alias without slash', async () => {
627
+ const html = await processMarkdown('<!--@include: @src/lib/MarkdownDocs/__tests__/fixtures/includes/part.md#nested-part-->', { filename: testFile });
628
+ expect(html).toContain('<h3 id="nested-part">');
629
+ expect(html).toContain('Nested content.');
630
+ expect(html).not.toContain('Included section');
631
+ });
632
+
633
+ it('resolves / prefix in snippet imports (maps to docsDir root)', async () => {
634
+ const html = await processMarkdown('<<< /markdown/snippet.js#snippet{2,2}', {
635
+ filename: testFile,
636
+ imports: importsFixtureOptions,
637
+ });
638
+ expect(html).toContain('..');
639
+ expect(html).not.toContain('export default foo');
640
+ });
641
+
642
+ it('resolves / prefix in markdown includes (maps to docsDir root)', async () => {
643
+ const html = await processMarkdown('<!--@include: /markdown/__partial-basic.md-->', {
644
+ filename: testFile,
645
+ imports: importsFixtureOptions,
646
+ });
647
+ expect(html).toContain('<h3 id="configuration">');
648
+ expect(html).toContain('Can be created using <code>.foorc.json</code>.');
649
+ });
650
+
651
+ it('normalizes internal link URLs (strips .md extension)', async () => {
652
+ const html = await processMarkdown('[Go to code](/docs/guide/markdown/code.md)', { filename: testFile });
653
+ expect(html).toContain('href="/docs/guide/markdown/code"');
654
+ });
655
+
656
+ it('does not normalize image src URLs', async () => {
657
+ const html = await processMarkdown('![alt](/docs/images/logo.png)', { filename: testFile });
658
+ expect(html).toContain('src="/docs/images/logo.png"');
659
+ });
660
+
661
+ it('/ prefix with custom docsDir resolves includes to that dir', async () => {
662
+ const html = await processMarkdown('[link](/documentation/foo.md)', {
663
+ filename: testFile,
664
+ imports: { sourceRoot: process.cwd(), docsDir: 'documentation' },
665
+ });
666
+ expect(html).toContain('href="/documentation/foo"');
667
+ });
668
+ });
669
+
670
+ describe('remarkImportsBrowser — link normalization', () => {
671
+ async function renderBrowserMarkdown(markdown, options) {
672
+ const file = await unified()
673
+ .use(remarkParse)
674
+ .use(remarkImportsBrowser, options)
675
+ .use(remarkRehype)
676
+ .use(rehypeStringify)
677
+ .process(markdown);
678
+ return String(file);
679
+ }
680
+
681
+ it('resolves ./ links relative to current markdown file directory', async () => {
682
+ const html = await renderBrowserMarkdown('[Header](./header-anchors)', {
683
+ baseUrl: '/docs/guide/markdown/index.md',
684
+ docsPrefix: '/docs',
685
+ });
686
+ expect(html).toContain('href="/docs/guide/markdown/header-anchors"');
687
+ });
688
+
689
+ it('maps leading / links to docsPrefix srcDir', async () => {
690
+ const html = await renderBrowserMarkdown('[Config](/reference/api.md)', {
691
+ baseUrl: '/documentation/guide/markdown/index.md',
692
+ docsPrefix: '/documentation',
693
+ });
694
+ expect(html).toContain('href="/documentation/reference/api"');
695
+ });
696
+
697
+ it('keeps external links unchanged', async () => {
698
+ const html = await renderBrowserMarkdown('[VitePress](https://vitepress.dev)', {
699
+ baseUrl: '/docs/guide/getting-started.md',
700
+ docsPrefix: '/docs',
701
+ });
702
+ expect(html).toContain('href="https://vitepress.dev"');
703
+ });
704
+ });