@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/dist/module.mjs CHANGED
@@ -6,6 +6,8 @@ import { defu } from 'defu';
6
6
  import { hash } from 'ohash';
7
7
  import { resolve, join, relative, isAbsolute } from 'pathe';
8
8
  import { withTrailingSlash } from 'ufo';
9
+ import { normalizeContentConfigCollectionNames } from '../dist/types/config';
10
+ export { defineCollection, defineContentConfig, reference } from '../dist/types/config';
9
11
  import 'unstorage/drivers/fs';
10
12
  import 'unstorage/drivers/http';
11
13
  import 'unstorage/drivers/github';
@@ -18,6 +20,7 @@ import { genSafeVariableName, genImport } from 'knitwork';
18
20
  import { listen } from 'listhen';
19
21
  import { makeIgnored } from '../dist/core/content/ignore';
20
22
  import { readFile, readdir } from 'node:fs/promises';
23
+ import { resolveContentSitemapSource, GINKO_SITEMAP_SOURCE_NAME } from '../dist/runtime/utils/sitemap-source';
21
24
  import { resolveCollectionI18nConfig } from '../dist/features/localization/config';
22
25
  import { globby } from 'globby';
23
26
  import { transformContent } from '../dist/parsers/index';
@@ -26,10 +29,11 @@ import { buildContentGraph } from '../dist/core/content/graph';
26
29
  import { normalizeCollectionSources, normalizeCollectionExcludes } from '../dist/core/content/sources';
27
30
  import { resolveCollectionI18n, prefixPathWithLocale } from '../dist/features/localization/path';
28
31
  import { resolveCollection } from '../dist/core/content/collection';
29
- export { defineCollection, defineContentConfig, reference } from '../dist/types/config';
32
+ import { normalizeRouteMounts, normalizeContentPath } from '../dist/core/content/path';
33
+ import { collectTopLevelReferenceFieldsByTarget } from '../dist/core/references/schema';
30
34
 
31
35
  const name = "@lupinum/ginko-content";
32
- const version = "0.1.0";
36
+ const version = "0.1.2";
33
37
 
34
38
  const logger = consola.withTag("@lupinum/ginko-content");
35
39
  const CACHE_VERSION = 3;
