@lupinum/ginko-content 0.1.0 → 0.1.2

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 (42) hide show
  1. package/README.md +93 -47
  2. package/compatibility.json +5 -5
  3. package/dist/cli.mjs +184 -29
  4. package/dist/core/references/schema.d.ts +1 -0
  5. package/dist/core/references/schema.js +33 -10
  6. package/dist/integrations/nitro/context.d.ts +4 -8
  7. package/dist/module.d.mts +90 -30
  8. package/dist/module.json +1 -1
  9. package/dist/module.mjs +241 -41
  10. package/dist/public/client.d.ts +4 -2
  11. package/dist/public/client.js +2 -0
  12. package/dist/runtime/app/components/ContentRenderer.vue +9 -5
  13. package/dist/runtime/app/components/internal/ContentRendererMarkdown.d.vue.ts +1 -1
  14. package/dist/runtime/app/components/internal/ContentRendererMarkdown.vue.d.ts +1 -1
  15. package/dist/runtime/app/composables/head.d.ts +27 -3
  16. package/dist/runtime/app/composables/head.js +82 -78
  17. package/dist/runtime/app/composables/query-api.d.ts +9 -10
  18. package/dist/runtime/app/composables/search.d.ts +4 -4
  19. package/dist/runtime/app/composables/search.js +11 -5
  20. package/dist/runtime/app/composables/use-content.d.ts +35 -15
  21. package/dist/runtime/app/composables/use-content.js +83 -13
  22. package/dist/runtime/query/unified.d.ts +1 -0
  23. package/dist/runtime/query/unified.js +93 -6
  24. package/dist/runtime/server/api/search.d.ts +2 -1
  25. package/dist/runtime/server/api/search.js +6 -2
  26. package/dist/runtime/server/pagefind.js +1 -0
  27. package/dist/runtime/server/plugins/sitemap.js +4 -4
  28. package/dist/runtime/server/search.d.ts +11 -0
  29. package/dist/runtime/server/search.js +24 -7
  30. package/dist/runtime/shared/search.js +3 -2
  31. package/dist/runtime/utils/sitemap-source +1 -0
  32. package/dist/runtime/utils/sitemap-source.d.ts +2 -0
  33. package/dist/runtime/utils/sitemap-source.js +6 -0
  34. package/dist/testing/provider-fixture.js +1 -0
  35. package/dist/types/config.d.ts +42 -20
  36. package/dist/types/config.js +26 -2
  37. package/dist/types/module.d.ts +39 -6
  38. package/dist/types/query.d.ts +20 -11
  39. package/dist/types/search.d.ts +3 -1
  40. package/dist/types.d.mts +2 -2
  41. package/dist/web-types.json +1 -1
  42. package/package.json +4 -4
package/README.md CHANGED
@@ -1,61 +1,80 @@
1
1
  # @lupinum/ginko-content
2
2
 
3
- Filesystem-first, provider-neutral content for Nuxt 4.
3
+ Filesystem-first content for Nuxt 4.
4
4
 
5
- `@lupinum/ginko-content` gives you:
5
+ Write Markdown and data files in `content/`, define collections once in
6
+ `content.config.ts`, then use those collection handles for route pages, lists,
7
+ navigation, search, i18n, and sitemap output.
6
8
 
7
- - file-authored pages and navigation from `content/`
8
- - required typed collections via `content.config.ts`
9
- - Markdown, MDC, YAML, JSON, and CSV ingestion
10
- - locale-aware content routing
11
- - built-in search endpoints and first-class search composables
12
- - a server-side provider contract for advanced custom sources
9
+ Use it when you want content files to stay simple, but your Nuxt app still
10
+ needs explicit APIs for route resolution, typed frontmatter, localized content,
11
+ and server-side reads.
12
+
13
+ ## Requirements
14
+
15
+ - Node.js 20 or later
16
+ - Nuxt 4.0 or later
13
17
 
14
18
  ## Install
15
19
 
16
20
  ```bash
17
- pnpm add @lupinum/ginko-content zod
21
+ npx nuxi module add @lupinum/ginko-content
18
22
  ```
19
23
 
20
- Add the module to your `nuxt.config.ts`:
24
+ The Nuxt CLI installs the package and registers the module in `nuxt.config.ts`.
25
+ If you prefer to install by hand:
26
+
27
+ ```bash
28
+ pnpm add @lupinum/ginko-content
29
+ ```
21
30
 
