@kirchdev/duxt 0.7.0 → 0.8.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.
@@ -0,0 +1,136 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readdirSync
8
+ } from 'node:fs';
9
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
10
+ import { resolvesInside } from '../sections/section-input';
11
+
12
+ const PRIVATE_DIRECTORIES = new Set([
13
+ '.git',
14
+ 'node_modules',
15
+ '.data',
16
+ '.nuxt',
17
+ '.output',
18
+ '.turbo',
19
+ '.cache'
20
+ ]);
21
+
22
+ const IMAGES = new Set([
23
+ '.png',
24
+ '.jpg',
25
+ '.jpeg',
26
+ '.gif',
27
+ '.svg',
28
+ '.webp',
29
+ '.avif',
30
+ '.apng',
31
+ '.ico'
32
+ ]);
33
+
34
+ export const documentationMediaDirectory = (root = process.cwd()) =>
35
+ join(root, '.data/duxt-media/public');
36
+
37
+ export interface DocumentationMedia {
38
+ root: string;
39
+ prefix: string;
40
+ files: string[];
41
+ }
42
+
43
+ /** Publish only image outputs in _assets, leaving capture sources private. */
44
+ export function publishDocumentationMedia(
45
+ root: string,
46
+ output: string,
47
+ identity: string,
48
+ checkout?: string
49
+ ): DocumentationMedia {
50
+ const prefix = `/_duxt/media/${createHash('sha256').update(identity).digest('hex')}`;
51
+ const media: DocumentationMedia = { root, prefix, files: [] };
52
+ if (!existsSync(root) || lstatSync(root).isSymbolicLink()) return media;
53
+ if (checkout && !resolvesInside(root, checkout)) return media;
54
+ const outputRoot = resolve(output);
55
+ const descend = (directory: string, asset = false) => {
56
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
57
+ if (entry.isSymbolicLink()) continue;
58
+ const absolute = join(directory, entry.name);
59
+ if (entry.isDirectory()) {
60
+ if (
61
+ !PRIVATE_DIRECTORIES.has(entry.name) &&
62
+ resolve(absolute) !== outputRoot
63
+ )
64
+ descend(absolute, asset || entry.name === '_assets');
65
+ } else if (
66
+ asset &&
67
+ entry.isFile() &&
68
+ IMAGES.has(extname(entry.name).toLowerCase())
69
+ ) {
70
+ const target = join(output, prefix, relative(root, absolute));
71
+ mkdirSync(dirname(target), { recursive: true });
72
+ copyFileSync(absolute, target);
73
+ media.files.push(relative(root, absolute).split(sep).join('/'));
74
+ }
75
+ }
76
+ };
77
+ descend(root);
78
+ return media;
79
+ }
80
+
81
+ /** Resolve file-relative documentation images; external/public images pass through. */
82
+ export function documentationImageUrl(
83
+ value: string,
84
+ file: string,
85
+ media: DocumentationMedia
86
+ ): string {
87
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
88
+ const owner = relative(media.root, file);
89
+ if (owner === '..' || owner.startsWith(`..${sep}`)) return value;
90
+ const match = /^([^?#]*)(.*)$/.exec(value)!;
91
+ let path: string;
92
+ try {
93
+ path = decodeURIComponent(match[1]!);
94
+ } catch {
95
+ return value;
96
+ }
97
+ const absolute = path.startsWith('/')
98
+ ? resolve(media.root, `.${path}`)
99
+ : resolve(dirname(file), path);
100
+ const rel = relative(media.root, absolute).split(sep).join('/');
101
+ if (
102
+ rel.startsWith('../') ||
103
+ rel === '..' ||
104
+ !rel.split('/').includes('_assets') ||
105
+ !media.files.includes(rel)
106
+ )
107
+ return value;
108
+ return `${media.prefix}/${rel.split('/').map(encodeURIComponent).join('/')}${match[2]}`;
109
+ }
110
+
111
+ /** Rewrite only image nodes in Content's compressed MDC tree. */
112
+ export function rewriteDocumentationImages(
113
+ content: Record<string, unknown>,
114
+ file: string,
115
+ media: DocumentationMedia
116
+ ): void {
117
+ const walk = (node: unknown) => {
118
+ if (Array.isArray(node)) {
119
+ const [tag, props] = node;
120
+ if (
121
+ ['img', 'prose-img', 'ProseImg'].includes(tag) &&
122
+ props &&
123
+ typeof props === 'object'
124
+ ) {
125
+ for (const key of ['src', 'dark']) {
126
+ if (typeof props[key] === 'string')
127
+ props[key] = documentationImageUrl(props[key], file, media);
128
+ }
129
+ }
130
+ for (const child of node) walk(child);
131
+ } else if (node && typeof node === 'object') {
132
+ for (const child of Object.values(node)) walk(child);
133
+ }
134
+ };
135
+ walk(content.body);
136
+ }
@@ -14,6 +14,10 @@ import { resolveLatestRefs } from './sources-git';
14
14
  // module could reach it without this file's imports coming with it.
15
15
  import { repositoryRoot } from './repository-root';
16
16
  import { resolvesInside } from '../sections/section-input';
17
+ import {
18
+ documentationMediaDirectory,
19
+ publishDocumentationMedia
20
+ } from './documentation-media';
17
21
  export { repositoryRoot };
18
22
  export type {
19
23
  DuxtResolvedSource,
@@ -232,7 +236,9 @@ const excluded = (source: DuxtSource) => {
232
236
  const partials = source.exclude?.partials ?? PARTIALS;
233
237
  const drafts = source.exclude?.drafts ?? DRAFTS;
234
238
 
235
- return includeDrafts() ? [partials] : [partials, drafts];
239
+ return includeDrafts()
240
+ ? [partials, '**/_assets/**']
241
+ : [partials, '**/_assets/**', drafts];
236
242
  };
237
243
 
238
244
  /**
@@ -315,6 +321,20 @@ export function duxtSources(
315
321
 
316
322
  containedKeys(collection);
317
323
 
324
+ const base = source.path ?? 'docs';
325
+ const mediaPath =
326
+ effective.path === base || effective.path.startsWith(`${base}/`)
327
+ ? base
328
+ : effective.path;
329
+ const identity = JSON.stringify([
330
+ effective.repo ?? repositoryRoot(),
331
+ mediaPath,
332
+ usedRef ? [refIsTag(usedRef) ? 'tag' : 'branch', refName(usedRef)] : null
333
+ ]);
334
+ for (const contentSource of collection.source ?? []) {
335
+ prepareMedia(contentSource, effective.repo, mediaPath, identity);
336
+ }
337
+
318
338
  collections[entry.collection] = collection;
319
339
  });
320
340
 
@@ -327,6 +347,37 @@ export function duxtSources(
327
347
  return collections;
328
348
  }
329
349
 
350
+ /** Prepare media for a source, including partial sources sharing a collection. */
351
+ function prepareMedia(
352
+ contentSource: NonNullable<
353
+ ReturnType<typeof defineCollection>['source']
354
+ >[number],
355
+ repo: string | undefined,
356
+ mediaPath: string,
357
+ identity: string
358
+ ) {
359
+ const output = documentationMediaDirectory();
360
+ const keys = contentSource.getKeys!;
361
+ const prepared = contentSource as typeof contentSource & {
362
+ duxtMediaIdentity?: string;
363
+ duxtMedia?: import('./documentation-media').DocumentationMedia;
364
+ };
365
+ // Include source ownership in Content's checksum, even when Markdown is unchanged.
366
+ prepared.duxtMediaIdentity = identity;
367
+ contentSource.getKeys = async () => {
368
+ const root = repo
369
+ ? join(contentSource.cwd, mediaPath)
370
+ : join(repositoryRoot(), mediaPath);
371
+ prepared.duxtMedia = publishDocumentationMedia(
372
+ root,
373
+ output,
374
+ identity,
375
+ repo ? contentSource.cwd : undefined
376
+ );
377
+ return keys();
378
+ };
379
+ }
380
+
330
381
  /**
331
382
  * Keep a downloaded repository's keys to files that are inside its checkout.
332
383
  *
@@ -367,11 +418,11 @@ function partialFolders(
367
418
  expanded: ReturnType<typeof expandSources>
368
419
  ): Map<
369
420
  string | undefined,
370
- { repo?: string; path: string; partials: string }[]
421
+ { repo?: string; path: string; partials: string; mediaPath: string }[]
371
422
  > {
372
423
  const byLocale = new Map<
373
424
  string | undefined,
374
- { repo?: string; path: string; partials: string }[]
425
+ { repo?: string; path: string; partials: string; mediaPath: string }[]
375
426
  >();
376
427
  const seen = new Set<string>();
377
428
 
@@ -389,7 +440,17 @@ function partialFolders(
389
440
  seen.add(claim);
390
441
 
391
442
  const list = byLocale.get(key) ?? [];
392
- list.push({ repo: effective.repo, path: effective.path, partials });
443
+ const base = source.path ?? 'docs';
444
+ const mediaPath =
445
+ effective.path === base || effective.path.startsWith(`${base}/`)
446
+ ? base
447
+ : effective.path;
448
+ list.push({
449
+ repo: effective.repo,
450
+ path: effective.path,
451
+ partials,
452
+ mediaPath
453
+ });
393
454
  byLocale.set(key, list);
394
455
  });
395
456
 
@@ -411,7 +472,12 @@ function partialFolders(
411
472
  * than resolves; see `modules/validate.ts`.
412
473
  */
413
474
  function definePartials(
414
- folders: { repo?: string; path: string; partials: string }[]
475
+ folders: {
476
+ repo?: string;
477
+ path: string;
478
+ partials: string;
479
+ mediaPath: string;
480
+ }[]
415
481
  ) {
416
482
  const entries = folders.map((folder) =>
417
483
  folder.repo
@@ -438,6 +504,16 @@ function definePartials(
438
504
  });
439
505
 
440
506
  containedKeys(collection);
507
+ for (const [index, contentSource] of (collection.source ?? []).entries()) {
508
+ const folder = folders[index]!;
509
+ // Partials stay shared across versions and read the repository's default ref.
510
+ const identity = JSON.stringify([
511
+ folder.repo ?? repositoryRoot(),
512
+ folder.mediaPath,
513
+ null
514
+ ]);
515
+ prepareMedia(contentSource, folder.repo, folder.mediaPath, identity);
516
+ }
441
517
 
442
518
  return collection;
443
519
  }
@@ -0,0 +1,50 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import type { Nuxt } from '@nuxt/schema';
3
+ import type { ModuleOptions } from '@nuxt/image';
4
+ import {
5
+ documentationMediaDirectory,
6
+ rewriteDocumentationImages
7
+ } from '../build/sources/documentation-media';
8
+ import type { DocumentationMedia } from '../build/sources/documentation-media';
9
+
10
+ interface ParseContext {
11
+ file?: { path?: string };
12
+ content?: Record<string, unknown>;
13
+ collection?: { source?: { duxtMedia?: DocumentationMedia }[] };
14
+ }
15
+
16
+ export default function duxtDocumentationMedia(_options: unknown, nuxt: Nuxt) {
17
+ const directory = documentationMediaDirectory(nuxt.options.rootDir);
18
+ // Rebuild the generated output even when Content reuses every parsed page.
19
+ // Removed source assets must not survive a later build of the same ref.
20
+ rmSync(directory, { recursive: true, force: true });
21
+ mkdirSync(directory, { recursive: true });
22
+ const image = nuxt.options.image as
23
+ | Partial<ModuleOptions>
24
+ | false
25
+ | undefined;
26
+ if (image !== false) {
27
+ const options = image ?? {};
28
+ options.dirs = [...(options.dirs ?? []), directory];
29
+ nuxt.options.image = options as ModuleOptions;
30
+ }
31
+
32
+ nuxt.hook('nitro:config', (nitro) => {
33
+ nitro.publicAssets ??= [];
34
+ if (!nitro.publicAssets.some((asset) => asset?.dir === directory)) {
35
+ nitro.publicAssets.push({ dir: directory, maxAge: 0 });
36
+ }
37
+ });
38
+
39
+ nuxt.hook(
40
+ 'content:file:afterParse' as never,
41
+ ((context: ParseContext) => {
42
+ const file = context.file?.path;
43
+ if (!file || !context.content) return;
44
+ for (const source of context.collection?.source ?? []) {
45
+ const media = source.duxtMedia;
46
+ if (media) rewriteDocumentationImages(context.content, file, media);
47
+ }
48
+ }) as never
49
+ );
50
+ }
package/nuxt.config.ts CHANGED
@@ -252,6 +252,9 @@ export default defineNuxtConfig({
252
252
  // history at parse time.
253
253
  layer('./modules/git-meta.ts'),
254
254
 
255
+ // Mount documentation media before @nuxt/image discovers its input dirs.
256
+ layer('./modules/documentation-media.ts'),
257
+
255
258
  // `redirectFrom:` in a page's frontmatter, under every prefix the site
256
259
  // serves that page at.
257
260
  layer('./modules/redirects.ts'),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "@kirchdev/duxt",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "description": "duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.",
6
6
  "keywords": [
7
7
  "content-v3",
@@ -75,10 +75,10 @@
75
75
  "@codemirror/state": "^6.7.6",
76
76
  "@codemirror/view": "^6.43.14",
77
77
  "@iconify-json/flag": "^1.2.12",
78
- "@iconify-json/lucide": "^1.2.141",
78
+ "@iconify-json/lucide": "^1.2.142",
79
79
  "@iconify-json/simple-icons": "^1.2.99",
80
80
  "@lezer/highlight": "^1.2.5",
81
- "@lucide/vue": "^1.53.0",
81
+ "@lucide/vue": "^1.54.0",
82
82
  "@nuxt/content": "^3.16.1",
83
83
  "@nuxt/icon": "^2.5.1",
84
84
  "@nuxt/image": "^2.1.0",
@@ -98,7 +98,7 @@
98
98
  "jiti": "^2.7.0",
99
99
  "mermaid": "^12.1.0",
100
100
  "reka-ui": "^2.11.0",
101
- "satori": "^0.44.0",
101
+ "satori": "^0.47.1",
102
102
  "shadcn-nuxt": "^2.8.2",
103
103
  "shiki": "^4.5.0",
104
104
  "tailwind-merge": "^3.7.0",
@@ -110,7 +110,7 @@
110
110
  },
111
111
  "devDependencies": {
112
112
  "@types/jsdom": "^30.0.0",
113
- "@types/node": "^26.6.4",
113
+ "@types/node": "^26.6.5",
114
114
  "jsdom": "^30.1.2",
115
115
  "nuxt": "^4.6.0",
116
116
  "typescript": "^6.0.3",