@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
package/src/theme.scss CHANGED
@@ -157,12 +157,13 @@ html, body, #app {
157
157
  }
158
158
 
159
159
  // === DEFAULT STYLES ===
160
- article h2:first-of-type {
160
+ article hr:first-of-type {
161
161
  display: none;
162
162
  }
163
163
 
164
- article hr:first-of-type {
165
- display: none;
164
+ // A rule inside a specimen is the example itself, not a page divider.
165
+ article .dory-specimen hr:first-of-type {
166
+ display: block;
166
167
  }
167
168
 
168
169
  article img {
@@ -193,3 +194,281 @@ article img {
193
194
  border: 0px;
194
195
  box-shadow: none !important;
195
196
  }
197
+
198
+ // === EDITORIAL STYLE (Dory v2) ===
199
+ :root {
200
+ --dory-code-string: #b65401;
201
+ }
202
+
203
+ :root.dark {
204
+ --dory-code-string: #f4a261;
205
+ }
206
+
207
+ .font-heading {
208
+ font-family: var(--font-heading, var(--font-body, inherit));
209
+ }
210
+
211
+ .dory-page-title {
212
+ font-family: var(--font-heading, var(--font-body, inherit));
213
+ font-size: 1.625rem;
214
+ font-weight: 700;
215
+ line-height: 1.15;
216
+ letter-spacing: -0.025em;
217
+ }
218
+
219
+ .dory-subtitle {
220
+ font-family: var(--font-mono, monospace);
221
+ font-size: 11px;
222
+ letter-spacing: 0.18em;
223
+ color: var(--muted-foreground);
224
+ }
225
+
226
+ article {
227
+ counter-reset: dory-section;
228
+ font-size: 0.875rem;
229
+ line-height: 1.6;
230
+
231
+ .dory-h1 {
232
+ font-size: 1.625rem;
233
+ font-weight: 700;
234
+ line-height: 1.15;
235
+ letter-spacing: -0.025em;
236
+ }
237
+
238
+ .dory-h2 {
239
+ counter-increment: dory-section;
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 0.625rem;
243
+ font-size: 1.3125rem;
244
+ font-weight: 700;
245
+ line-height: 1.2;
246
+ letter-spacing: -0.02em;
247
+
248
+ &::before {
249
+ content: counter(dory-section, decimal-leading-zero);
250
+ flex-shrink: 0;
251
+ padding: 0.0625rem 0.375rem;
252
+ border: 1px solid var(--border);
253
+ border-radius: 0.3125rem;
254
+ background: var(--muted);
255
+ font-family: var(--font-mono, monospace);
256
+ font-size: 0.6875rem;
257
+ font-weight: 400;
258
+ letter-spacing: 0;
259
+ color: var(--muted-foreground);
260
+ }
261
+
262
+ + p {
263
+ color: var(--muted-foreground);
264
+ }
265
+ }
266
+
267
+ .dory-h3 {
268
+ font-size: 1.0625rem;
269
+ font-weight: 700;
270
+ letter-spacing: -0.01em;
271
+ }
272
+
273
+ .dory-h4 {
274
+ font-size: 0.9375rem;
275
+ font-weight: 600;
276
+ letter-spacing: -0.005em;
277
+ }
278
+
279
+ // Unordered: small brand-colored squares. Ordered: mono two-digit counters.
280
+ .dory-ul > li::before {
281
+ content: "";
282
+ position: absolute;
283
+ left: 0.5rem;
284
+ top: 0.7em;
285
+ width: 0.375rem;
286
+ height: 0.375rem;
287
+ border-radius: 0.125rem;
288
+ background: var(--primary);
289
+ }
290
+
291
+ .dory-ul .dory-ul > li {
292
+ color: var(--muted-foreground);
293
+
294
+ &::before {
295
+ background: var(--muted-foreground);
296
+ }
297
+ }
298
+
299
+ .dory-ol {
300
+ counter-reset: dory-ol;
301
+
302
+ > li {
303
+ counter-increment: dory-ol;
304
+
305
+ &::before {
306
+ content: counter(dory-ol, decimal-leading-zero);
307
+ position: absolute;
308
+ left: 0;
309
+ top: 0.15em;
310
+ font-family: var(--font-mono, monospace);
311
+ font-size: 0.6875rem;
312
+ color: var(--primary);
313
+ }
314
+ }
315
+ }
316
+
317
+ // Task lists (GFM) render a checkbox as the first child, so drop the bullet.
318
+ li:has(> input[type="checkbox"]) {
319
+ padding-left: 0;
320
+
321
+ &::before {
322
+ display: none;
323
+ }
324
+ }
325
+
326
+ input[type="checkbox"] {
327
+ width: 0.9375rem;
328
+ height: 0.9375rem;
329
+ margin-right: 0.5rem;
330
+ vertical-align: -0.2em;
331
+ accent-color: var(--primary);
332
+ }
333
+
334
+ blockquote {
335
+ margin: 1.5rem 0;
336
+ padding-left: 1rem;
337
+ border-left: 2px solid var(--primary);
338
+ font-size: 0.9375rem;
339
+ }
340
+
341
+ kbd {
342
+ padding: 0.0625rem 0.375rem;
343
+ border: 1px solid var(--border);
344
+ border-bottom-width: 2px;
345
+ border-radius: 0.375rem;
346
+ background: var(--card);
347
+ font-family: var(--font-mono, monospace);
348
+ font-size: 0.8125em;
349
+ }
350
+
351
+ mark {
352
+ padding: 0.0625rem 0.3rem;
353
+ border-radius: 0.25rem;
354
+ background: var(--highlight);
355
+ color: var(--highlight-foreground);
356
+ }
357
+ }
358
+
359
+ // === CODE LINES ===
360
+ // Lines bleed to the frame edges (CodeBody padding is 1rem left / 3rem right)
361
+ // so highlighted and diff rows read as full-width bands.
362
+ .dory-code {
363
+ .dory-line {
364
+ display: block;
365
+ margin: 0 -3rem 0 -1rem;
366
+ padding: 0 3rem 0 calc(1rem - 2px);
367
+ border-left: 2px solid transparent;
368
+ }
369
+
370
+ .dory-line-highlight {
371
+ background: var(--accent);
372
+ border-left-color: var(--primary);
373
+ }
374
+
375
+ .dory-line-add {
376
+ background: var(--success-muted);
377
+ border-left-color: #2f7d4a;
378
+ }
379
+
380
+ .dory-line-del {
381
+ background: color-mix(in oklch, var(--destructive) 10%, transparent);
382
+ border-left-color: var(--destructive);
383
+ }
384
+
385
+ .dory-line-number {
386
+ display: inline-block;
387
+ width: 1.25rem;
388
+ margin-right: 1rem;
389
+ color: var(--muted-foreground);
390
+ user-select: none;
391
+ }
392
+
393
+ .dory-line-marker {
394
+ display: inline-block;
395
+ width: 2rem;
396
+ user-select: none;
397
+ }
398
+
399
+ .dory-line-add .dory-line-marker {
400
+ color: #2f7d4a;
401
+ }
402
+
403
+ .dory-line-del .dory-line-marker {
404
+ color: var(--destructive);
405
+ }
406
+ }
407
+
408
+ :root.dark .dory-code .dory-line-add {
409
+ border-left-color: #6fbf87;
410
+
411
+ .dory-line-marker {
412
+ color: #6fbf87;
413
+ }
414
+ }
415
+
416
+ // === PDF DETAILS ===
417
+ .dory-lead {
418
+ margin-top: 0.75rem;
419
+ font-size: 0.9375rem;
420
+ line-height: 1.6;
421
+ color: var(--foreground);
422
+ }
423
+
424
+ article .dory-h2 .dory-anchor {
425
+ margin-left: auto;
426
+ font-family: var(--font-mono, monospace);
427
+ font-size: 0.8125rem;
428
+ font-weight: 400;
429
+ letter-spacing: 0;
430
+ color: var(--muted-foreground);
431
+ text-decoration: none;
432
+
433
+ &:hover {
434
+ color: var(--primary);
435
+ }
436
+ }
437
+
438
+ // Optional numbering for CardGroup and AccordionGroup (numbered prop)
439
+ .dory-cards-numbered {
440
+ counter-reset: dory-card;
441
+
442
+ .dory-card {
443
+ counter-increment: dory-card;
444
+
445
+ &::before {
446
+ content: counter(dory-card, decimal-leading-zero);
447
+ display: block;
448
+ margin-bottom: 1.25rem;
449
+ font-family: var(--font-mono, monospace);
450
+ font-size: 0.75rem;
451
+ color: var(--primary);
452
+ }
453
+ }
454
+ }
455
+
456
+ .dory-accordion-numbered {
457
+ counter-reset: dory-accordion;
458
+
459
+ [data-slot="accordion-item"] {
460
+ counter-increment: dory-accordion;
461
+ }
462
+
463
+ [data-slot="accordion-trigger"] > div::before {
464
+ content: counter(dory-accordion, decimal-leading-zero);
465
+ margin-right: 0.25rem;
466
+ font-family: var(--font-mono, monospace);
467
+ font-size: 0.6875rem;
468
+ color: var(--primary);
469
+ }
470
+ }
471
+
472
+ .dory-latex-inline .katex {
473
+ font-size: 1.05em;
474
+ }
@@ -128,7 +128,10 @@ export interface DoryConfig {
128
128
  title?: string;
129
129
  description?: string;
130
130
  image?: string;
131
+ /** Origin the site is published at, e.g. "https://example.com" (no base path). */
131
132
  url?: string;
133
+ /** Path prefix the site is served under, e.g. "/docs". Default: served at the root. */
134
+ basePath?: string;
132
135
  twitter?: string;
133
136
  favicon: string;
134
137
  colors?: DoryColors;
package/src/ui/header.tsx CHANGED
@@ -9,6 +9,7 @@ import { ALL_NAVIGATION } from '../components/store';
9
9
  import { MobileNavigation } from './mobile-navigation';
10
10
  import { ModeToggle } from '@clidey/ux';
11
11
  import { useIsEmbedded } from '../components/hooks';
12
+ import { assetUrl } from '../utils/base-path';
12
13
 
13
14
  const config = docsConfig as DoryConfig;
14
15
  const isThemeForced = config.theme?.mode === 'dark' || config.theme?.mode === 'light';
@@ -44,7 +45,7 @@ export function Header({ className }: { className?: string }) {
44
45
  const isEmbedded = useIsEmbedded();
45
46
 
46
47
  const { light, dark } = useMemo(() => {
47
- return { light: config.logo.light, dark: config.logo.dark };
48
+ return { light: assetUrl(config.logo.light), dark: assetUrl(config.logo.dark) };
48
49
  }, []);
49
50
 
50
51
  useEffect(() => {
@@ -71,8 +72,8 @@ export function Header({ className }: { className?: string }) {
71
72
  >
72
73
  <div className="relative flex justify-between gap-6 sm:gap-8 md:grow items-center w-full px-4">
73
74
  <div className="flex items-center grow gap-2">
74
- <img src={`/${light}`} alt="logo" className="w-8 hidden dark:block" />
75
- <img src={`/${dark}`} alt="logo" className="w-8 block dark:hidden" />
75
+ <img src={light} alt="logo" className="w-8 hidden dark:block" />
76
+ <img src={dark} alt="logo" className="w-8 block dark:hidden" />
76
77
  <h1 className="text-base lg:text-2xl font-bold">{config.name}</h1>
77
78
  </div>
78
79
  <nav className="hidden md:block">
package/src/ui/layout.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ComponentChildren } from 'preact';
2
- import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
3
3
  import { Navigation } from '../components/navigation';
4
4
  import { ALL_NAVIGATION, ALL_OPENAPI, completeFrontMatter, loadMDXFrontMatterForPath, loadAllMDXFrontMatter, preloadFrontmatter, ALL_ASYNCAPI, isFrontmatterReady } from '../components/store';
5
5
  import docsConfig from '../../docs/dory.json' with { type: 'json' };
@@ -10,6 +10,7 @@ import { AsyncAPI } from '../mdx/async-api';
10
10
  import { Header } from './header';
11
11
  import { PrevNextLinks } from './prev-next-link';
12
12
  import { TableOfContents } from './table-of-content';
13
+ import { PageMeta } from './page-meta';
13
14
  import { Loading } from '../components/loading';
14
15
  import Dropdown from '../components/dropdown';
15
16
  import { useNotification } from '../components/notification';
@@ -17,6 +18,7 @@ import { SparkleIcon } from 'lucide-react';
17
18
  import { useIsEmbedded } from '../components/hooks';
18
19
  import { ErrorBoundary } from '../components/error-boundary';
19
20
  import { cn } from '@clidey/ux';
21
+ import { withBase } from '../utils/base-path';
20
22
 
21
23
  const config = docsConfig as DoryConfig;
22
24
 
@@ -24,7 +26,7 @@ interface LayoutProps {
24
26
  children: ComponentChildren;
25
27
  }
26
28
 
27
- const prompt = (pathname: string) => `Please read and analyze the content from this documentation page: ${window.location.origin}${pathname}.mdx\n\n` +
29
+ const prompt = (pathname: string) => `Please read and analyze the content from this documentation page: ${window.location.origin}${withBase(pathname)}.mdx\n\n` +
28
30
  `Then, help me understand and answer any questions I have about it. ` +
29
31
  `Please provide clear, detailed explanations and examples where relevant.`;
30
32
 
@@ -44,13 +46,27 @@ export default function Layout({ children }: LayoutProps) {
44
46
  const { showNotification } = useNotification();
45
47
  const isEmbedded = useIsEmbedded();
46
48
 
49
+ // Client-side navigation keeps the previous scroll position, so start each new
50
+ // page at the top. The initial load keeps the browser's own position, and a
51
+ // hash means an in-page anchor is the target.
52
+ const isFirstRender = useRef(true);
53
+ useEffect(() => {
54
+ if (isFirstRender.current) {
55
+ isFirstRender.current = false;
56
+ return;
57
+ }
58
+ if (!window.location.hash) {
59
+ window.scrollTo(0, 0);
60
+ }
61
+ }, [pathname]);
62
+
47
63
  const handleOpenMDX = useCallback(() => {
48
- window.open(`${window.location.origin}${pathname}.mdx`, '_blank');
64
+ window.open(`${window.location.origin}${withBase(pathname)}.mdx`, '_blank');
49
65
  }, [pathname]);
50
66
 
51
67
  const handleCopyMDX = useCallback(async () => {
52
68
  try {
53
- const response = await fetch(`${pathname}.mdx`);
69
+ const response = await fetch(`${withBase(pathname)}.mdx`);
54
70
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
55
71
  await navigator.clipboard.writeText(await response.text());
56
72
  showNotification('Copied to clipboard');
@@ -277,12 +293,14 @@ export default function Layout({ children }: LayoutProps) {
277
293
  {group}
278
294
  </h1>
279
295
  )}
296
+ {page?.lead && <p className="dory-lead">{page.lead}</p>}
280
297
  </header>
281
298
  {
282
299
  openAPIJSON == null && asyncAPIJSON == null &&
283
300
  <Dropdown className="gap-0" buttonLabel={<SparkleIcon className="w-4 h-4" />} items={dropdownItems} />
284
301
  }
285
302
  </div>
303
+ <PageMeta meta={page?.meta} />
286
304
  <div className="flex-1 min-h-[calc(100vh-2rem)]">
287
305
  <ErrorBoundary fallback={(error) => (
288
306
  <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
@@ -0,0 +1,37 @@
1
+ type MetaValue = string | number | boolean | Date
2
+
3
+ const isoDate = /^\d{4}-\d{2}-\d{2}(T00:00:00(\.000)?Z)?$/
4
+
5
+ function formatValue(value: MetaValue): string {
6
+ const date = value instanceof Date ? value : typeof value === 'string' && isoDate.test(value) ? new Date(value) : null
7
+ if (date && !Number.isNaN(date.getTime())) {
8
+ return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })
9
+ }
10
+ return String(value)
11
+ }
12
+
13
+ /**
14
+ * Label/value strip under the page title, from the frontmatter `meta` map:
15
+ *
16
+ * meta:
17
+ * Edition: Community
18
+ * Updated: 2026-10-12
19
+ *
20
+ * YAML dates render as "12 Oct 2026". Renders nothing when `meta` is absent.
21
+ */
22
+ export function PageMeta({ meta }: { meta?: Record<string, MetaValue> }) {
23
+ if (!meta || typeof meta !== 'object') return null
24
+ const entries = Object.entries(meta)
25
+ if (entries.length === 0) return null
26
+
27
+ return (
28
+ <dl className="dory-page-meta mb-8 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-[var(--border)] py-3 sm:grid-cols-3">
29
+ {entries.map(([label, value]) => (
30
+ <div key={label}>
31
+ <dt className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">{label}</dt>
32
+ <dd className="mt-0.5 text-sm font-semibold text-foreground">{formatValue(value)}</dd>
33
+ </div>
34
+ ))}
35
+ </dl>
36
+ )
37
+ }
@@ -1,4 +1,5 @@
1
1
  import { ArrowRightIcon } from "@heroicons/react/24/outline";
2
+ import { withBase } from '../utils/base-path';
2
3
  import classNames from "classnames";
3
4
  import { ALL_NAVIGATION } from "../components/store";
4
5
  import { usePathname } from "../components/hooks";
@@ -15,15 +16,15 @@ function PageLink({
15
16
  }) {
16
17
  return (
17
18
  <div {...props}>
18
- <p className="font-display text-sm font-medium">
19
+ <p className="font-display text-xs font-medium text-muted-foreground">
19
20
  {dir === 'next' ? 'Next' : 'Previous'}
20
21
  </p>
21
22
  <div className="mt-1">
22
- <a href={href} className={classNames('flex items-center gap-x-1 text-base font-semibold hover:opacity-80', {
23
+ <a href={withBase(href)} className={classNames('flex items-center gap-x-1 text-sm font-semibold hover:opacity-80', {
23
24
  'flex-row-reverse': dir === 'previous',
24
25
  },
25
26
  )}>
26
- <p className={classNames("flex items-center gap-x-1 text-base font-semibold hover:opacity-80", {
27
+ <p className={classNames("flex items-center gap-x-1 text-sm font-semibold hover:opacity-80", {
27
28
  "flex-row-reverse": dir === "previous"
28
29
  })}>
29
30
  {title}
@@ -41,6 +41,14 @@ export function TableOfContents() {
41
41
  const sections: Section[] = []
42
42
  let currentSection: Section | null = null
43
43
 
44
+ // Heading text without the trailing "#" anchor link
45
+ const headingText = (heading: Element) =>
46
+ Array.from(heading.childNodes)
47
+ .filter((node) => !(node instanceof Element && node.classList.contains('dory-anchor')))
48
+ .map((node) => node.textContent ?? '')
49
+ .join('')
50
+ .trim()
51
+
44
52
  headings.forEach((heading) => {
45
53
  // Check if heading is a valid Element and is visible
46
54
  if (!(heading instanceof Element)) return
@@ -48,7 +56,7 @@ export function TableOfContents() {
48
56
  if (style.display === 'none' || style.visibility === 'hidden') return
49
57
 
50
58
  // Prefer the build-time id from rehype-slug; only assign when missing
51
- const id = heading.id || slugify(heading.textContent || '')
59
+ const id = heading.id || slugify(headingText(heading))
52
60
  if (!heading.id && id) {
53
61
  heading.id = id
54
62
  }
@@ -56,14 +64,14 @@ export function TableOfContents() {
56
64
  if (heading.tagName === 'H2') {
57
65
  currentSection = {
58
66
  id,
59
- title: heading.textContent || '',
67
+ title: headingText(heading),
60
68
  children: []
61
69
  }
62
70
  sections.push(currentSection)
63
71
  } else if (heading.tagName === 'H3' && currentSection) {
64
72
  currentSection.children.push({
65
73
  id,
66
- title: heading.textContent || ''
74
+ title: headingText(heading)
67
75
  })
68
76
  }
69
77
  })
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assetUrl, joinBasePath, normalizeBasePath } from './base-path';
3
+
4
+ describe('normalizeBasePath', () => {
5
+ it('normalizes slashes', () => {
6
+ for (const value of ['docs', '/docs', '/docs/', ' /docs/ ', '//docs//']) {
7
+ expect(normalizeBasePath(value)).toBe('/docs');
8
+ }
9
+ expect(normalizeBasePath('/a/b/')).toBe('/a/b');
10
+ });
11
+
12
+ it('treats root and non-strings as no base', () => {
13
+ for (const value of ['', '/', ' ', undefined, null, 3]) {
14
+ expect(normalizeBasePath(value)).toBe('');
15
+ }
16
+ });
17
+ });
18
+
19
+ describe('joinBasePath', () => {
20
+ it('prefixes root-relative paths', () => {
21
+ expect(joinBasePath('/docs', '/intro')).toBe('/docs/intro');
22
+ expect(joinBasePath('/docs', '/')).toBe('/docs/');
23
+ expect(joinBasePath('/docs', '/frontmatter.json')).toBe('/docs/frontmatter.json');
24
+ });
25
+
26
+ it('is a no-op without a base', () => {
27
+ expect(joinBasePath('', '/intro')).toBe('/intro');
28
+ });
29
+
30
+ it('does not double-prefix or touch non-root URLs', () => {
31
+ expect(joinBasePath('/docs', '/docs/intro')).toBe('/docs/intro');
32
+ expect(joinBasePath('/docs', '/docs')).toBe('/docs');
33
+ expect(joinBasePath('/docs', '/docsy/intro')).toBe('/docs/docsy/intro');
34
+ for (const url of ['https://x.dev/a', '//cdn.dev/a', '#top', 'relative/a', 'mailto:a@b.c']) {
35
+ expect(joinBasePath('/docs', url)).toBe(url);
36
+ }
37
+ });
38
+ });
39
+
40
+ describe('assetUrl', () => {
41
+ it('strips config-style prefixes', () => {
42
+ expect(assetUrl('./logo/logo.png')).toBe('/logo/logo.png');
43
+ expect(assetUrl('logo/logo.png')).toBe('/logo/logo.png');
44
+ expect(assetUrl('/logo/logo.png')).toBe('/logo/logo.png');
45
+ });
46
+ });
@@ -0,0 +1,41 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ /**
4
+ * Normalizes a configured base path to "" (site served at the origin root) or
5
+ * "/segment[/more]" with a leading slash and no trailing slash. Accepts
6
+ * "docs", "/docs", "/docs/" and "/" interchangeably; non-strings yield "".
7
+ */
8
+ export function normalizeBasePath(value: unknown): string {
9
+ if (typeof value !== 'string') return '';
10
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
11
+ return trimmed ? `/${trimmed}` : '';
12
+ }
13
+
14
+ /**
15
+ * Prefixes a root-relative path ("/x") with `base`. Protocol URLs,
16
+ * protocol-relative URLs, anchors and paths already under `base` are
17
+ * returned unchanged.
18
+ */
19
+ export function joinBasePath(base: string, path: string): string {
20
+ if (!base || !path.startsWith('/') || path.startsWith('//')) return path;
21
+ if (path === base || path.startsWith(`${base}/`)) return path;
22
+ return `${base}${path}`;
23
+ }
24
+
25
+ /**
26
+ * The base path the site is served under, from Vite's `base` (which the
27
+ * configs derive from `basePath` in dory.json). "" when served at the root.
28
+ */
29
+ export const BASE_PATH = normalizeBasePath(
30
+ typeof import.meta.env !== 'undefined' ? import.meta.env.BASE_URL : ''
31
+ );
32
+
33
+ /** Prefixes a root-relative path with the configured base path. */
34
+ export function withBase(path: string): string {
35
+ return joinBasePath(BASE_PATH, path);
36
+ }
37
+
38
+ /** Resolves a dory.json asset path such as "./logo/logo.png" to a site URL. */
39
+ export function assetUrl(path: string): string {
40
+ return withBase(`/${path.replace(/^\.?\/*/, '')}`);
41
+ }
@@ -3,11 +3,14 @@ import preact from '@preact/preset-vite';
3
3
  import mdx from '@mdx-js/rollup';
4
4
  import { unifiedMdxPreprocessor } from './src/plugins/unified-mdx-plugin';
5
5
  import { getMdxConfig } from './src/config/mdx';
6
+ import { getBasePath, readDoryConfig } from './src/plugins/shared';
6
7
 
7
8
  // Minimal Vite config for the SSR build.
8
9
  // Only includes plugins needed for MDX compilation — no sitemap, prerender,
9
10
  // analytics, or other build-time plugins that target the client output.
10
11
  export default defineConfig({
12
+ // Must match vite.config.ts so SSR markup uses the same base-prefixed URLs.
13
+ base: `${getBasePath(readDoryConfig())}/`,
11
14
  plugins: [
12
15
  unifiedMdxPreprocessor(),
13
16
  preact(),
package/vite.config.ts CHANGED
@@ -15,8 +15,11 @@ import { sitemapGenerator } from './src/plugins/sitemap-generator';
15
15
  import { robotsGenerator } from './src/plugins/robots-generator';
16
16
  import { prerender } from './src/plugins/prerender';
17
17
  import { getMdxConfig } from './src/config/mdx';
18
+ import { getBasePath, readDoryConfig } from './src/plugins/shared';
18
19
 
19
20
  export default defineConfig(({ command }) => ({
21
+ // Serve and build under `basePath` from dory.json (asset URLs, import.meta.env.BASE_URL).
22
+ base: `${getBasePath(readDoryConfig())}/`,
20
23
  plugins: [
21
24
  // SINGLE SOURCE OF TRUTH: All MDX preprocessing happens in src/mdx/processor.ts
22
25
  htmlFilesMiddleware(), // Must be first to intercept HTML file requests