22
31
  ```ts
23
32
  export default defineNuxtConfig({
24
- modules: ['@lupinum/ginko-content']
33
+ modules: ['@lupinum/ginko-content'],
34
+ imports: {
35
+ autoImport: true
36
+ }
25
37
  })
26
38
  ```
27
39
 
28
- Ginko is published as `@lupinum/ginko-content` and should be registered under that package name.
40
+ ## Quick Start
29
41
 
30
- The default provider reads files from your Nuxt project. The package does not include a CMS UI, Studio, admin panel, or content editing workflow.
42
+ Define a collection:
31
43
 
32
- ## Compatibility
44
+ ```ts
45
+ // content.config.ts
46
+ import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'
33
47
 
34
- `@lupinum/ginko-content@2.13.4` targets Nuxt `>=4.0.0` and is verified against
35
- Nuxt `4.4.6`. It is compatible with the first Ginko CMS release line:
36
- `@lupinum/ginko-cms@0.1.0`, `@lupinum/ginko-cms-convex@0.1.0`, and
37
- `@lupinum/ginko-cms-contract@0.1.0`.
48
+ export const pages = defineCollection({
49
+ type: 'page',
50
+ source: '**/*.md'
51
+ })
52
+
53
+ export default defineContentConfig({
54
+ collections: {
55
+ pages
56
+ }
57
+ })
58
+ ```
38
59
 
39
- The CMS boundary uses `./cms-contract` and `./cms-import`. Those subpaths are
40
- runtime-neutral seams for package integration, not a bundled CMS runtime.
60
+ Create `content/index.md`:
41
61
 
42
- ## Basic usage
62
+ ```md
63
+ ---
64
+ title: Welcome
65
+ ---
43
66
 
44
- Create content files:
67
+ # Welcome
45
68
 
46
- ```text
47
- content/
48
- index.md
49
- guide/getting-started.md
69
+ This file renders at `/`.
50
70
  ```
51
71
 
52
- Render content in a catch-all page:
72
+ Render the current route through the collection:
53
73
 
54
74
  ```vue
75
+ <!-- pages/[...slug].vue -->
55
76
  <script setup lang="ts">
56
- import { pages } from '~/content.config'
57
-
58
- const { page } = await useContentPage(pages)
77
+ const { page } = await useContentPage('pages')
59
78
  </script>
60
79
 
61
80
  <template>
@@ -63,28 +82,55 @@ const { page } = await useContentPage(pages)
63
82
  </template>
64
83
  ```
65
84
 
66
- Define the collection in `content.config.ts`:
85
+ ## Features
67
86
 
68
- ```ts
69
- import { defineCollection, defineContentConfig, fields } from '@lupinum/ginko-content/config'
70
- import { z } from 'zod'
87
+ - file-authored pages and navigation from `content/`
88
+ - collection definitions as the source of truth for content shape and source
89
+ files
90
+ - Markdown, Comark component tags, YAML, JSON, and CSV ingestion
91
+ - locale-aware content routing
92
+ - route-aware page loading with `useContentPage('pages')`
93
+ - semantic previous/next route-page data through `useContentPage('pages', { surround })`
94
+ - server reads through `one`, `many`, `paginate`, `resolveOne`, `tree`, and
95
+ `neighbors`
96
+ - Vue composables for the same read model
97
+ - search helpers for MiniSearch, Pagefind, and provider-owned search
98
+ - sitemap integration for public content routes
99
+ - a server-side provider contract for advanced custom sources
71
100
 
72
- export const pages = defineCollection('pages', {
73
- type: 'page',
74
- source: ['index.md', 'guide/**/*.md'],
75
- schema: z.object({
76
- title: fields.text()
77
- })
78
- })
101
+ ## I18n, Sitemap, and Prerender Ownership
79
102
 
