@lupinum/ginko-content 0.1.1 → 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 (35) hide show
  1. package/README.md +22 -6
  2. package/compatibility.json +2 -2
  3. package/dist/cli.mjs +108 -36
  4. package/dist/core/references/schema.d.ts +1 -0
  5. package/dist/core/references/schema.js +33 -10
  6. package/dist/module.d.mts +78 -19
  7. package/dist/module.json +1 -1
  8. package/dist/module.mjs +196 -33
  9. package/dist/public/client.d.ts +4 -2
  10. package/dist/public/client.js +2 -0
  11. package/dist/runtime/app/components/ContentRenderer.vue +9 -5
  12. package/dist/runtime/app/composables/head.d.ts +27 -3
  13. package/dist/runtime/app/composables/head.js +82 -78
  14. package/dist/runtime/app/composables/query-api.d.ts +9 -10
  15. package/dist/runtime/app/composables/search.d.ts +4 -4
  16. package/dist/runtime/app/composables/search.js +11 -5
  17. package/dist/runtime/app/composables/use-content.d.ts +35 -15
  18. package/dist/runtime/app/composables/use-content.js +83 -13
  19. package/dist/runtime/query/unified.d.ts +1 -0
  20. package/dist/runtime/query/unified.js +93 -6
  21. package/dist/runtime/server/api/search.d.ts +2 -1
  22. package/dist/runtime/server/api/search.js +6 -2
  23. package/dist/runtime/server/pagefind.js +1 -0
  24. package/dist/runtime/server/search.d.ts +11 -0
  25. package/dist/runtime/server/search.js +24 -7
  26. package/dist/runtime/shared/search.js +3 -2
  27. package/dist/testing/provider-fixture.js +1 -0
  28. package/dist/types/config.d.ts +27 -7
  29. package/dist/types/config.js +26 -2
  30. package/dist/types/module.d.ts +35 -3
  31. package/dist/types/query.d.ts +19 -11
  32. package/dist/types/search.d.ts +3 -1
  33. package/dist/types.d.mts +2 -2
  34. package/dist/web-types.json +1 -1
  35. package/package.json +4 -4
@@ -1,8 +1,8 @@
1
1
  import { compileQueryParams } from "../../core/query/filter";
2
2
  import { decorateLocalePathsWithFallbacks, localizePageResult } from "../../features/localization/results";
3
- import { normalizeRouteMounts } from "../../features/localization/path";
3
+ import { normalizeContentPath, normalizeRouteMounts } from "../../features/localization/path";
4
4
  import { normalizeReferenceValue } from "../../core/references/resolve";
5
- import { collectTopLevelReferenceFields } from "../../core/references/schema";
5
+ import { collectTopLevelReferenceFields, collectTopLevelReferenceFieldsByTarget } from "../../core/references/schema";
6
6
  import { MAX_PUBLIC_QUERY_LIMIT, MAX_PUBLIC_QUERY_SKIP } from "./public-limits.js";
7
7
  const NAVIGATION_INTERNAL_FIELDS = [
8
8
  "_id",
@@ -95,6 +95,23 @@ const decorate = (doc, collection, runtime, requestedLocale) => {
95
95
  result.localePaths = decorateLocalePathsWithFallbacks(result.localePaths, locales, fallbackLocale, defaultLocale, routeMounts);
96
96
  return result;
97
97
  };
98
+ const collectionRouteRoots = (collection, runtime) => {
99
+ const collectionConfig = runtime?.collections?.[collection];
100
+ const collectionI18n = collectionConfig?.i18n;
101
+ const collectionLocales = collectionI18n && typeof collectionI18n === "object" ? collectionI18n.locales : void 0;
102
+ const collectionDefault = collectionI18n && typeof collectionI18n === "object" ? collectionI18n.defaultLocale : void 0;
103
+ const locales = collectionLocales?.length ? collectionLocales : runtime?.locales?.length ? runtime.locales : [];
104
+ const defaultLocale = collectionDefault || runtime?.defaultLocale;
105
+ const routeMounts = normalizeRouteMounts(collectionConfig?.route, locales, defaultLocale);
106
+ return new Set(Object.values(routeMounts || {}).map((value) => normalizeContentPath(value)));
107
+ };
108
+ const isCollectionRouteRoot = (path, collection, runtime) => collectionRouteRoots(collection, runtime).has(normalizeContentPath(path));
109
+ const isAncestorRoutePath = (path, childPath) => {
110
+ const normalized = normalizeContentPath(path);
111
+ const child = normalizeContentPath(childPath);
112
+ return normalized === "/" ? child !== "/" : child.startsWith(`${normalized}/`);
113
+ };
114
+ const isNavigationRootPath = (path, flat) => Boolean(flat[0]?.path && isAncestorRoutePath(path, flat[0].path));
98
115
  const collectionDefaultLocale = (collection, runtime) => {
99
116
  const collectionI18n = runtime?.collections?.[collection]?.i18n;
100
117
  const collectionDefault = collectionI18n && typeof collectionI18n === "object" ? collectionI18n.defaultLocale : void 0;
@@ -133,7 +150,7 @@ const explainResolution = (collection, requestedBy, normalizedBy, requestedLocal
133
150
  const ensureCollectionName = (handle) => {
134
151
  if (typeof handle === "string") return handle;
135
152
  if (!handle || typeof handle !== "object" || typeof handle.name !== "string") {
136
- throw new TypeError("query API: expected a collection handle from defineCollection(name, config) or a string collection name");
153
+ throw new TypeError("query API: expected a string collection name or a collection handle from defineContentConfig({ collections }). Use useContentPage('docs') or config.collections.docs.");
137
154
  }
138
155
  return handle.name;
139
156
  };
@@ -150,6 +167,55 @@ const populateReferenceValue = async (context, target, value, locale, fallback)
150
167
  ...fallback !== void 0 ? { fallback } : {}
151
168
  });
152
169
  };