@@ -240,16 +244,8 @@ const registerContentDevRuntime = (nuxt, options, contentContext) => {
240
244
 
241
245
  const registerRuntimeImports = (resolveRuntimeModule) => {
242
246
  addImports([
243
- // Unified query API (ADR-0016) — app wrappers bind the Nuxt content context explicitly.
244
- { name: "one", as: "one", from: resolveRuntimeModule("./app/composables/query-api.js") },
245
- { name: "many", as: "many", from: resolveRuntimeModule("./app/composables/query-api.js") },
246
- { name: "paginate", as: "paginate", from: resolveRuntimeModule("./app/composables/query-api.js") },
247
- { name: "backlinks", as: "backlinks", from: resolveRuntimeModule("./app/composables/query-api.js") },
248
- { name: "resolveOne", as: "resolveOne", from: resolveRuntimeModule("./app/composables/query-api.js") },
249
- { name: "variants", as: "variants", from: resolveRuntimeModule("./app/composables/query-api.js") },
250
- { name: "tree", as: "tree", from: resolveRuntimeModule("./app/composables/query-api.js") },
251
- { name: "neighbors", as: "neighbors", from: resolveRuntimeModule("./app/composables/query-api.js") },
252
247
  { name: "getCollectionPath", as: "getCollectionPath", from: resolveRuntimeModule("./query/routes.js") },
248
+ { name: "useContentHead", as: "useContentHead", from: resolveRuntimeModule("./app/composables/head.js") },
253
249
  // Vue composables — reactive wrappers over the same options shape.
254
250
  { name: "useContentPage", as: "useContentPage", from: resolveRuntimeModule("./app/composables/use-content.js") },
255
251
  { name: "useContentOne", as: "useContentOne", from: resolveRuntimeModule("./app/composables/use-content.js") },
@@ -259,9 +255,12 @@ const registerRuntimeImports = (resolveRuntimeModule) => {
259
255
  { name: "useContentResolveOne", as: "useContentResolveOne", from: resolveRuntimeModule("./app/composables/use-content.js") },
260
256
  { name: "useContentVariants", as: "useContentVariants", from: resolveRuntimeModule("./app/composables/use-content.js") },
261
257
  { name: "useContentTree", as: "useContentTree", from: resolveRuntimeModule("./app/composables/use-content.js") },
258
+ { name: "useContentNavigation", as: "useContentNavigation", from: resolveRuntimeModule("./app/composables/use-content.js") },
262
259
  { name: "useContentNeighbors", as: "useContentNeighbors", from: resolveRuntimeModule("./app/composables/use-content.js") },
263
260
  { name: "useContentLocaleSwitch", as: "useContentLocaleSwitch", from: resolveRuntimeModule("./app/composables/use-content.js") },
264
- // Search & site data — kept (out of scope for ADR-0016).
261
+ { name: "useContentSwitchLocalePath", as: "useContentSwitchLocalePath", from: resolveRuntimeModule("./app/composables/route.js") },
262
+ // Search & site data — keep the headless useContentSearch export explicit
263
+ // because Nuxt UI owns the same auto-import name.
265
264
  { name: "useContentSearchData", as: "useContentSearchData", from: resolveRuntimeModule("./app/composables/search.js") },
266
265
  { name: "useContentSearchResults", as: "useContentSearchResults", from: resolveRuntimeModule("./app/composables/search.js") },
267
266
  { name: "querySiteData", as: "querySiteData", from: resolveRuntimeModule("./app/composables/site-data.js") }
@@ -356,25 +355,68 @@ const registerUserContentComponents = async (nuxt, resolve) => {
356
355
  }
357
356
  }
358
357
  };
359
- const registerGeneratedTypes = (contentConfigPath, resolveRuntimeModuleRoot) => {
358
+ const registerGeneratedTypes = (contentConfigPath, resolveRuntimeModuleRoot, collectionNames = [], i18nCollectionNames = []) => {
359
+ const collectionNameUnion = collectionNames.length ? collectionNames.map((name) => JSON.stringify(name)).join(" | ") : "never";
360
+ const i18nCollectionNameUnion = i18nCollectionNames.length ? i18nCollectionNames.map((name) => JSON.stringify(name)).join(" | ") : "never";
361
+ const collectionMapProperties = collectionNames.map((name) => {
362
+ const key = JSON.stringify(name);
363
+ return ` ${key}: StrictParsedContent & __GeneratedCollectionSchema<__ContentCollectionExport<${key}>>`;
364
+ });
360
365
  return addTypeTemplate({
361
366
  filename: "types/content.d.ts",
362
367
  getContents: () => [
363
368
  contentConfigPath ? `import type contentConfig from ${JSON.stringify(contentConfigPath)}` : "type __ContentConfig = { collections?: {} }",
369
+ contentConfigPath ? `import type * as contentConfigModule from ${JSON.stringify(contentConfigPath)}` : "declare const contentConfigModule: {}",
364
370
  "import type { StrictParsedContent } from '@lupinum/ginko-content'",
365
371
  "import type { CollectionSchema } from '@lupinum/ginko-content/config'",
366
372
  contentConfigPath ? "type __ContentConfig = typeof contentConfig" : "",
367
373
  "type __ContentCollections = NonNullable<__ContentConfig['collections']>",
374
+ `type __RuntimeContentCollectionNames = ${collectionNameUnion}`,
375
+ "type __NamedContentCollectionNames = {",
376
+ " [K in keyof typeof contentConfigModule & string]: typeof contentConfigModule[K] extends { name: K } ? K : never",
377
+ "}[keyof typeof contentConfigModule & string]",
378
+ "type __InferredContentCollectionNames = string extends keyof __ContentCollections",
379
+ " ? __NamedContentCollectionNames",
380
+ " : keyof __ContentCollections & string",
381
+ "type __ContentCollectionNames = [__RuntimeContentCollectionNames] extends [never]",
382
+ " ? __InferredContentCollectionNames",
383
+ " : __RuntimeContentCollectionNames",
384
+ "type __ContentCollectionExport<K extends string> = K extends keyof typeof contentConfigModule",
385
+ " ? typeof contentConfigModule[K]",
386
+ " : __ContentCollections[K]",
387
+ "type __GeneratedCollectionSchema<TCollection> = TCollection extends { __schema: infer TSchema }",
388
+ " ? TSchema extends { _output: infer TOutput } ? TOutput : {}",
389
+ " : CollectionSchema<TCollection>",
368
390
  "type __GeneratedContentCollectionMap = {",
369
- " [K in keyof __ContentCollections & string]: StrictParsedContent & CollectionSchema<__ContentCollections[K]>",
391
+ " [K in __ContentCollectionNames]: StrictParsedContent & __GeneratedCollectionSchema<__ContentCollectionExport<K>>",
370
392
  "}",
371
- "type __GeneratedI18nCollectionNames = {",
372
- " [K in keyof __ContentCollections & string]: NonNullable<__ContentCollections[K]['i18n']> extends never ? never : K",
373
- "}[keyof __ContentCollections & string]",
393
+ `type __RuntimeI18nCollectionNames = ${i18nCollectionNameUnion}`,
394
+ "type __InferredI18nCollectionNames = {",
395
+ " [K in __ContentCollectionNames]: __ContentCollectionExport<K> extends { __i18n: true }",
396
+ " ? K",
397
+ " : __ContentCollectionExport<K> extends { i18n: true } ? K : never",
398
+ "}[__ContentCollectionNames]",
399
+ "type __GeneratedI18nCollectionNames = [__RuntimeI18nCollectionNames] extends [never]",
400
+ " ? __InferredI18nCollectionNames",
401
+ " : __RuntimeI18nCollectionNames",
374
402
  "declare module '@lupinum/ginko-content' {",
375
403
  " interface ContentCollectionMap extends __GeneratedContentCollectionMap {}",
376
404
  " interface ContentCollectionI18nMap extends Pick<__GeneratedContentCollectionMap, __GeneratedI18nCollectionNames> {}",
377
405
  "}",
406
+ "declare global {",
407
+ " interface GinkoContentCollectionMap {",
408
+ ...collectionMapProperties,
409
+ " }",
410
+ " interface GinkoContentCollectionI18nMap extends Pick<__GeneratedContentCollectionMap, __GeneratedI18nCollectionNames> {}",
411
+ "}",
412
+ "declare module '@lupinum/ginko-content/dist/types/query' {",
413
+ " interface ContentCollectionMap extends __GeneratedContentCollectionMap {}",
414
+ " interface ContentCollectionI18nMap extends Pick<__GeneratedContentCollectionMap, __GeneratedI18nCollectionNames> {}",
415
+ "}",
416
+ "declare module '@lupinum/ginko-content/dist/types/query.js' {",
417
+ " interface ContentCollectionMap extends __GeneratedContentCollectionMap {}",
418
+ " interface ContentCollectionI18nMap extends Pick<__GeneratedContentCollectionMap, __GeneratedI18nCollectionNames> {}",
419
+ "}",
378
420
  "declare module '#content/server' {",
379
421
  ` const one: typeof import('${resolveRuntimeModuleRoot("./server")}').one`,
380
422
  ` const many: typeof import('${resolveRuntimeModuleRoot("./server")}').many`,
@@ -498,6 +540,23 @@ const toLocalPath = (value) => {
498
540
  return value;
499
541
  }
500
542
  };
543
+ const placeholderHosts = /* @__PURE__ */ new Set([
544
+ "example.com",
545
+ "example.net",
546
+ "example.org",
547
+ "localhost",
548
+ "127.0.0.1",
549
+ "0.0.0.0",
550
+ "::1"
551
+ ]);
552
+ const isPlaceholderHost = (host) => placeholderHosts.has(host) || host.endsWith(".example.com") || host.endsWith(".example.net") || host.endsWith(".example.org") || host.endsWith(".localhost");
553
+ const extractPlaceholderSiteUrls = (locValues) => Array.from(new Set(locValues.filter((value) => {
554
+ try {
555
+ return isPlaceholderHost(new URL(value).hostname);
556
+ } catch {
557
+ return false;
558
+ }
559
+ })));
501
560
  const discoverSitemapsFromDisk = async (outputPublicDir) => {
502
561
  const indexPath = join$1(outputPublicDir, SITEMAP_INDEX);
503
562
  try {
@@ -553,6 +612,9 @@ const normalizeContentSitemapAssertOptions = (options) => ({
553
612
  minUrlsPerSitemap: options?.minUrlsPerSitemap ?? 1,
554
613
  requireImages: options?.requireImages ?? false,
555
614
  requiredCollections: options?.requiredCollections ?? [],
615
+ requiredPaths: options?.requiredPaths ?? [],
616
+ forbiddenPathPrefixes: options?.forbiddenPathPrefixes ?? [],
617
+ requireProductionSiteUrl: options?.requireProductionSiteUrl ?? false,
556
618
  sitemaps: options?.sitemaps ?? {}
557
619
  });
558
620
  const shouldRunSitemapAssertionOnCompiled = (options, nitro) => {
@@ -594,6 +656,29 @@ async function assertGeneratedSitemaps({
594
656
  failures.push(`- ${target.name}: expected image entries but found none`);
595
657
  }
596
658
  }
659
+ const sitemapLocValues = discoveredSitemaps.flatMap((target) => extractLocValues(target.xml));
660
+ const sitemapPaths = Array.from(new Set(sitemapLocValues.map(toLocalPath)));
661
+ const pathSet = new Set(sitemapPaths);
662
+ const missingPaths = options.requiredPaths.filter((path) => !pathSet.has(path));
663
+ if (missingPaths.length) {
664
+ failures.push(`- Missing required sitemap paths: ${missingPaths.join(", ")}`);
665
+ }
666
+ const forbiddenPaths = sitemapPaths.filter(
667
+ (path) => options.forbiddenPathPrefixes.some((prefix) => path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))
668
+ );
669
+ if (forbiddenPaths.length) {
670
+ failures.push(`- Forbidden sitemap paths found: ${forbiddenPaths.join(", ")}`);
671
+ }
672
+ if (options.requireProductionSiteUrl) {
673
+ const placeholderSiteUrls = extractPlaceholderSiteUrls(sitemapLocValues);
674
+ if (placeholderSiteUrls.length) {
675
+ failures.push([
676
+ `- Placeholder sitemap URLs found: ${placeholderSiteUrls.join(", ")}`,
677
+ "Expected production URLs in generated sitemap loc values.",
678
+ "Set site.url or runtimeConfig.public.siteUrl to the deployed origin for production release checks."
679
+ ].join(" "));
680
+ }
681
+ }
597
682
  const missingCollections = options.requiredCollections.filter((collection) => (collectionRouteCounts[collection] || 0) === 0);
598
683
  if (missingCollections.length) {
599
684
  failures.push(`- Missing content sitemap routes for collections: ${missingCollections.join(", ")}`);
@@ -643,16 +728,40 @@ function hasNuxtI18nModule(modules = []) {
643
728
  function hasNuxtSitemapModule(modules = []) {
644
729
  return hasNuxtModule(modules, "@nuxtjs/sitemap");
645
730
  }
731
+ function configureNuxtSitemapSource(nuxt, apiBaseURL, sitemapPath = "/sitemap") {
732
+ if (!hasNuxtSitemapModule(nuxt.options.modules)) {
733
+ return;
734
+ }
735
+ const source = {
736
+ context: {
737
+ name: GINKO_SITEMAP_SOURCE_NAME
738
+ },
739
+ fetch: resolveContentSitemapSource(apiBaseURL, sitemapPath)
740
+ };
741
+ const sitemap = nuxt.options.sitemap ??= {};
742
+ const sources = Array.isArray(sitemap.sources) ? sitemap.sources : [];
743
+ sitemap.sources = [
744
+ ...sources.filter((item) => {
745
+ if (typeof item === "string") return item !== source.fetch;
746
+ return item?.fetch !== source.fetch && item?.context?.name !== GINKO_SITEMAP_SOURCE_NAME;
747
+ }),
748
+ source
749
+ ];
750
+ }
646
751
  function resolveNuxtSitemapPrerenderRoutes(nuxt) {
647
752
  if (!hasNuxtSitemapModule(nuxt.options.modules)) {
648
753
  return [];
649
754
  }
755
+ const nuxtSitemap = nuxt.options.sitemap;
756
+ if (nuxtSitemap?.sitemaps === false) {
757
+ return ["/sitemap.xml"];
758
+ }
650
759
  const nuxtI18n = nuxt.options.i18n || {};
651
760
  if (!hasNuxtI18nModule(nuxt.options.modules) || !Array.isArray(nuxtI18n.locales) || nuxtI18n.locales.length === 0) {
652
- return [];
761
+ return ["/sitemap.xml"];
653
762
  }
654
763
  const childRoutes = nuxtI18n.locales.map((locale) => typeof locale === "string" ? locale : locale.language || locale.code).filter(Boolean).map((locale) => `/__sitemap__/${locale}.xml`);
655
- return Array.from(/* @__PURE__ */ new Set(["/sitemap_index.xml", ...childRoutes]));
764
+ return Array.from(/* @__PURE__ */ new Set(["/sitemap.xml", "/sitemap_index.xml", ...childRoutes]));
656
765
  }
657
766
  function resolveModuleI18nOptions(options, nuxt) {
658
767
  if (options.i18n === false) {
@@ -711,7 +820,7 @@ function normalizeSearchOptions(options) {
711
820
  }
712
821
  const defaultMiniSearchOptions = {
713
822
  fields: ["title", "content", "headings"],
714
- storeFields: ["path", "title", "excerpt", "anchor", "locale"],
823
+ storeFields: ["path", "title", "excerpt", "anchor", "locale", "collection"],
715
824
  boost: {
716
825
  title: 4,
717
826
  headings: 2,
@@ -1018,9 +1127,7 @@ const collectSitemapCollectionRouteCounts = async (rootDir, contentContext) => {
1018
1127
  return counts;
1019
1128
  };
1020
1129
  const registerContentNitroIntegrationHooks = (nitroConfig, options, contentContext) => {
1021
- if (contentContext.provider && contentContext.provider !== "filesystem") {
1022
- return;
1023
- }
1130
+ const usesFilesystemProvider = !contentContext.provider || contentContext.provider === "filesystem";
1024
1131
  nitroConfig.hooks ||= {};
1025
1132
  if (contentContext.sitemap?.assert?.enabled) {
1026
1133
  appendHook(nitroConfig.hooks, "compiled", async (nitro) => {
@@ -1032,7 +1139,7 @@ const registerContentNitroIntegrationHooks = (nitroConfig, options, contentConte
1032
1139
  await assertGeneratedSitemaps({
1033
1140
  outputPublicDir: nitro.options.output.publicDir,
1034
1141
  options: assertOptions,
1035
- collectionRouteCounts: await collectSitemapCollectionRouteCounts(options.rootDir, contentContext),
1142
+ collectionRouteCounts: usesFilesystemProvider ? await collectSitemapCollectionRouteCounts(options.rootDir, contentContext) : {},
1036
1143
  logger: nitro.logger
1037
1144
  });
1038
1145
  } catch (error) {
@@ -1044,15 +1151,73 @@ const registerContentNitroIntegrationHooks = (nitroConfig, options, contentConte
1044
1151
  });
1045
1152
  }
1046
1153
  appendHook(nitroConfig.hooks, "prerender:routes", async (routes) => {
1047
- for (const route of await collectPrerenderRoutes(options.rootDir, contentContext)) {
1048
- routes.add(route);
1154
+ if (usesFilesystemProvider) {
1155
+ for (const route of await collectPrerenderRoutes(options.rootDir, contentContext)) {
1156
+ routes.add(route);
1157
+ }
1049
1158
  }
1050
- for (const route of options.sitemapPrerenderRoutes || []) {
1159
+ const sitemapPrerenderRoutes = typeof options.sitemapPrerenderRoutes === "function" ? options.sitemapPrerenderRoutes() : options.sitemapPrerenderRoutes || [];
1160
+ for (const route of sitemapPrerenderRoutes) {
1051
1161
  routes.add(route);
1052
1162
  }
1053
1163
  });
1054
1164
  };
1055
1165
 
1166
+ const getContentPageMeta = (page) => {
1167
+ const content = page.meta?.content;
1168
+ return content && typeof content === "object" ? content : void 0;
1169
+ };
1170
+ const isRouteConfig = (value) => typeof value === "string" || value !== null && typeof value === "object" && Object.values(value).every((entry) => typeof entry === "string");
1171
+ const describePage = (page) => page.file || page.path || "<unknown page>";
1172
+ const expectedLocalesFor = (collection, locales, defaultLocale) => {
1173
+ const collectionI18n = collection.i18n && typeof collection.i18n === "object" ? collection.i18n : void 0;
1174
+ return collectionI18n?.locales?.length ? collectionI18n.locales : locales.length ? locales : defaultLocale ? [defaultLocale] : [];
1175
+ };
1176
+ function validateContentPageRouteMetadata(pages, collections, options = {}) {
1177
+ const visit = (page) => {
1178
+ const contentMeta = getContentPageMeta(page);
1179
+ if (contentMeta) {
1180
+ validatePage(page, contentMeta, collections, options);
1181
+ }
1182
+ for (const child of page.children || []) {
1183
+ visit(child);
1184
+ }
1185
+ };
1186
+ for (const page of pages) {
1187
+ visit(page);
1188
+ }
1189
+ }
1190
+ function validatePage(page, contentMeta, collections, options) {
1191
+ const collectionName = contentMeta.collection;
1192
+ if (typeof collectionName !== "string" || !collectionName) {
1193
+ throw new Error(`@lupinum/ginko-content page "${describePage(page)}" declares content route metadata without a string collection name.`);
1194
+ }
1195
+ const collection = collections[collectionName];
1196
+ if (!collection) {
1197
+ throw new Error(`@lupinum/ginko-content page "${describePage(page)}" declares content collection "${collectionName}", but no matching collection exists in content.config.ts.`);
1198
+ }
1199
+ if (!isRouteConfig(contentMeta.route)) {
1200
+ throw new Error(`@lupinum/ginko-content page "${describePage(page)}" declares content collection "${collectionName}" but no route metadata. Add definePageMeta({ content: { collection: '${collectionName}', route: ... } }).`);
1201
+ }
1202
+ const locales = expectedLocalesFor(collection, options.locales || [], options.defaultLocale);
1203
+ const defaultLocale = collection.i18n && typeof collection.i18n === "object" ? collection.i18n.defaultLocale : options.defaultLocale;
1204
+ const expected = normalizeRouteMounts(collection.route, locales, defaultLocale);
1205
+ const actual = normalizeRouteMounts(contentMeta.route, locales, defaultLocale);
1206
+ if (!expected) {
1207
+ throw new Error(`@lupinum/ginko-content page "${describePage(page)}" declares route metadata for collection "${collectionName}", but that collection has no route config in content.config.ts.`);
1208
+ }
1209
+ for (const locale of Object.keys(expected)) {
1210
+ const expectedRoute = normalizeContentPath(expected[locale]);
1211
+ const actualRoute = actual?.[locale] ? normalizeContentPath(actual[locale]) : void 0;
1212
+ if (!actualRoute) {
1213
+ throw new Error(`@lupinum/ginko-content route metadata mismatch in "${describePage(page)}": collection "${collectionName}" is missing locale "${locale}" route "${expectedRoute}".`);
1214
+ }
1215
+ if (actualRoute !== expectedRoute) {
1216
+ throw new Error(`@lupinum/ginko-content route metadata mismatch in "${describePage(page)}": collection "${collectionName}" locale "${locale}" expected route "${expectedRoute}" but page metadata declares "${actualRoute}".`);
1217
+ }
1218
+ }
1219
+ }
1220
+
1056
1221
  const module$1 = defineNuxtModule({
1057
1222
  meta: {
1058
1223
  name,
@@ -1158,6 +1323,7 @@ const module$1 = defineNuxtModule({
1158
1323
  const resolvedI18n = resolveModuleI18nOptions(options, nuxt);
1159
1324
  const resolvedSitemap = normalizeSitemapOptions(options);
1160
1325
  const resolvedSearch = normalizeSearchOptions(options);
1326
+ validateCollectionNames(appContentConfig.collections);
1161
1327
  options.collections = Object.fromEntries(Object.entries(appContentConfig.collections).map(([name2, collection]) => [
1162
1328
  name2,
1163
1329
  {
@@ -1170,10 +1336,11 @@ const module$1 = defineNuxtModule({
1170
1336
  }
1171
1337
  ]));
1172
1338
  options.provider = appContentConfig.provider || options.provider || "filesystem";
1173
- options.providers = {
1339
+ const providerRegistry = {
1174
1340
  ...options.providers || {},
1175
1341
  ...appContentConfig.providers || {}
1176
1342
  };
1343
+ options.providers = providerRegistry;
1177
1344
  const buildIntegrity = nuxt.options.dev ? void 0 : Date.now();
1178
1345
  const contentContext = {
1179
1346
  ...options,
@@ -1186,6 +1353,15 @@ const module$1 = defineNuxtModule({
1186
1353
  sitemap: resolvedSitemap,
1187
1354
  search: resolvedSearch
1188
1355
  };
1356
+ if (resolvedSitemap !== false) {
1357
+ configureNuxtSitemapSource(nuxt, options.api.baseURL, resolvedSitemap.path);
1358
+ }
1359
+ nuxt.hook("pages:extend", (pages) => {
1360
+ validateContentPageRouteMetadata(pages, options.collections, {
1361
+ locales: resolvedI18n.locales,
1362
+ defaultLocale: resolvedI18n.defaultLocale
1363
+ });
1364
+ });
1189
1365
  if (resolvedSitemap && resolvedSitemap.assert.enabled) {
1190
1366
  nuxt.hook("sitemap:prerender:done", async ({ sitemaps }) => {
1191
1367
  const assertOptions = contentContext.sitemap?.assert;
@@ -1207,7 +1383,12 @@ const module$1 = defineNuxtModule({
1207
1383
  registerRuntimeImports(resolveRuntimeModule);
1208
1384
  registerRuntimeComponents(resolve);
1209
1385
  registerContentComponentsTemplate(addTemplate);
1210
- registerGeneratedTypes(contentConfigPath, resolveRuntimeModule);
1386
+ registerGeneratedTypes(
1387
+ contentConfigPath,
1388
+ resolveRuntimeModule,
1389
+ Object.keys(appContentConfig.collections),
1390
+ Object.entries(appContentConfig.collections).filter(([, collection]) => Boolean(collection.i18n)).map(([name2]) => name2)
1391
+ );
1211
1392
  await registerUserContentComponents(nuxt, resolve);
1212
1393
  const getSearchRuntime = () => contentContext.search === false ? false : createSearchRuntimeConfig(contentContext.search, options.api.baseURL);
1213
1394
  nuxt.hook("nitro:config", (nitroConfig) => {
@@ -1265,7 +1446,7 @@ const module$1 = defineNuxtModule({
1265
1446
  }
1266
1447
  registerContentNitroIntegrationHooks(nitroConfig, {
1267
1448
  rootDir: nuxt.options.rootDir,
1268
- sitemapPrerenderRoutes: contentContext.sitemap === false ? [] : resolveNuxtSitemapPrerenderRoutes(nuxt)
1449
+ sitemapPrerenderRoutes: () => contentContext.sitemap === false ? [] : resolveNuxtSitemapPrerenderRoutes(nuxt)
1269
1450
  }, {
1270
1451
  collections: contentContext.collections,
1271
1452
  locales: contentContext.locales,
@@ -1310,6 +1491,8 @@ const module$1 = defineNuxtModule({
1310
1491
  });
1311
1492
  }
1312
1493
  nuxt.hook("modules:done", async () => {
1494
+ await nuxt.callHook("content:providers", contentContext.providers ||= {});
1495
+ assertConfiguredProviderAvailable(contentContext);
1313
1496
  await nuxt.callHook("content:context", contentContext);
1314
1497
  if (contentContext.search !== false) {
1315
1498
  registerContentSearchServerHandlers(options.api.baseURL, contentContext.search, resolveRuntimeModule);
@@ -1317,17 +1500,22 @@ const module$1 = defineNuxtModule({
1317
1500
  contentContext.defaultLocale = contentContext.defaultLocale || contentContext.locales[0];
1318
1501
  contentContext.markdown = processMarkdownOptions(contentContext.markdown);
1319
1502
  await validateBuiltinMarkdownPlugins(contentContext.markdown.plugins, resolvePath);
1320
- const runtimeCollections = Object.fromEntries(Object.entries(options.collections || {}).map(([name2, collection]) => [
1321
- name2,
1322
- {
1323
- ...collection.source ? { source: collection.source } : {},
1324
- ...collection.exclude ? { exclude: collection.exclude } : {},
1325
- strict: collection.strict ?? true,
1326
- ...collection.route ? { route: collection.route } : {},
1327
- ...typeof collection.sitemap === "boolean" ? { sitemap: collection.sitemap } : {},
1328
- ...collection.i18n && collection.i18n !== true ? { i18n: collection.i18n } : {}
1329
- }
1330
- ]));
1503
+ const runtimeCollections = Object.fromEntries(Object.entries(options.collections || {}).map(([name2, collection]) => {
1504
+ const references = collectTopLevelReferenceFieldsByTarget(collection.schema);
1505
+ return [
1506
+ name2,
1507
+ {
1508
+ ...collection.source ? { source: collection.source } : {},
1509
+ ...collection.exclude ? { exclude: collection.exclude } : {},
1510
+ ...collection.type ? { type: collection.type } : {},
1511
+ strict: collection.strict ?? true,
1512
+ ...collection.route ? { route: collection.route } : {},
1513
+ ...typeof collection.sitemap === "boolean" ? { sitemap: collection.sitemap } : {},
1514
+ ...collection.i18n && collection.i18n !== true ? { i18n: collection.i18n } : {},
1515
+ ...Object.keys(references).length ? { references } : {}
1516
+ }
1517
+ ];
1518
+ }));
1331
1519
  const cacheIntegrity = hash({
1332
1520
  locales: contentContext.locales,
1333
1521
  defaultLocale: contentContext.defaultLocale,
@@ -1348,6 +1536,18 @@ const module$1 = defineNuxtModule({
1348
1536
  }
1349
1537
  }
1350
1538
  });
1539
+ function assertConfiguredProviderAvailable(contentContext) {
1540
+ const provider = contentContext.provider || "filesystem";
1541
+ if (provider === "filesystem") return;
1542
+ if (contentContext.providers?.[provider]) return;
1543
+ if (provider === "cms") {
1544
+ throw new Error('content.config.ts sets provider "cms", but no CMS provider module registered it. Add @lupinum/ginko-cms to nuxt.config.ts modules or remove provider: "cms".');
1545
+ }
1546
+ throw new Error(`content.config.ts sets provider "${provider}", but no provider module registered it. Register a module for "${provider}" or add it to content providers.`);
1547
+ }
1548
+ function validateCollectionNames(collections) {
1549
+ normalizeContentConfigCollectionNames(collections);
1550
+ }
1351
1551
  function validateRemovedMarkdownOptions(options) {
1352
1552
  if (options.highlight !== void 0) {
1353
1553
  throw new Error("`content.highlight` was removed. Enable syntax highlighting with `content.markdown.plugins`, for example `[['highlight', { ...options }]]`.");
@@ -7,9 +7,11 @@
7
7
  export { one, many, paginate, backlinks, resolveOne, variants, tree, neighbors } from '../runtime/app/composables/query-api.js';
8
8
  export { getCollectionPath } from '../runtime/query/routes.js';
9
9
  export type { CollectionPathOptions } from '../runtime/query/routes.js';
10
- export { useContentPage, useContentOne, useContentMany, useContentPagination, useContentBacklinks, useContentResolveOne, useContentVariants, useContentTree, useContentNeighbors, useContentLocaleSwitch } from '../runtime/app/composables/use-content.js';
10
+ export { useContentHead } from '../runtime/app/composables/head.js';
11
+ export { useContentPage, useContentOne, useContentMany, useContentPagination, useContentBacklinks, useContentResolveOne, useContentVariants, useContentTree, useContentNavigation, useContentNeighbors, useContentLocaleSwitch } from '../runtime/app/composables/use-content.js';
11
12
  export type { UseContentPageOptions } from '../runtime/app/composables/use-content.js';
12
- export type { QueryWhere, QueryOperators, ContentSelector, ContentResolvedMeta, BacklinkFields, BacklinkSource, BacklinksOptions, BacklinksResult, LocaleFallback, DocumentFromHandle, OneOptions, ManyOptions, PaginationOptions, PaginationResult, PopulateSpec, PopulatedDocument, ResolveOneOptions, ResolveOneResult, LocalizedDoc, LocalePathEntry, NeighborsOptions, NeighborsResult, ResolutionEnvelope, SortSpec, TreeOptions, VariantsOptions, ContentVariant, ContentNavigationItem, ContentTreeItem, ContentRouteMeta, ContentSearchSection } from '../types/query.js';
13
+ export type { QueryWhere, QueryOperators, ContentSelector, ContentResolvedMeta, BacklinkFields, BacklinkSource, BacklinksOptions, BacklinksResult, LocaleFallback, DocumentFromHandle, OneOptions, ManyOptions, PaginationOptions, PaginationResult, PopulateSpec, PopulatedDocument, ResolveOneOptions, ResolveOneResult, LocalizedDoc, LocalePathEntry, NeighborsOptions, NeighborsResult, ResolutionEnvelope, SortSpec, TreeOptions, VariantsOptions, ContentVariant, ContentNavigationItem, ContentCollectionName, ContentCollectionStringName, ContentCollectionTarget, ContentTreeItem, ContentRouteMeta, ContentSearchSection } from '../types/query.js';
14
+ export type { ContentNavigationNode } from '../runtime/app/composables/use-content.js';
13
15
  export { useContentSearch, useContentSearchData, useContentSearchResults } from '../runtime/app/composables/search.js';
14
16
  export type { UseContentSearchDataOptions, UseContentSearchDataResult, UseContentSearchOptions, UseContentSearchResult, UseContentSearchResultsOptions, UseContentSearchResultsResult } from '../runtime/app/composables/search.js';
15
17
  export { querySiteData } from '../runtime/app/composables/site-data.js';
@@ -9,6 +9,7 @@ export {
9
9
  neighbors
10
10
  } from "../runtime/app/composables/query-api.js";
11
11
  export { getCollectionPath } from "../runtime/query/routes.js";
12
+ export { useContentHead } from "../runtime/app/composables/head.js";
12
13
  export {
13
14
  useContentPage,
14
15
  useContentOne,
@@ -18,6 +19,7 @@ export {
18
19
  useContentResolveOne,
19
20
  useContentVariants,
20
21
  useContentTree,
22
+ useContentNavigation,
21
23
  useContentNeighbors,
22
24
  useContentLocaleSwitch
23
25
  } from "../runtime/app/composables/use-content.js";
@@ -11,6 +11,13 @@ function normalizeBody(value, excerpt) {
11
11
  }
12
12
  return null;
13
13
  }
14
+ function warnUnsupportedValue(value, excerpt) {
15
+ const path = value?._path ? ` for "${value._path}"` : "";
16
+ const target = excerpt ? "excerpt" : "body";
17
+ console.warn(
18
+ `[ginko-content] <ContentRenderer> could not render ${target}${path}. Pass the full content document with a markdown body, or provide an \`empty\` slot for empty/unsupported content.`
19
+ );
20
+ }
14
21
  export default defineComponent({
15
22
  name: "ContentRenderer",
16
23
  props: {
@@ -74,11 +81,8 @@ export default defineComponent({
74
81
  ...this.$attrs
75
82
  });
76
83
  }
77
- return h(
78
- "pre",
79
- null,
80
- JSON.stringify({ message: "You should use slots with <ContentRenderer>", value, excerpt, tag, prose, unwrap }, null, 2)
81
- );
84
+ warnUnsupportedValue(value, excerpt);
85
+ return null;
82
86
  }
83
87
  });
84
88
  </script>
@@ -57,8 +57,8 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
57
57
  default: () => {};
58
58
  };
59
59
  }>> & Readonly<{}>, {
60
- data: Record<string, any>;
61
60
  excerpt: boolean;
61
+ data: Record<string, any>;
62
62
  components: Record<string, any>;
63
63
  prose: boolean;
64
64
  tag: string;
@@ -57,8 +57,8 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
57
57
  default: () => {};
58
58
  };
59
59
  }>> & Readonly<{}>, {
60
- data: Record<string, any>;
61
60
  excerpt: boolean;
61
+ data: Record<string, any>;
62
62
  components: Record<string, any>;
63
63
  prose: boolean;
64
64
  tag: string;
@@ -1,11 +1,35 @@
1
1
  import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from 'vue-router';
2
2
  import type { Ref } from 'vue';
3
- import type { ParsedContent } from '../../../types/content';
3
+ type ContentHeadObject = {
4
+ title?: string;
5
+ description?: string;
6
+ image?: string | Record<string, any>;
7
+ meta?: Array<Record<string, any>>;
8
+ link?: Array<Record<string, any>>;
9
+ [key: string]: any;
10
+ };
11
+ type ContentHeadDocument = {
12
+ title?: string;
13
+ description?: string;
14
+ image?: string | Record<string, any>;
15
+ head?: ContentHeadObject;
16
+ seo?: ContentHeadObject;
17
+ };
18
+ type ContentHeadRuntimeConfig = {
19
+ app: {
20
+ baseURL: string;
21
+ };
22
+ public: {
23
+ siteUrl?: unknown;
24
+ };
25
+ };
4
26
  /**
5
27
  * Apply sensible page head defaults from a parsed content document.
6
28
  *
7
- * The composable merges explicit `content.head` values with common fallbacks
29
+ * The composable merges explicit `content.head` or `content.seo` values with common fallbacks
8
30
  * from the document (`title`, `description`, `image`) and adds canonical /
9
31
  * Open Graph URL metadata when a public site URL is configured.
10
32
  */
11
- export declare const useContentHead: (_content: ParsedContent | Ref<ParsedContent>, to?: RouteLocationNormalized | RouteLocationNormalizedLoaded) => void;
33
+ export declare const useContentHead: (_content: ContentHeadDocument | null | undefined | Ref<ContentHeadDocument | null | undefined>, to?: RouteLocationNormalized | RouteLocationNormalizedLoaded) => void;
34
+ export declare const resolveContentHead: (data: ContentHeadDocument | null | undefined, to: Pick<RouteLocationNormalized | RouteLocationNormalizedLoaded, "path" | "fullPath">, config: ContentHeadRuntimeConfig) => ContentHeadObject | null;
35
+ export {};