80
- export default defineContentConfig({
81
- collections: {
82
- pages
83
- }
84
- })
85
- ```
103
+ For localized Nuxt apps, keep one source of truth per route type:
104
+
105
+ - Static Nuxt page paths belong in Nuxt I18n `i18n.pages`.
106
+ - Content page paths belong in Ginko collection routes and content files.
107
+ - Sitemap XML is generated by `@nuxtjs/sitemap`.
108
+ - Ginko registers the content sitemap source and contributes content prerender
109
+ routes.
110
+
111
+ Do not duplicate docs, blog, pricing, privacy, or translated locale paths in
112
+ `sitemap.urls` or app-owned `nitro.prerender.routes` arrays. Use
113
+ `@nuxtjs/sitemap >= 8.0.15` when translated static app slugs such as `/preise`
114
+ and `/en/pricing` need cross-locale sitemap alternates.
115
+
116
+ See the public guide: [Sitemap and prerender](../../docs/content/docs/6.i18n/4.sitemap-prerender.md).
117
+
118
+ ## Scope
119
+
120
+ The default provider reads files from your Nuxt project. The package does not
121
+ include a CMS UI, Studio, admin panel, or content editing workflow.
86
122
 
87
123
  ## Docs
88
124
 
89
125
  - Documentation: [ginko-content.nuxt.dev](https://ginko-content.nuxt.dev)
90
126
  - Repository: [github.com/lupinum-dev/ginko-content](https://github.com/lupinum-dev/ginko-content)
127
+
128
+ ## Credits
129
+
130
+ Ginko Content is its own implementation, with clear inspiration from
131
+ [Nuxt Content](https://content.nuxt.com/), [Nuxt UI](https://ui.nuxt.com/), and
132
+ [Comark](https://comark.dev/), the successor to the previous MDC work.
133
+
134
+ ## License
135
+
136
+ [MIT](./LICENSE)
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "Ginko Content release compatibility",
3
3
  "releaseStack": {
4
- "@lupinum/ginko-content": "0.1.0",
5
- "@lupinum/ginko-cms": "0.1.0",
6
- "@lupinum/ginko-cms-convex": "0.1.0",
7
- "@lupinum/ginko-cms-contract": "0.1.0"
4
+ "@lupinum/ginko-content": "0.1.2",
5
+ "@lupinum/ginko-cms": "0.1.2",
6
+ "@lupinum/ginko-cms-convex": "0.1.1",
7
+ "@lupinum/ginko-cms-contract": "0.1.1"
8
8
  },
9
9
  "tracked": {
10
10
  "@convex-dev/better-auth": ["^0.12.2", "0.12.2"],
@@ -13,7 +13,7 @@
13
13
  "convex": ["1.38.0", "^1.38.0"],
14
14
  "convex-helpers": ["^0.1.117", "0.1.117"],
15
15
  "h3": ["1.15.11"],
16
- "nuxt": ["^4.4.6", "4.4.6", ">=4.0.0"],
16
+ "nuxt": ["^4.4.7", "4.4.7", ">=4.4.7"],
17
17
  "typescript": ["~6.0.3", "^6.0.3", "6.0.3"],
18
18
  "vite": ["^7.3.3", "7.3.3"],
19
19
  "vitest": ["^4.1.6", ">=4.1.6"],
package/dist/cli.mjs CHANGED
@@ -42,32 +42,27 @@ const sourceChecks = [
42
42
  {
43
43
  pattern: /\bqueryCollectionItemSurroundings\s*\(/,
44
44
  message: "Nuxt Content v3 surround helper found.",
45
- suggestion: "Use useContentNeighbors(handle, { by: { route } }) in route page components."
45
+ suggestion: "Use useContentPage('docs', { surround: true }) in route page components."
46
46
  },
47
47
  {
48
48
  pattern: /\bqueryCollectionSearchSections\s*\(/,
49
49
  message: "Nuxt Content v3 search sections helper found.",
50
- suggestion: "Use useContentSearchData(collection) for UI search data."
50
+ suggestion: "Use useContentSearchData('docs') for UI search data."
51
51
  },
52
52
  {
53
53
  pattern: /\bqueryCollectionNavigation\s*\(/,
54
54
  message: "Nuxt Content v3 navigation helper found.",
55
- suggestion: "Use useContentTree(handle) for layout navigation."
55
+ suggestion: "Use useContentNavigation('docs') for layout navigation."
56
56
  },
57
57
  {
58
58
  pattern: /\bqueryCollection\s*\(/,
59
59
  message: "Removed collection query helper found.",
60
- suggestion: "Use one(handle, options), many(handle, options), paginate(handle, options), or the matching useContent* composable."
60
+ suggestion: "Use one('docs', options), many('docs', options), paginate('docs', options), or the matching useContent* composable."
61
61
  },
62
62
  {
63
63
  pattern: /\buseContentList\s*\(/,
64
64
  message: "Removed content list composable found.",
65
- suggestion: "Use useContentMany(handle, options) or many(handle, options)."
66
- },
67
- {
68
- pattern: /\buseContentNavigation\s*\(/,
69
- message: "Removed content navigation composable found.",
70
- suggestion: "Use useContentTree(handle) for layout navigation."
65
+ suggestion: "Use useContentMany('docs', options) or many('docs', options)."
71
66
  },
72
67
  {
73
68
  pattern: /\bcontent\.(database|preview|build)\b/,
@@ -84,21 +79,158 @@ const sourceChecks = [
84
79
  message: "ContentRenderer is receiving a document body.",
85
80
  suggestion: "Pass the full content document to ContentRenderer, not document.body."
86
81
  },
87
- {
88
- pattern: /<NuxtLink\b[^>]*:to\s*=\s*["'][^"']+\.path["']/,
89
- message: "NuxtLink may be using raw query .path.",
90
- suggestion: "Use useContentMany(handle, options) for route-safe list items, or link route-page payloads with their explicit path field."
91
- },
92
82
  {
93
83
  pattern: /<NuxtLink\b[^>]*:to\s*=\s*["'][^"']+\._path["']/,
94
84
  message: "NuxtLink is using raw content _path.",
95
- suggestion: "Use useContentMany(handle, options) for list pages and bind the route-safe item.path field."
85
+ suggestion: "Use useContentMany('docs', options) for list pages and bind the route-safe item.path field."
96
86
  }
97
87
  ];
98
88
  const lockfileNames = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"];
99
89
  const localeCodePattern = /^[a-z]{2}(?:-[A-Z]{2})?$/;
100
90
  const toRelativePath = (rootDir, file) => relative(rootDir, file) || ".";
101
91
  const countSitemapUrls = (text) => (text.match(/<url>/g) || []).length;
92
+ function findMatchingBrace(text, start) {
93
+ let depth = 0;
94
+ let quote;
95
+ let escaped = false;
96
+ let lineComment = false;
97
+ let blockComment = false;
98
+ for (let index = start; index < text.length; index++) {
99
+ const char = text[index];
100
+ const next = text[index + 1];
101
+ if (lineComment) {
102
+ if (char === "\n") {
103
+ lineComment = false;
104
+ }
105
+ continue;
106
+ }
107
+ if (blockComment) {
108
+ if (char === "*" && next === "/") {
109
+ blockComment = false;
110
+ index++;
111
+ }
112
+ continue;
113
+ }
114
+ if (quote) {
115
+ if (escaped) {
116
+ escaped = false;
117
+ } else if (char === "\\") {
118
+ escaped = true;
119
+ } else if (char === quote) {
120
+ quote = void 0;
121
+ }
122
+ continue;
123
+ }
124
+ if (char === "/" && next === "/") {
125
+ lineComment = true;
126
+ index++;
127
+ continue;
128
+ }
129
+ if (char === "/" && next === "*") {
130
+ blockComment = true;
131
+ index++;
132
+ continue;
133
+ }
134
+ if (char === "'" || char === '"' || char === "`") {
135
+ quote = char;
136
+ continue;
137
+ }
138
+ if (char === "{") {
139
+ depth++;
140
+ } else if (char === "}") {
141
+ depth--;
142
+ if (depth === 0) {
143
+ return index;
144
+ }
145
+ }
146
+ }
147
+ return text.length - 1;
148
+ }
149
+ function findAuthoredCollectionDefinitions(text) {
150
+ const definitions = /* @__PURE__ */ new Map();
151
+ const callPattern = /\bdefineCollection\s*\(/g;
152
+ for (const match of text.matchAll(callPattern)) {
153
+ const callStart = match.index || 0;
154
+ const argsStart = callStart + match[0].length;
155
+ const args = text.slice(argsStart);
156
+ const objectMatch = args.match(/^\s*\{/);
157
+ if (!objectMatch) {
158
+ continue;
159
+ }
160
+ const bodyStart = argsStart + objectMatch[0].lastIndexOf("{");
161
+ const bodyEnd = findMatchingBrace(text, bodyStart);
162
+ const block = text.slice(bodyStart, bodyEnd + 1);
163
+ const prefix = text.slice(0, callStart);
164
+ const propertyMatch = prefix.match(/([a-z_$][\w$]*)\s*:\s*$/i);
165
+ if (propertyMatch) {
166
+ definitions.set(propertyMatch[1], {
167
+ key: propertyMatch[1],
168
+ block
169
+ });
170
+ continue;
171
+ }
172
+ const variableMatch = prefix.match(/(?:^|[\s;])(?:export\s+)?(?:const|let|var)\s+([a-z_$][\w$]*)\s*=\s*$/i);
173
+ if (variableMatch) {
174
+ definitions.set(variableMatch[1], { block });
175
+ }
176
+ }
177
+ return definitions;
178
+ }
179
+ function findCollectionDefinitions(text) {
180
+ const authoredCollections = findAuthoredCollectionDefinitions(text);
181
+ const definitions = /* @__PURE__ */ new Map();
182
+ for (const block of findObjectPropertyBlocks(text, "collections")) {
183
+ for (const match of block.matchAll(/([a-z_$][\w$]*)\s*:\s*([a-z_$][\w$]*)\b/g)) {
184
+ const [, key, identifier] = match;
185
+ const authored = authoredCollections.get(identifier);
186
+ if (authored) {
187
+ definitions.set(key, {
188
+ name: key,
189
+ block: authored.block
190
+ });
191
+ }
192
+ }
193
+ for (const match of block.matchAll(/(?:^|[,{]\s*)([a-z_$][\w$]*)\s*(?=,|\})/g)) {
194
+ const key = match[1];
195
+ const authored = authoredCollections.get(key);
196
+ if (authored) {
197
+ definitions.set(key, {
198
+ name: key,
199
+ block: authored.block
200
+ });
201
+ }
202
+ }
203
+ }
204
+ for (const authored of authoredCollections.values()) {
205
+ if (authored.key) {
206
+ definitions.set(authored.key, {
207
+ name: authored.key,
208
+ block: authored.block
209
+ });
210
+ }
211
+ }
212
+ return [...definitions.values()];
213
+ }
214
+ function findObjectPropertyBlocks(text, property) {
215
+ const blocks = [];
216
+ const pattern = new RegExp(`\\b${property}\\s*:\\s*\\{`, "g");
217
+ for (const match of text.matchAll(pattern)) {
218
+ const bodyStart = text.indexOf("{", match.index);
219
+ if (bodyStart === -1) {
220
+ continue;
221
+ }
222
+ const bodyEnd = findMatchingBrace(text, bodyStart);
223
+ blocks.push(text.slice(bodyStart, bodyEnd + 1));
224
+ }
225
+ return blocks;
226
+ }
227
+ function extractStringArrayProperty(block, property) {
228
+ const match = block.match(new RegExp(`\\b${property}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
229
+ if (!match) {
230
+ return [];
231
+ }
232
+ return [...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((item) => item[1]);
233
+ }
102
234
  async function collectFiles(dir, rootDir, files = []) {
103
235
  const entries = await readdir(dir, { withFileTypes: true });
104
236
  for (const entry of entries) {
@@ -174,6 +306,32 @@ async function inspectSourceFiles(rootDir) {
174
306
  }
175
307
  return findings;
176
308
  }
309
+ async function inspectSearchCollections(rootDir) {
310
+ const contentConfig = await readTextIfPresent(join(rootDir, "content.config.ts"));
311
+ const nuxtConfig = await readTextIfPresent(join(rootDir, "nuxt.config.ts"));
312
+ if (!contentConfig || !nuxtConfig) {
313
+ return [];
314
+ }
315
+ const dataCollections = new Set(
316
+ findCollectionDefinitions(contentConfig).filter((collection) => /\btype\s*:\s*['"]data['"]/.test(collection.block)).map((collection) => collection.name)
317
+ );
318
+ if (!dataCollections.size) {
319
+ return [];
320
+ }
321
+ const configuredCollections = new Set(
322
+ findObjectPropertyBlocks(nuxtConfig, "search").flatMap((block) => extractStringArrayProperty(block, "collections"))
323
+ );
324
+ const dataSearchCollections = [...configuredCollections].filter((collection) => dataCollections.has(collection));
325
+ if (!dataSearchCollections.length) {
326
+ return [];
327
+ }
328
+ return [{
329
+ severity: "info",
330
+ file: "nuxt.config.ts",
331
+ message: `Data-only collections listed in content.search.collections: ${dataSearchCollections.join(", ")}.`,
332
+ suggestion: "Remove data-only collections from the static public search index, make them route-backed pages, or use provider-backed search with route-safe results."
333
+ }];
334
+ }
177
335
  async function inspectLockfiles(rootDir) {
178
336
  const findings = [];
179
337
  for (const lockfileName of lockfileNames) {
@@ -372,25 +530,21 @@ async function inspectI18nCollections(rootDir) {
372
530
  suggestion: "Declare i18n collections with defineContentConfig({ collections })."
373
531
  }];
374
532
  }
375
- const matches = [...text.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*defineCollection\s*\(\s*\{/g)];
376
- if (!matches.length) {
533
+ const collections = findCollectionDefinitions(text);
534
+ if (!collections.length) {
377
535
  return [];
378
536
  }
379
537
  const findings = [];
380
- matches.forEach((match, index) => {
381
- const collectionName = match[1];
382
- const start = match.index || 0;
383
- const end = matches[index + 1]?.index || text.length;
384
- const collectionBlock = text.slice(start, end);
385
- if (!/\bi18n\s*:\s*(true|\{)/.test(collectionBlock)) {
538
+ for (const collection of collections) {
539
+ if (!/\bi18n\s*:\s*(true|\{)/.test(collection.block)) {
386
540
  findings.push({
387
541
  severity: "error",
388
542
  file: "content.config.ts",
389
- message: `Collection "${collectionName}" is not marked as i18n-aware.`,
390
- suggestion: `Add i18n: true to the ${collectionName} collection or provide collection-level i18n locales.`
543
+ message: `Collection "${collection.name}" is not marked as i18n-aware.`,
544
+ suggestion: `Add i18n: true to the ${collection.name} collection or provide collection-level i18n locales.`
391
545
  });
392
546
  }
393
- });
547
+ }
394
548
  return findings;
395
549
  }
396
550
  async function inspectI18nContentFolders(rootDir, locales) {
@@ -427,7 +581,7 @@ async function inspectI18nSourceSmells(rootDir, locales) {
427
581
  severity: "error",
428
582
  file: relativeFile,
429
583
  message: "Hardcoded locale route branch found.",
430
- suggestion: "Use @nuxtjs/i18n route helpers, useContentTree(handle), or useContentMany(handle, options) item paths instead of branching on locale codes."
584
+ suggestion: "Use @nuxtjs/i18n route helpers, useContentNavigation('docs'), or useContentMany('docs', options) item paths instead of branching on locale codes."
431
585
  });
432
586
  }
433
587
  if (/<(?:NuxtLink|U[A-Za-z]+)\b[^>]*:to\s*=\s*["'][^"']+\._path["']/.test(text)) {
@@ -435,7 +589,7 @@ async function inspectI18nSourceSmells(rootDir, locales) {
435
589
  severity: "error",
436
590
  file: relativeFile,
437
591
  message: "UI link is bound to raw content _path.",
438
- suggestion: "Use useContentMany(handle, options) for localized list pages and bind item.path."
592
+ suggestion: "Use useContentMany('docs', options) for localized list pages and bind item.path."
439
593
  });
440
594
  }
441
595
  }
@@ -620,6 +774,7 @@ async function runDoctor(options = {}) {
620
774
  const findings = [
621
775
  ...await inspectPackageJson(rootDir),
622
776
  ...await inspectSourceFiles(rootDir),
777
+ ...await inspectSearchCollections(rootDir),
623
778
  ...await inspectLockfiles(rootDir),
624
779
  ...await inspectSitemap(rootDir),
625
780
  ...options.i18n ? await inspectI18n(rootDir) : []
@@ -5,4 +5,5 @@ export declare const getObjectShape: (schema: any) => Record<string, any>;
5
5
  export declare const getReferenceDescriptor: (schema: any) => {
6
6
  collection?: string;
7
7
  } | null;
8
+ export declare const collectTopLevelReferenceFieldsByTarget: (schema: unknown) => Record<string, string[]>;
8
9
  export declare const collectTopLevelReferenceFields: (schema: unknown, targetCollection?: string) => string[];
@@ -53,18 +53,41 @@ const getArrayElement = (schema) => {
53
53
  const def = getSchemaDef(current);
54
54
  return def.element || def.type || null;
55
55
  };
56
- export const collectTopLevelReferenceFields = (schema, targetCollection) => {
56
+ const wildcardReferenceTarget = "*";
57
+ const getTopLevelReferenceDescriptor = (schema) => {
58
+ const direct = getReferenceDescriptor(schema);
59
+ if (direct) {
60
+ return direct;
61
+ }
62
+ const arrayElement = getArrayElement(schema);
63
+ return arrayElement ? getReferenceDescriptor(arrayElement) : null;
64
+ };
65
+ export const collectTopLevelReferenceFieldsByTarget = (schema) => {
57
66
  const current = unwrapSchema(schema);
58
67
  if (getSchemaTypeName(current) !== "ZodObject") {
59
- return [];
68
+ return {};
60
69
  }
61
- return Object.entries(getObjectShape(current)).filter(([, childSchema]) => {
62
- const direct = getReferenceDescriptor(childSchema);
63
- if (direct) {
64
- return !direct.collection || !targetCollection || direct.collection === targetCollection;
70
+ const references = {};
71
+ for (const [field, childSchema] of Object.entries(getObjectShape(current))) {
72
+ const descriptor = getTopLevelReferenceDescriptor(childSchema);
73
+ if (!descriptor) {
74
+ continue;
65
75
  }
66
- const arrayElement = getArrayElement(childSchema);
67
- const arrayRef = arrayElement ? getReferenceDescriptor(arrayElement) : null;
68
- return Boolean(arrayRef && (!arrayRef.collection || !targetCollection || arrayRef.collection === targetCollection));
69
- }).map(([field]) => field);
76
+ const target = descriptor.collection || wildcardReferenceTarget;
77
+ references[target] ||= [];
78
+ references[target].push(field);
79
+ }
80
+ return references;
81
+ };
82
+ export const collectTopLevelReferenceFields = (schema, targetCollection) => {
83
+ const references = collectTopLevelReferenceFieldsByTarget(schema);
84
+ if (!targetCollection) {
85
+ return [...new Set(Object.values(references).flat())];
86
+ }
87
+ return [
88
+ .../* @__PURE__ */ new Set([
89
+ ...references[targetCollection] || [],
90
+ ...references[wildcardReferenceTarget] || []
91
+ ])
92
+ ];
70
93
  };
@@ -13,14 +13,13 @@
13
13
  *
14
14
  * The context is attached to `event.context.__contentRuntime` and created
15
15
  * lazily on first access. Memoization uses `memoizeRuntimeValue` so the
16
- * `graph` / `contents` caches are shared across helpers within a single
16
+ * expensive per-request values are shared across helpers within a single
17
17
  * request but torn down with the event.
18
18
  */
19
19
  import type { H3Event } from 'h3';
20
20
  import type { Storage } from 'unstorage';
21
21
  import type { ParsedContent } from '../../types/content';
22
22
  import type { ContentContext as RuntimeContentConfig } from '../../types/module';
23
- import type { ContentGraph } from '../../core/content/graph';
24
23
  import type { ContentCacheStore } from '../../core/cache';
25
24
  import type { ContentCacheHint } from '../../public/provider';
26
25
  /**
@@ -41,16 +40,13 @@ export interface ContentRuntimeContext {
41
40
  cacheStorage: Storage;
42
41
  cacheParsedStorage: Storage;
43
42
  };
44
- memo: {
45
- contents?: ParsedContent[] | Promise<ParsedContent[]>;
46
- graph?: ContentGraph | Promise<ContentGraph>;
47
- };
43
+ memo: Record<string, unknown | Promise<unknown> | undefined>;
48
44
  caches?: ContentCacheStore<ParsedContent>;
49
45
  cacheHint?: ContentCacheHint | false;
50
46
  }
51
47
  export declare const getContentRuntimeContext: (event: H3Event) => ContentRuntimeContext;
52
48
  /**
53
- * Memoize an expensive per-request value (typically `contents` or `graph`).
49
+ * Memoize an expensive per-request value.
54
50
  *
55
51
  * Concurrent callers within a single request that hit the same key all await
56
52
  * the one `create()` promise — we do not start a second compute. The cache
@@ -60,4 +56,4 @@ export declare const getContentRuntimeContext: (event: H3Event) => ContentRuntim
60
56
  * you cannot use this helper — mutate `runtime.memo[key]` directly. In
61
57
  * practice, nothing we build mid-request invalidates the graph.
62
58
  */
63
- export declare const memoizeRuntimeValue: <T>(event: H3Event, key: keyof ContentRuntimeContext["memo"], create: () => Promise<T>) => Promise<T>;
59
+ export declare const memoizeRuntimeValue: <T>(event: H3Event, key: string, create: () => Promise<T>) => Promise<T>;