170
+ const wildcardReferenceTarget = "*";
171
+ const collectReferenceFieldsByTarget = (source, sourceCollection, runtime) => {
172
+ if (typeof source !== "string") {
173
+ return collectTopLevelReferenceFieldsByTarget(source.schema);
174
+ }
175
+ return runtime?.collections?.[sourceCollection]?.references || {};
176
+ };
177
+ const invertReferenceFields = (references) => {
178
+ const fields = /* @__PURE__ */ new Map();
179
+ for (const [target, targetFields] of Object.entries(references)) {
180
+ for (const field of targetFields) {
181
+ const targets = fields.get(field) || [];
182
+ targets.push(target);
183
+ fields.set(field, targets);
184
+ }
185
+ }
186
+ return fields;
187
+ };
188
+ const createPopulateTargetMismatchError = (sourceCollection, field, expectedTargets, actualTarget) => new Error([
189
+ `Cannot populate "${sourceCollection}.${field}" from "${actualTarget}".`,
190
+ `Reference metadata declares "${sourceCollection}.${field}" points to ${expectedTargets.map((target) => `"${target}"`).join(" or ")}.`,
191
+ `Change populate.${field} to the declared target collection, or update ${sourceCollection}.schema relation metadata.`
192
+ ].join(" "));
193
+ const validatePopulateSpec = (source, sourceCollection, runtime, populate) => {
194
+ if (!populate || !isRecord(populate)) {
195
+ return;
196
+ }
197
+ const references = collectReferenceFieldsByTarget(source, sourceCollection, runtime);
198
+ const fieldTargets = invertReferenceFields(references);
199
+ if (!fieldTargets.size) {
200
+ return;
201
+ }
202
+ for (const [field, target] of Object.entries(populate)) {
203
+ const declaredTargets = fieldTargets.get(field);
204
+ if (!declaredTargets?.length) {
205
+ continue;
206
+ }
207
+ const actualTarget = ensureCollectionName(target);
208
+ if (declaredTargets.includes(actualTarget) || declaredTargets.includes(wildcardReferenceTarget)) {
209
+ continue;
210
+ }
211
+ throw createPopulateTargetMismatchError(
212
+ sourceCollection,
213
+ field,
214
+ declaredTargets.filter((target2) => target2 !== wildcardReferenceTarget),
215
+ actualTarget
216
+ );
217
+ }
218
+ };
153
219
  const populateDocument = async (context, doc, populate, locale, fallback) => {
154
220
  if (!populate || !isRecord(populate)) {
155
221
  return doc;
@@ -179,6 +245,7 @@ const selectWithPopulate = (select, populate) => {
179
245
  export async function resolveOne(context, handle, options) {
180
246
  const collection = ensureCollectionName(handle);
181
247
  const runtime = context.runtime;
248
+ validatePopulateSpec(handle, collection, runtime, options.populate);
182
249
  const by = options.by;
183
250
  const fallback = resolveFallback(options.fallback, collection, runtime);
184
251
  const params = compileQueryParams({
@@ -216,6 +283,7 @@ export async function one(context, handle, options) {
216
283
  export async function many(context, handle, options = {}) {
217
284
  const collection = ensureCollectionName(handle);
218
285
  const runtime = context.runtime;
286
+ validatePopulateSpec(handle, collection, runtime, options.populate);
219
287
  const fallback = resolveFallback(options.fallback, collection, runtime);
220
288
  const params = compileQueryParams({
221
289
  collection,
@@ -247,6 +315,7 @@ const normalizePositiveInteger = (value, fallback) => {
247
315
  export async function paginate(context, handle, options) {
248
316
  const collection = ensureCollectionName(handle);
249
317
  const runtime = context.runtime;
318
+ validatePopulateSpec(handle, collection, runtime, options.populate);
250
319
  const requestedPage = normalizePositiveInteger(options.page, 1);
251
320
  const limit = Math.min(normalizePositiveInteger(options.limit, 10), MAX_PUBLIC_QUERY_LIMIT);
252
321
  const skip = Math.min((requestedPage - 1) * limit, MAX_PUBLIC_QUERY_SKIP);
@@ -323,9 +392,13 @@ const resolveExplicitBacklinkFields = (fields, sourceName) => {
323
392
  const sourceFields = fields?.[sourceName];
324
393
  return Array.isArray(sourceFields) ? sourceFields.filter((field) => typeof field === "string" && field.length > 0) : [];
325
394
  };
326
- const inferBacklinkFields = (source, targetCollection) => {
395
+ const inferBacklinkFields = (source, targetCollection, runtime) => {
327
396
  if (typeof source === "string") {
328
- return [];
397
+ const references = runtime?.collections?.[source]?.references;
398
+ return [
399
+ ...references?.[targetCollection] || [],
400
+ ...references?.["*"] || []
401
+ ];
329
402
  }
330
403
  return collectTopLevelReferenceFields(source.schema, targetCollection);
331
404
  };
@@ -346,6 +419,9 @@ const backlinkWhere = (fields, candidates) => {
346
419
  }));
347
420
  return clauses.length ? { $or: clauses } : void 0;
348
421
  };
422
+ const createMissingBacklinkFieldsError = (sourceCollection, targetCollection) => new Error(
423
+ `Cannot infer backlink fields from "${sourceCollection}" to "${targetCollection}". Declare fields.relation('${targetCollection}') / fields.relations('${targetCollection}') in ${sourceCollection}.schema, or pass fields explicitly.`
424
+ );
349
425
  export async function backlinks(context, targetHandle, options) {
350
426
  const targetCollection = ensureCollectionName(targetHandle);
351
427
  const target = await one(context, targetHandle, {
@@ -366,9 +442,12 @@ export async function backlinks(context, targetHandle, options) {
366
442
  const fields = [
367
443
  .../* @__PURE__ */ new Set([
368
444
  ...resolveExplicitBacklinkFields(options.fields, sourceName),
369
- ...inferBacklinkFields(source, targetCollection)
445
+ ...inferBacklinkFields(source, targetCollection, context.runtime)
370
446
  ])
371
447
  ];
448
+ if (!fields.length) {
449
+ throw createMissingBacklinkFieldsError(sourceName, targetCollection);
450
+ }
372
451
  const where = backlinkWhere(fields, candidates);
373
452
  if (!where) {
374
453
  return [];
@@ -441,6 +520,8 @@ export async function tree(context, handle, options = {}) {
441
520
  return list;
442
521
  }
443
522
  export async function neighbors(context, handle, options) {
523
+ const collection = ensureCollectionName(handle);
524
+ const runtime = context.runtime;
444
525
  const seed = await one(context, handle, {
445
526
  by: options.by,
446
527
  locale: options.locale,
@@ -465,6 +546,12 @@ export async function neighbors(context, handle, options) {
465
546
  walk(fullTree);
466
547
  const targetPath = seed.path;
467
548
  const idx = flat.findIndex((entry) => entry.path === targetPath);
549
+ if (idx === -1 && (isCollectionRouteRoot(targetPath, collection, runtime) || isNavigationRootPath(targetPath, flat))) {
550
+ return {
551
+ prev: null,
552
+ next: flat[0]?.item ?? null
553
+ };
554
+ }
468
555
  if (idx === -1) return { prev: null, next: null };
469
556
  return {
470
557
  prev: flat[idx - 1]?.item ?? null,
@@ -1,2 +1,3 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("..").MaybeContentProviderResult<import("../../../types").ContentSearchResult[]>>>;
1
+ import type { ContentSearchResult } from '../../../types/search';
2
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<ContentSearchResult[]>>;
2
3
  export default _default;
@@ -3,6 +3,10 @@ import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { buildSearchIndex, searchRecords } from "../search.js";
4
4
  import { getContentProvider } from "../providers/index.js";
5
5
  import { createContentProviderError } from "../../../public/provider-errors";
6
+ const normalizeSearchResults = (results = []) => results.map((result) => ({
7
+ ...result,
8
+ collection: typeof result.collection === "string" ? result.collection : ""
9
+ }));
6
10
  export default defineEventHandler(async (event) => {
7
11
  const runtimeConfig = useRuntimeConfig(event);
8
12
  const searchConfig = runtimeConfig.content.search;
@@ -22,11 +26,11 @@ export default defineEventHandler(async (event) => {
22
26
  provider: provider.name
23
27
  });
24
28
  }
25
- return await provider.search(event, {
29
+ return normalizeSearchResults(await provider.search(event, {
26
30
  term,
27
31
  locale,
28
32
  collections: searchConfig.collections
29
- });
33
+ }));
30
34
  }
31
35
  const records = await buildSearchIndex(event, {
32
36
  collections: searchConfig.collections,
@@ -14,6 +14,7 @@ export async function writePagefindIndex(records, outputPath) {
14
14
  meta: {
15
15
  title: record.title,
16
16
  excerpt: record.excerpt,
17
+ collection: record.collection,
17
18
  locale: record.locale || "",
18
19
  anchor: record.anchor || "",
19
20
  path: record.path
@@ -4,6 +4,17 @@ import type { ContentQueryBuilderWhere } from '../../types/query';
4
4
  import type { ContentSearchIndexRecord } from '../../types/search';
5
5
  export { searchRecords } from '../shared/search';
6
6
  type SearchablePage = Pick<ParsedContent, '_path' | '_locale' | 'title' | 'description' | 'body'> & Record<string, unknown>;
7
+ type RuntimeSearchConfig = {
8
+ search?: {
9
+ collections?: string[];
10
+ } | false;
11
+ collections?: Record<string, {
12
+ type?: 'page' | 'data';
13
+ route?: unknown;
14
+ sitemap?: boolean;
15
+ } | unknown>;
16
+ };
17
+ export declare const resolveSearchCollections: (runtimeContent: RuntimeSearchConfig, collectionsOverride?: string[]) => string[];
7
18
  export declare const clearSearchRecordsCache: () => void;
8
19
  export declare function serverSearchContent(event: H3Event, filterQuery?: ContentQueryBuilderWhere, locale?: string, collectionsOverride?: string[], opts?: {
9
20
  allLocales?: boolean;
@@ -5,6 +5,21 @@ import { serverQueryCollection } from "./provider-query.js";
5
5
  import { createContentProviderError } from "../../public/provider-errors";
6
6
  export { searchRecords } from "../shared/search.js";
7
7
  const unique = (values) => Array.from(new Set(values.filter(Boolean)));
8
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
9
+ const hasRouteMount = (route) => route !== void 0 && route !== null && route !== "";
10
+ const isRouteBackedSearchCollection = (config) => {
11
+ if (!isRecord(config) || config.sitemap === false || config.type === "data") {
12
+ return false;
13
+ }
14
+ return config.type === "page" || hasRouteMount(config.route) || config.sitemap === true;
15
+ };
16
+ export const resolveSearchCollections = (runtimeContent, collectionsOverride) => {
17
+ const configuredCollections = collectionsOverride || (runtimeContent.search && runtimeContent.search !== false ? runtimeContent.search.collections : void 0);
18
+ if (configuredCollections?.length) {
19
+ return unique(configuredCollections);
20
+ }
21
+ return Object.entries(runtimeContent.collections || {}).filter(([, config]) => isRouteBackedSearchCollection(config)).map(([collection]) => collection);
22
+ };
8
23
  const searchRecordsCache = /* @__PURE__ */ new Map();
9
24
  const MAX_SEARCH_RECORDS_CACHE_ENTRIES = 12;
10
25
  export const clearSearchRecordsCache = () => {
@@ -32,9 +47,7 @@ const mergeSearchFilter = (filterQuery, locale) => {
32
47
  };
33
48
  export async function serverSearchContent(event, filterQuery, locale, collectionsOverride, opts = {}) {
34
49
  const runtimeConfig = useRuntimeConfig(event);
35
- const configuredCollections = collectionsOverride || runtimeConfig.content.search?.collections;
36
- const contentCollections = Object.keys(runtimeConfig.content.collections || {});
37
- const collections = (configuredCollections?.length ? configuredCollections : contentCollections).filter(Boolean);
50
+ const collections = resolveSearchCollections(runtimeConfig.content, collectionsOverride);
38
51
  const results = await Promise.all(collections.map(async (collection) => {
39
52
  const loadPages = async (queryLocale) => {
40
53
  const query = serverQueryCollection(event, collection).select("_path", "_locale", "title", "description", "body");
@@ -64,6 +77,7 @@ const toSearchRecord = (section) => {
64
77
  return {
65
78
  ...extraFields,
66
79
  id: section.id,
80
+ collection: section.collection || "",
67
81
  path,
68
82
  title: section.title,
69
83
  excerpt: section.content.slice(0, 240),
@@ -76,12 +90,15 @@ const toSearchRecord = (section) => {
76
90
  const buildProviderSearchSections = async (event, collections, provider, opts) => {
77
91
  const runtimeConfig = useRuntimeConfig(event);
78
92
  const sections = await Promise.all(collections.map(async (collection) => {
79
- const loadCollectionLocale = async (locale) => await provider.searchSections(event, collection, {
93
+ const loadCollectionLocale = async (locale) => (await provider.searchSections(event, collection, {
80
94
  ignoredTags: opts.ignoredTags || [],
81
95
  extraFields: unique(["_locale", ...opts.extraFields || []]),
82
96
  filterQuery: opts.filterQuery,
83
97
  locale
84
- });
98
+ })).map((section) => ({
99
+ ...section,
100
+ collection
101
+ }));
85
102
  if (opts.locale) {
86
103
  return await loadCollectionLocale(opts.locale);
87
104
  }
@@ -100,7 +117,7 @@ export async function buildSearchIndex(event, opts = {}) {
100
117
  const cacheKey = JSON.stringify({
101
118
  provider: provider.name,
102
119
  integrity: runtimeConfig.content.cacheIntegrity || runtimeConfig.public.content?.integrity,
103
- collections: opts.collections || runtimeConfig.content.search?.collections,
120
+ collections: resolveSearchCollections(runtimeConfig.content, opts.collections),
104
121
  ignoredTags: opts.ignoredTags || [],
105
122
  extraFields: opts.extraFields || runtimeConfig.content.search?.extraFields || [],
106
123
  filterQuery: opts.filterQuery,
@@ -111,7 +128,7 @@ export async function buildSearchIndex(event, opts = {}) {
111
128
  if (cached) {
112
129
  return cached;
113
130
  }
114
- const collections = (opts.collections || runtimeConfig.content.search?.collections || Object.keys(runtimeConfig.content.collections || {})).filter(Boolean);
131
+ const collections = resolveSearchCollections(runtimeConfig.content, opts.collections);
115
132
  if (!provider.capabilities.searchSections) {
116
133
  throw createContentProviderError("unsupported_provider_search_index", `${provider.name} does not support search index generation`, {
117
134
  provider: provider.name
@@ -1,7 +1,7 @@
1
1
  import MiniSearch from "minisearch";
2
2
  const DEFAULT_SEARCH_OPTIONS = {
3
3
  fields: ["title", "content", "headings"],
4
- storeFields: ["path", "title", "excerpt", "anchor", "locale"],
4
+ storeFields: ["path", "title", "excerpt", "anchor", "locale", "collection"],
5
5
  boost: {
6
6
  title: 4,
7
7
  headings: 2,
@@ -10,7 +10,7 @@ const DEFAULT_SEARCH_OPTIONS = {
10
10
  fuzzy: 0.2,
11
11
  prefix: true
12
12
  };
13
- const REQUIRED_STORE_FIELDS = ["path", "title", "excerpt"];
13
+ const REQUIRED_STORE_FIELDS = ["path", "title", "excerpt", "collection"];
14
14
  const MAX_SEARCH_INDEX_CACHE_ENTRIES = 12;
15
15
  const searchIndexCache = /* @__PURE__ */ new Map();
16
16
  const resolveSearchOptions = (options = {}) => {
@@ -79,6 +79,7 @@ export const searchRecords = (records, term, locale, searchOptions) => {
79
79
  return {
80
80
  ...storedFields,
81
81
  path: typeof result.path === "string" ? result.path : "",
82
+ collection: typeof result.collection === "string" ? result.collection : "",
82
83
  title: typeof result.title === "string" ? result.title : "",
83
84
  excerpt: typeof result.excerpt === "string" ? result.excerpt : "",
84
85
  score: result.score,
@@ -342,6 +342,7 @@ export const createFixtureContentProvider = (fixture, name = fixture.providerNam
342
342
  const term = request.term.toLocaleLowerCase();
343
343
  return fixture.documents.filter((doc) => !request.collections?.length || request.collections.includes(doc._collection || "")).filter((doc) => !request.locale || doc._locale === request.locale).filter((doc) => String(doc.title || "").toLocaleLowerCase().includes(term)).map((doc) => ({
344
344
  score: 1,
345
+ collection: doc._collection || "",
345
346
  title: doc.title || "",
346
347
  excerpt: String(doc.description || ""),
347
348
  path: localizePath(fixture, doc._collection || "", doc._path || "/", doc._locale),
@@ -20,6 +20,7 @@ export type ContentCollectionSource = string | string[];
20
20
  * express roots such as `{ en: '/docs', de: '/dokumentation' }`.
21
21
  */
22
22
  export type ContentCollectionRouteConfig = string | Record<string, string>;
23
+ export type ContentCollectionKind = 'page' | 'data';
23
24
  export type ContentCmsFieldType = 'text' | 'textarea' | 'richtext' | 'slug' | 'email' | 'url' | 'number' | 'range' | 'select' | 'multiselect' | 'radio' | 'checkbox' | 'toggle' | 'date' | 'datetime' | 'time' | 'json' | 'object' | 'array' | 'blocks' | 'relation' | 'relations' | 'image' | 'images' | 'file' | 'icon' | 'code' | 'color' | 'divider' | 'section';
24
25
  export interface ContentCmsRelationConfig {
25
26
  collectionId: string;
@@ -79,6 +80,11 @@ export interface ContentCollectionSourceObject {
79
80
  * Declarative collection definition used by `content.config.ts`.
80
81
  */
81
82
  export interface ContentCollectionConfig<TSchema extends ZodType | undefined = ZodType | undefined> {
83
+ /**
84
+ * Collection kind declared in `defineCollection`. Page collections are public
85
+ * content routes by default; data collections are app-owned records.
86
+ */
87
+ type?: ContentCollectionKind;
82
88
  /**
83
89
  * Source glob or source descriptor understood by the filesystem ingestion
84
90
  * layer. CMS-backed projects do not need a runtime source; filesystem
@@ -131,13 +137,13 @@ export interface ContentCollectionConfig<TSchema extends ZodType | undefined = Z
131
137
  */
132
138
  cms?: ContentCmsCollectionConfig;
133
139
  }
134
- export type ContentCollectionKind = 'page' | 'data';
135
140
  export type ContentProviderName = 'filesystem' | (string & {});
136
141
  export type DefineCollectionOptions<TSchema extends ZodType | undefined = ZodType | undefined> = Omit<ContentCollectionConfig<TSchema>, 'source' | 'exclude'>;
137
142
  export interface DefineCollectionObject<TSchema extends ZodType | undefined = ZodType | undefined> extends DefineCollectionOptions<TSchema> {
138
143
  /**
139
144
  * Nuxt Content v3-compatible collection kind. Ginko does not keep separate
140
- * page/data collection runtimes; `data` collections default to `sitemap: false`.
145
+ * page/data query builders, but the kind remains canonical metadata at runtime.
146
+ * `data` collections default to `sitemap: false`.
141
147
  */
142
148
  type: ContentCollectionKind;
143
149
  /**
@@ -213,6 +219,16 @@ export interface ContentCollectionHandle<Name extends string = string, TSchema e
213
219
  type IsI18nConfig<TConfig> = TConfig extends {
214
220
  i18n: true | ContentCollectionI18nConfig;
215
221
  } ? TConfig['i18n'] extends false ? false : true : false;
222
+ type CollectionNameFromConfigKey<Key extends string, TCollection> = TCollection extends ContentCollectionHandle<infer Name, ZodType | undefined, boolean> ? [Name] extends [never] ? Key : Name : Key;
223
+ type NamedContentCollection<Key extends string, TCollection> = TCollection extends ContentCollectionHandle<infer Name, infer TSchema, infer TI18n> ? Omit<TCollection, 'name' | '__schema' | '__i18n'> & ContentCollectionHandle<[
224
+ Name
225
+ ] extends [never] ? Key : Name, TSchema, TI18n> : TCollection extends ContentCollectionConfig<infer TSchema> ? Omit<TCollection, 'name' | '__schema' | '__i18n'> & ContentCollectionHandle<CollectionNameFromConfigKey<Key, TCollection>, TSchema, IsI18nConfig<TCollection>> : TCollection;
226
+ type NamedContentCollections<TCollections extends Record<string, ContentCollectionConfig>> = {
227
+ [Key in keyof TCollections]: Key extends string ? NamedContentCollection<Key, TCollections[Key]> : TCollections[Key];
228
+ };
229
+ type NamedContentConfig<TCollections extends Record<string, ContentCollectionConfig>> = Omit<ContentConfig<TCollections>, 'collections'> & {
230
+ collections: NamedContentCollections<TCollections>;
231
+ };
216
232
  /**
217
233
  * Define a content collection in `content.config.ts`.
218
234
  *
@@ -221,7 +237,7 @@ type IsI18nConfig<TConfig> = TConfig extends {
221
237
  * import { z } from 'zod'
222
238
  * import { defineCollection } from '@lupinum/ginko-content/config'
223
239
  *
224
- * export const docs = defineCollection('docs', {
240
+ * export const docs = defineCollection({
225
241
  * type: 'page',
226
242
  * source: 'docs/**\/*.md',
227
243
  * i18n: { locales: ['en', 'fr', 'de'], defaultLocale: 'en' },
@@ -235,7 +251,8 @@ type IsI18nConfig<TConfig> = TConfig extends {
235
251
  type SchemaOf<TConfig> = TConfig extends {
236
252
  schema?: infer S;
237
253
  } ? S extends ZodType ? S : undefined : undefined;
238
- export declare function defineCollection<const Name extends string, const TConfig extends DefineCollectionObject<ZodType | undefined>>(name: Name, config: TConfig): ContentCollectionHandle<Name, SchemaOf<TConfig>, IsI18nConfig<TConfig>>;
254
+ export declare function defineCollection<const TConfig extends DefineCollectionObject<ZodType | undefined>>(config: TConfig): ContentCollectionHandle<never, SchemaOf<TConfig>, IsI18nConfig<TConfig>>;
255
+ export declare function normalizeContentConfigCollectionNames<TCollections extends Record<string, ContentCollectionConfig>>(collections: TCollections): NamedContentCollections<TCollections>;
239
256
  /**
240
257
  * Wrap the root content configuration with full type inference.
241
258
  *
@@ -243,7 +260,7 @@ export declare function defineCollection<const Name extends string, const TConfi
243
260
  * ```ts
244
261
  * import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'
245
262
  *
246
- * export const docs = defineCollection('docs', {
263
+ * export const docs = defineCollection({
247
264
  * type: 'page',
248
265
  * source: 'docs/*.md'
249
266
  * })
@@ -253,7 +270,10 @@ export declare function defineCollection<const Name extends string, const TConfi
253
270
  * })
254
271
  * ```
255
272
  */
256
- export declare function defineContentConfig<TCollections extends Record<string, ContentCollectionConfig>>(config: ContentConfig<TCollections>): ContentConfig<TCollections>;
273
+ export declare function defineContentConfig<const TCollections extends Record<string, ContentCollectionConfig>>(config: Omit<ContentConfig<TCollections>, 'collections'> & {
274
+ collections: TCollections;
275
+ }): NamedContentConfig<TCollections>;
276
+ export declare function defineContentConfig<const TConfig extends ContentConfig<Record<string, ContentCollectionConfig>>>(config: TConfig): TConfig;
257
277
  /**
258
278
  * Declare a schema field that stores a reference to another content entry.
259
279
  *
@@ -265,7 +285,7 @@ export declare function defineContentConfig<TCollections extends Record<string,
265
285
  * import { z } from 'zod'
266
286
  * import { defineCollection, defineContentConfig, reference } from '@lupinum/ginko-content/config'
267
287
  *
268
- * export const blog = defineCollection('blog', {
288
+ * export const blog = defineCollection({
269
289
  * type: 'page',
270
290
  * source: 'blog/*.md',
271
291
  * schema: z.object({
@@ -1,10 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { CONTENT_REFERENCE_PREFIX } from "./reference.js";
3
- export function defineCollection(name, config) {
3
+ export function defineCollection(config) {
4
+ if (typeof config === "string" || arguments.length > 1) {
5
+ throw new TypeError("@lupinum/ginko-content defineCollection(name, config) was removed. Use defineCollection({ ... }) under the desired defineContentConfig({ collections: { docs: ... } }) map key.");
6
+ }
4
7
  const { type, source, sitemap, ...rest } = config;
5
8
  const normalized = normalizeCollectionSource(source);
6
9
  return {
7
- name,
10
+ type,
8
11
  ...normalized,
9
12
  sitemap: sitemap ?? (type === "data" ? false : void 0),
10
13
  ...rest
@@ -20,7 +23,28 @@ function normalizeCollectionSource(source) {
20
23
  }
21
24
  return { source };
22
25
  }
26
+ export function normalizeContentConfigCollectionNames(collections) {
27
+ for (const [key, collection] of Object.entries(collections)) {
28
+ const authoredName = collection.name;
29
+ if (typeof authoredName === "string") {
30
+ if (authoredName !== key) {
31
+ throw new Error(`@lupinum/ginko-content collection key "${key}" must match collection name "${authoredName}". Use defineCollection({ ... }) under collections: { ${key}: ... }, or rename the collections map key.`);
32
+ }
33
+ continue;
34
+ }
35
+ Object.defineProperty(collection, "name", {
36
+ value: key,
37
+ enumerable: true,
38
+ configurable: true,
39
+ writable: true
40
+ });
41
+ }
42
+ return collections;
43
+ }
23
44
  export function defineContentConfig(config) {
45
+ if (config.collections) {
46
+ normalizeContentConfigCollectionNames(config.collections);
47
+ }
24
48
  return config;
25
49
  }
26
50
  export function reference(collection) {
@@ -160,6 +160,36 @@ export interface ContentSitemapAssertOptions {
160
160
  * @default []
161
161
  */
162
162
  requiredCollections?: string[];
163
+ /**
164
+ * Public URL paths that must appear in generated sitemap loc values.
165
+ *
166
+ * Values are compared by URL pathname, so absolute sitemap URLs are
167
+ * normalized before assertion. Use this for release QA of important route
168
+ * identities; keep `requiredCollections` for the broader content invariant.
169
+ *
170
+ * @default []
171
+ */
172
+ requiredPaths?: string[];
173
+ /**
174
+ * Public URL path prefixes that must not appear in generated sitemap loc
175
+ * values.
176
+ *
177
+ * Use this to guard against internal/generated routes such as `/_payload`,
178
+ * `/_nuxt`, API routes, or auth-only sections.
179
+ *
180
+ * @default []
181
+ */
182
+ forbiddenPathPrefixes?: string[];
183
+ /**
184
+ * Fail when generated sitemap URLs use placeholder or local hosts such as
185
+ * `example.com`, `localhost`, `127.0.0.1`, or `.localhost`.
186
+ *
187
+ * Enable this for production-like release checks. It is disabled by default
188
+ * so local examples and template builds can still generate static output.
189
+ *
190
+ * @default false
191
+ */
192
+ requireProductionSiteUrl?: boolean;
163
193
  /**
164
194
  * Optional per-sitemap overrides keyed by sitemap name, for example `en-US`.
165
195
  *
@@ -195,7 +225,8 @@ export interface ContentSearchOptions {
195
225
  /**
196
226
  * Collections included in the built-in index.
197
227
  *
198
- * When omitted, all configured collections are indexed.
228
+ * When omitted, Ginko indexes route-backed public collections only. Data-only
229
+ * collections are excluded unless listed explicitly.
199
230
  */
200
231
  collections?: string[];
201
232
  /**
@@ -258,8 +289,9 @@ export interface ModuleOptions {
258
289
  * Built-in full-text search configuration.
259
290
  *
260
291
  * When enabled, Ginko exposes JSON/Pagefind search endpoints under
261
- * the content api base route. Import `useContentSearchResults()` from
262
- * `@lupinum/ginko-content/client` to consume them from app components.
292
+ * the content api base route. `useContentSearchData()` and
293
+ * `useContentSearchResults()` are auto-imported; import the headless
294
+ * `useContentSearch()` helper from `@lupinum/ginko-content/client`.
263
295
  */
264
296
  search: false | ContentSearchOptions;
265
297
  /**
@@ -1,6 +1,6 @@
1
1
  import type { ContentQueryResponse } from './api';
2
2
  import type { ContentCollectionHandle } from './config';
3
- import type { ContentNavigationItem, ParsedContent, ParsedContentInternalMeta, ParsedContentMeta, StrictParsedContentMeta } from './content';
3
+ import type { ContentNavigationItem, ParsedContent, ParsedContentInternalMeta, ParsedContentMeta, StrictParsedContent, StrictParsedContentMeta } from './content';
4
4
  /**
5
5
  * Query
6
6
  */
@@ -531,12 +531,20 @@ export interface ContentQueryRequest {
531
531
  */
532
532
  export type ContentQueryFetcher<T> = (query: ContentQueryRequest) => Promise<ContentQueryResponse<T>>;
533
533
  export type QueryMatchOperator = (item: unknown, condition: unknown) => boolean;
534
- export interface ContentCollectionMap {
534
+ declare global {
535
+ interface GinkoContentCollectionMap {
536
+ }
537
+ interface GinkoContentCollectionI18nMap {
538
+ }
535
539
  }
536
- export interface ContentCollectionI18nMap {
540
+ export interface ContentCollectionMap extends GinkoContentCollectionMap {
537
541
  }
538
- export type ContentCollectionName = keyof ContentCollectionMap & string;
542
+ export interface ContentCollectionI18nMap extends GinkoContentCollectionI18nMap {
543
+ }
544
+ export type ContentCollectionName = keyof GinkoContentCollectionMap & string;
539
545
  export type ContentCollectionItem<K extends ContentCollectionName> = ContentCollectionMap[K];
546
+ export type ContentCollectionStringName = [ContentCollectionName] extends [never] ? string : ContentCollectionName;
547
+ export type ContentCollectionTarget = ContentCollectionHandle | ContentCollectionStringName;
540
548
  /**
541
549
  * ============================================================================
542
550
  * Unified Query API — public surface (ADR-0016)
@@ -627,8 +635,8 @@ type HandleSchema<H> = H extends {
627
635
  __schema: infer S;
628
636
  } ? S extends {
629
637
  _output: infer O;
630
- } ? O & StrictParsedContentMeta : StrictParsedContentMeta : StrictParsedContentMeta;
631
- type SelectFields<H> = H extends string ? ReadonlyArray<string> : ReadonlyArray<Extract<keyof HandleSchema<H>, string>>;
638
+ } ? O & StrictParsedContentMeta : StrictParsedContentMeta : H extends ContentCollectionName ? ContentCollectionMap[H] & StrictParsedContentMeta : StrictParsedContentMeta;
639
+ type SelectFields<H> = string extends H ? ReadonlyArray<string> : ReadonlyArray<Extract<keyof HandleSchema<H>, string>>;
632
640
  /**
633
641
  * Runtime document shape inferred from a collection handle.
634
642
  */
@@ -636,12 +644,12 @@ export type DocumentFromHandle<H> = H extends {
636
644
  __schema: {
637
645
  _output: infer O;
638
646
  };
639
- } ? O & ParsedContent : ParsedContent;
647
+ } ? O & StrictParsedContent : H extends ContentCollectionName ? ContentCollectionMap[H] : ParsedContent;
640
648
  /**
641
649
  * Explicit reference population map. Keys are fields on the source document;
642
650
  * values are target collection handles.
643
651
  */
644
- export type PopulateSpec = Record<string, ContentCollectionHandle | string>;
652
+ export type PopulateSpec = Record<string, ContentCollectionTarget>;
645
653
  export type PopulatedDocument<T, P> = P extends undefined ? T : P extends PopulateSpec ? string extends keyof P ? T : Omit<T, keyof P> & {
646
654
  [K in keyof P & keyof T]: T[K] extends ReadonlyArray<unknown> ? Array<LocalizedDoc<DocumentFromHandle<P[K]>>> : LocalizedDoc<DocumentFromHandle<P[K]>> | null;
647
655
  } : T;
@@ -656,7 +664,7 @@ type PopulateOption<P extends PopulateSpec | undefined = undefined> = {
656
664
  */
657
665
  type HandleIsI18n<H> = H extends {
658
666
  __i18n: infer I;
659
- } ? I extends true ? true : false : false;
667
+ } ? I extends true ? true : false : H extends keyof GinkoContentCollectionI18nMap ? true : false;
660
668
  /**
661
669
  * Locale option made required when the handle is i18n.
662
670
  */
@@ -773,7 +781,7 @@ export interface PaginationResult<T = ParsedContentMeta> {
773
781
  nextPage: number | null;
774
782
  prevPage: number | null;
775
783
  }
776
- export type BacklinkSource = ContentCollectionHandle | string;
784
+ export type BacklinkSource = ContentCollectionTarget;
777
785
  type SourceName<S> = S extends string ? S : S extends {
778
786
  name: infer N;
779
787
  } ? Extract<N, string> : string;
@@ -800,7 +808,7 @@ export type BacklinksOptions<Target = unknown, Source extends BacklinkSource | R
800
808
  limit?: number;
801
809
  skip?: number;
802
810
  fallback?: LocaleFallback;
803
- select?: Source extends string ? ReadonlyArray<string> : ReadonlyArray<Extract<keyof DocumentFromSource<Source>, string>>;
811
+ select?: string extends Source ? ReadonlyArray<string> : ReadonlyArray<Extract<keyof DocumentFromSource<Source>, string>>;
804
812
  } & BacklinksLocaleOption<Target, Source> & PopulateOption<P>;
805
813
  export type BacklinksResult<Source extends BacklinkSource | ReadonlyArray<BacklinkSource> = BacklinkSource | ReadonlyArray<BacklinkSource>, P extends PopulateSpec | undefined = undefined> = Array<LocalizedDoc<PopulatedDocument<DocumentFromSource<Source>, P>>>;
806
814
  /**