@lupinum/ginko-content 1.0.0-beta.2 → 1.0.0-beta.3

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 (54) hide show
  1. package/README.md +3 -3
  2. package/dist/cms-contract/build.js +17 -6
  3. package/dist/cms-contract/index.d.ts +5 -4
  4. package/dist/cms-contract/index.js +2 -1
  5. package/dist/cms-contract/path.d.ts +1 -1
  6. package/dist/cms-contract/path.js +1 -2
  7. package/dist/cms-contract/provider-wire.d.ts +8 -0
  8. package/dist/cms-contract/provider-wire.js +12 -3
  9. package/dist/cms-contract/types.d.ts +3 -2
  10. package/dist/cms-contract/validate.js +1 -2
  11. package/dist/cms-contract-node/artifact.d.ts +11 -0
  12. package/dist/cms-contract-node/artifact.js +74 -0
  13. package/dist/cms-contract-node/index.d.ts +1 -0
  14. package/dist/cms-contract-node/index.js +4 -0
  15. package/dist/cms-contract-node/stable-file.d.ts +7 -0
  16. package/dist/cms-contract-node/stable-file.js +44 -0
  17. package/dist/core/content/path.d.ts +0 -17
  18. package/dist/core/content/path.js +0 -18
  19. package/dist/core/data-source-error.d.ts +1 -1
  20. package/dist/core/data-source-error.js +1 -1
  21. package/dist/core/query/execute.js +18 -2
  22. package/dist/core/references/resolve.js +8 -1
  23. package/dist/features/localization/path.d.ts +2 -2
  24. package/dist/features/localization/path.js +0 -2
  25. package/dist/features/query/query-plan-boundary.d.ts +1 -1
  26. package/dist/features/query/query-plan-boundary.js +12 -7
  27. package/dist/module.d.mts +1 -1
  28. package/dist/module.json +1 -1
  29. package/dist/module.mjs +3 -1
  30. package/dist/portability/assets.js +2 -2
  31. package/dist/portability/documents.js +9 -2
  32. package/dist/portability-node/index.d.ts +1 -1
  33. package/dist/portability-node/index.js +1 -0
  34. package/dist/portability-node/read-directory.d.ts +17 -0
  35. package/dist/portability-node/read-directory.js +60 -10
  36. package/dist/portability-node/streams.d.ts +1 -1
  37. package/dist/portability-node/streams.js +6 -19
  38. package/dist/public/data-source.d.ts +3 -0
  39. package/dist/public/data-source.js +48 -0
  40. package/dist/public/provider-binder.js +19 -40
  41. package/dist/public/provider-query.d.ts +8 -7
  42. package/dist/public/provider-query.js +4 -3
  43. package/dist/runtime/server/providers/filesystem.js +2 -2
  44. package/dist/testing/data-source-contract.d.ts +32 -1
  45. package/dist/testing/data-source-contract.js +89 -4
  46. package/dist/testing/provider-contract.d.ts +27 -1
  47. package/dist/testing/provider-contract.js +59 -10
  48. package/dist/testing/provider-fixture.d.ts +3 -0
  49. package/dist/testing/provider-fixture.js +80 -2
  50. package/dist/types/config.d.ts +1 -1
  51. package/dist/types/fields.d.ts +5 -13
  52. package/dist/types/fields.js +6 -20
  53. package/dist/web-types.json +1 -1
  54. package/package.json +6 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  </p>
16
16
 
17
17
  > [!WARNING]
18
- > Version `1.0.0-beta.2` is a prerelease. Install it from npm's `next`
18
+ > Version `1.0.0-beta.3` is a prerelease. Install it from npm's `next`
19
19
  > channel. The stable `0.3` line remains on `latest`.
20
20
 
21
21
  ## Why use this package?
@@ -40,13 +40,13 @@ output.
40
40
  Use the Nuxt CLI:
41
41
 
42
42
  ```bash
43
- npx nuxi module add @lupinum/ginko-content@1.0.0-beta.2
43
+ npx nuxi module add @lupinum/ginko-content@1.0.0-beta.3
44
44
  ```
45
45
 
46
46
  Or install and register the module by hand:
47
47
 
48
48
  ```bash
49
- pnpm add @lupinum/ginko-content@1.0.0-beta.2
49
+ pnpm add @lupinum/ginko-content@1.0.0-beta.3
50
50
  ```
51
51
 
52
52
  ```ts
@@ -1,5 +1,8 @@
1
1
  import { getObjectShape, getReferenceDescriptor, getSchemaDef, getSchemaTypeName, unwrapSchema } from "../core/references/schema.js";
2
- import { getContentFieldMetadata } from "../types/fields.js";
2
+ import {
3
+ CONTENT_MANAGED_MEDIA_TYPES,
4
+ getContentFieldMetadata
5
+ } from "../types/fields.js";
3
6
  import { canonicalJsonBytes } from "./hash.js";
4
7
  import {
5
8
  canonicalizePortableComponentName,
@@ -105,7 +108,7 @@ function fieldFromSchema(key, schema, localized) {
105
108
  return field({ key, type: typeName === "ZodString" ? "text" : "json", required, localized, default: defaultFromSchema(schema, key), validation: validationFromSchema(schema) });
106
109
  }
107
110
  function fieldFromMetadata(key, metadata, sourceSchema, schema, localized, required) {
108
- const type = metadata.type === "boolean" ? "toggle" : metadata.type === "asset" ? "file" : metadata.type;
111
+ const type = metadata.type === "boolean" ? "toggle" : metadata.type;
109
112
  const nested = type === "object" ? nestedFields(schema) : type === "array" && getSchemaTypeName(unwrapSchema(getSchemaDef(schema)?.element)) === "ZodObject" ? nestedFields(getSchemaDef(schema)?.element) : null;
110
113
  return field({
111
114
  key,
@@ -115,7 +118,7 @@ function fieldFromMetadata(key, metadata, sourceSchema, schema, localized, requi
115
118
  default: defaultFromSchema(sourceSchema, key),
116
119
  options: metadata.options ?? null,
117
120
  relation: metadata.relation ? { collection: metadata.relation.collectionId, multiple: metadata.relation.multiple ?? type === "relations" } : null,
118
- media: metadata.image || metadata.asset ? { mediaTypes: portableMediaTypes(metadata.image?.accept ?? metadata.asset?.accept), aspectRatio: metadata.image?.aspectRatio ?? null } : null,
121
+ media: metadata.image ? { mediaTypes: portableMediaTypes(metadata.image.accept), aspectRatio: metadata.image.aspectRatio ?? null } : null,
119
122
  fields: nested,
120
123
  validation: validationFromSchema(sourceSchema, type),
121
124
  slugFrom: metadata.slugFrom ?? null
@@ -296,7 +299,7 @@ function validateFieldLevel(collection, fields) {
296
299
  if (candidate.relation && (!candidate.relation.collection || candidate.relation.multiple !== (candidate.type === "relations"))) {
297
300
  throw new Error(`Field "${candidate.key}" has invalid relation cardinality or target.`);
298
301
  }
299
- if (candidate.media && !["image", "images", "file"].includes(candidate.type)) throw new Error(`Field "${candidate.key}" has media policy for type "${candidate.type}".`);
302
+ if (candidate.media && !["image", "images"].includes(candidate.type)) throw new Error(`Field "${candidate.key}" has media policy for type "${candidate.type}".`);
300
303
  if (candidate.fields && !["object", "array", "blocks"].includes(candidate.type)) throw new Error(`Field "${candidate.key}" has nested fields for type "${candidate.type}".`);
301
304
  if (candidate.options && !["select", "multiselect", "radio"].includes(candidate.type)) throw new Error(`Field "${candidate.key}" has options for type "${candidate.type}".`);
302
305
  if ((candidate.min !== null || candidate.max !== null || candidate.step !== null) && !["number", "range"].includes(candidate.type)) {
@@ -374,8 +377,16 @@ function dataFormat(collection) {
374
377
  return formats[0] ?? "yaml";
375
378
  }
376
379
  function portableMediaTypes(values) {
377
- const allowed = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
378
- return unique(values ?? []).filter((value) => allowed.has(value));
380
+ if (!values) return [...CONTENT_MANAGED_MEDIA_TYPES];
381
+ const allowed = new Set(CONTENT_MANAGED_MEDIA_TYPES);
382
+ const normalized = unique(values);
383
+ const unsupported = normalized.filter((value) => !allowed.has(value));
384
+ if (unsupported.length > 0) {
385
+ throw new Error(
386
+ `Managed asset media types are limited to ${CONTENT_MANAGED_MEDIA_TYPES.join(", ")}; unsupported: ${unsupported.join(", ")}.`
387
+ );
388
+ }
389
+ return normalized;
379
390
  }
380
391
  function normalizeComponentPolicy(policy) {
381
392
  const components = {};
@@ -7,8 +7,9 @@
7
7
  * or browser.
8
8
  *
9
9
  * Consumers use this surface to:
10
- * - normalize a host's `content.config.ts` into the one portable
11
- * `ResolvedContentContractV1` artifact (`buildResolvedContentContract`),
10
+ * - build a framework-free `ResolvedContentContractV1` from already resolved
11
+ * inputs (`buildResolvedContentContract`); Nuxt applications consume the
12
+ * generated artifact through the separate Node subpath,
12
13
  * - produce RFC 8785 canonical JSON and incremental SHA-256 hashes without
13
14
  * relying on Node or Web Crypto,
14
15
  * - introspect Zod schemas without re-implementing the walker
@@ -23,11 +24,11 @@
23
24
  */
24
25
  export { RESOLVED_CONTENT_CONTRACT_VERSION, buildResolvedContentContract, type BuildResolvedContentContractInput, type BuildResolvedContentContractOptions, } from './build.js';
25
26
  export type { PortableComponentPolicyV1, PortableMediaType, ResolvedContentCollectionV1, ResolvedContentContractV1, ResolvedContentFieldTypeV1, ResolvedContentFieldV1, ResolvedContentValidationV1, ContentCmsCollectionConfig, ContentCmsFieldConfig, ContentCmsFieldType, ContentCmsRelationConfig, ContentCollectionConfig, ContentCollectionI18nConfig, } from './types.js';
26
- export { describeId, generatePath, generateCanonicalKey, generateTitle, isDraftPath, isPartialPath, mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, refineUrlPart, routeRemainder, routeToContentPathCandidates, slugifyUrlSegment, stripLocalePrefix, } from './path.js';
27
+ export { describeId, generatePath, generateCanonicalKey, generateTitle, isDraftPath, isPartialPath, mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, refineUrlPart, routeRemainder, slugifyUrlSegment, stripLocalePrefix, } from './path.js';
27
28
  export { CONTENT_FIELD_METADATA_KEY, CONTENT_REFERENCE_METADATA_KEY, collectTopLevelReferenceFields, getContentFieldMetadata, getObjectShape, getReferenceDescriptor, getSchemaDef, getSchemaTypeName, unwrapSchema, type ContentFieldMetadata, type ContentFieldSchema, } from './schema.js';
28
29
  export { parseMdcBody, type ParseMdcBodyOptions, type ParseMdcBodyResult, } from './mdc.js';
29
30
  export { assertPublicMarkdownAst, isSafePublicMarkdownUrl, PublicMarkdownValidationError, validatePublicMarkdownAst, type PublicMarkdownIssue, type PublicMarkdownIssueCode, type PublicMarkdownValidationResult, } from './render-policy.js';
30
31
  export { verifyPublicImageBytes, type VerifiedPublicImage, } from './asset-bytes.js';
31
32
  export { canonicalJsonBytes, hashCanonicalJson, sha256Hex, IncrementalSha256, type JsonPrimitive, type JsonValue, } from './hash.js';
32
33
  export { assertResolvedContentContract } from './validate.js';
33
- export { assertCmsRequestedFacts, cmsPublicEntryWireSchema, parseCmsListWireResult, parseCmsNavWireResult, parseCmsPageWireResult, parseCmsRoutesWireResult, parseCmsSearchWireResult, parseCmsSiteDataWireResult, parseCmsSurroundWireResult, type CmsPublicEntryWire, } from './provider-wire.js';
34
+ export { CMS_PROVIDER_WIRE_PROTOCOL, assertCmsRequestedFacts, cmsPublicEntryWireSchema, createCmsProviderWireEnvelope, parseCmsListWireResult, parseCmsNavWireResult, parseCmsPageWireResult, parseCmsRoutesWireResult, parseCmsSearchWireResult, parseCmsSiteDataWireResult, parseCmsSurroundWireResult, type CmsPublicEntryWire, type CmsProviderWireEnvelope, } from './provider-wire.js';
@@ -15,7 +15,6 @@ export {
15
15
  prefixPathWithLocale,
16
16
  refineUrlPart,
17
17
  routeRemainder,
18
- routeToContentPathCandidates,
19
18
  slugifyUrlSegment,
20
19
  stripLocalePrefix
21
20
  } from "./path.js";
@@ -50,8 +49,10 @@ export {
50
49
  } from "./hash.js";
51
50
  export { assertResolvedContentContract } from "./validate.js";
52
51
  export {
52
+ CMS_PROVIDER_WIRE_PROTOCOL,
53
53
  assertCmsRequestedFacts,
54
54
  cmsPublicEntryWireSchema,
55
+ createCmsProviderWireEnvelope,
55
56
  parseCmsListWireResult,
56
57
  parseCmsNavWireResult,
57
58
  parseCmsPageWireResult,
@@ -6,5 +6,5 @@
6
6
  *
7
7
  * All re-exports MUST be runtime-pure (no Node, no Nuxt, no h3). They are.
8
8
  */
9
- export { describeId, generatePath, generateCanonicalKey, generateTitle, isDraftPath, isPartialPath, normalizeContentPath, normalizeRouteMounts, routeRemainder, mountContentPath, prefixPathWithLocale, stripLocalePrefix, refineUrlPart, routeToContentPathCandidates, } from '../core/content/path.js';
9
+ export { describeId, generatePath, generateCanonicalKey, generateTitle, isDraftPath, isPartialPath, normalizeContentPath, normalizeRouteMounts, routeRemainder, mountContentPath, prefixPathWithLocale, stripLocalePrefix, refineUrlPart, } from '../core/content/path.js';
10
10
  export { slugifyUrlSegment } from '../core/content/slug.js';
@@ -11,7 +11,6 @@ export {
11
11
  mountContentPath,
12
12
  prefixPathWithLocale,
13
13
  stripLocalePrefix,
14
- refineUrlPart,
15
- routeToContentPathCandidates
14
+ refineUrlPart
16
15
  } from "../core/content/path.js";
17
16
  export { slugifyUrlSegment } from "../core/content/slug.js";
@@ -1,4 +1,12 @@
1
1
  import { z } from 'zod';
2
+ /** Version shared by separately deployed CMS producers and Ginko Content consumers. */
3
+ export declare const CMS_PROVIDER_WIRE_PROTOCOL: "ginko-content-cms/v1";
4
+ export type CmsProviderWireEnvelope<T> = {
5
+ protocol: typeof CMS_PROVIDER_WIRE_PROTOCOL;
6
+ result: T;
7
+ };
8
+ /** Wrap a CMS result at the deployment boundary without exposing backend details. */
9
+ export declare const createCmsProviderWireEnvelope: <T>(result: T) => CmsProviderWireEnvelope<T>;
2
10
  export declare const cmsPublicEntryWireSchema: z.ZodPipe<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>, z.ZodObject<{
3
11
  id: z.ZodString;
4
12
  collection: z.ZodString;
@@ -10,6 +10,11 @@ const wireLimits = {
10
10
  totalStringBytes: 8 * 1024 * 1024
11
11
  };
12
12
  const encoder = new TextEncoder();
13
+ export const CMS_PROVIDER_WIRE_PROTOCOL = "ginko-content-cms/v1";
14
+ export const createCmsProviderWireEnvelope = (result) => ({
15
+ protocol: CMS_PROVIDER_WIRE_PROTOCOL,
16
+ result
17
+ });
13
18
  const wireLimitError = (reason) => new TypeError(`CMS wire value exceeds its bounded ${reason} limit.`);
14
19
  function assertBoundedWireValue(root) {
15
20
  const stack = [{ value: root, depth: 0 }];
@@ -256,9 +261,13 @@ const parse = (schema, operation, value) => {
256
261
  const message = error instanceof Error ? error.message : "CMS wire value is not bounded.";
257
262
  throw new TypeError(`Invalid CMS ${operation} wire result at result: ${message}`);
258
263
  }
259
- const result = schema.safeParse(value);
260
- if (result.success) return result.data;
261
- const issue = result.error.issues[0];
264
+ const envelopeSchema = z.object({
265
+ protocol: z.literal(CMS_PROVIDER_WIRE_PROTOCOL),
266
+ result: schema
267
+ }).strict();
268
+ const parsed = envelopeSchema.safeParse(value);
269
+ if (parsed.success) return parsed.data.result;
270
+ const issue = parsed.error.issues[0];
262
271
  const path = issue?.path.length ? issue.path.join(".") : "result";
263
272
  throw new TypeError(`Invalid CMS ${operation} wire result at ${path}: ${issue?.message || "invalid value"}`);
264
273
  };
@@ -1,9 +1,10 @@
1
1
  import type { ContentCmsCollectionConfig, ContentCmsFieldConfig, ContentCmsFieldType, ContentCmsRelationConfig, ContentCollectionConfig, ContentCollectionI18nConfig } from '../types/config.js';
2
+ import type { ContentManagedMediaType } from '../types/fields.js';
2
3
  import type { PortableComponentPolicyV1 } from '../types/component-policy.js';
3
4
  import type { JsonValue } from './hash.js';
4
5
  export type { ContentCmsCollectionConfig, ContentCmsFieldConfig, ContentCmsFieldType, ContentCmsRelationConfig, ContentCollectionConfig, ContentCollectionI18nConfig, PortableComponentPolicyV1, };
5
- export type PortableMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
6
- export type ResolvedContentFieldTypeV1 = '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';
6
+ export type PortableMediaType = ContentManagedMediaType;
7
+ export type ResolvedContentFieldTypeV1 = 'text' | 'textarea' | 'richtext' | 'slug' | 'email' | 'url' | 'number' | 'range' | 'select' | 'multiselect' | 'radio' | 'checkbox' | 'toggle' | 'date' | 'datetime' | 'time' | 'json' | 'object' | 'array' | 'blocks' | 'relation' | 'relations' | 'image' | 'images' | 'icon' | 'code' | 'color';
7
8
  export type ResolvedContentValidationV1 = {
8
9
  kind: 'string';
9
10
  minLength: number | null;
@@ -29,7 +29,6 @@ const fieldTypes = /* @__PURE__ */ new Set([
29
29
  "relations",
30
30
  "image",
31
31
  "images",
32
- "file",
33
32
  "icon",
34
33
  "code",
35
34
  "color"
@@ -159,7 +158,7 @@ function field(value, path) {
159
158
  }
160
159
  function validateFieldPolicy(input, path) {
161
160
  if (input.relation && (!["relation", "relations"].includes(input.type) || input.relation.multiple !== (input.type === "relations"))) throw new Error(`${path} has invalid relation policy.`);
162
- if (input.media && !["image", "images", "file"].includes(input.type)) throw new Error(`${path} has invalid media policy.`);
161
+ if (input.media && !["image", "images"].includes(input.type)) throw new Error(`${path} has invalid media policy.`);
163
162
  if (input.fields && !["object", "array", "blocks"].includes(input.type)) throw new Error(`${path} has invalid nested field policy.`);
164
163
  if (input.options && !["select", "multiselect", "radio"].includes(input.type)) throw new Error(`${path} has invalid options policy.`);
165
164
  if ((input.min !== null || input.max !== null || input.step !== null) && !["number", "range"].includes(input.type)) throw new Error(`${path} has invalid numeric policy.`);
@@ -0,0 +1,11 @@
1
+ import type { ResolvedContentContractV1 } from '../cms-contract/types.js';
2
+ export declare const RESOLVED_CONTENT_CONTRACT_ARTIFACT: ".ginko/content-contract.json";
3
+ export interface ResolvedContentContractArtifact {
4
+ contract: ResolvedContentContractV1;
5
+ sha256: string;
6
+ }
7
+ export interface ReadResolvedContentContractOptions {
8
+ root: string;
9
+ }
10
+ export declare function readResolvedContentContract(options: ReadResolvedContentContractOptions): Promise<ResolvedContentContractArtifact>;
11
+ export declare function writeResolvedContentContractArtifact(root: string, contract: ResolvedContentContractV1): Promise<ResolvedContentContractArtifact>;
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { canonicalJsonBytes, hashCanonicalJson } from "../cms-contract/hash.js";
5
+ import { PORTABLE_CONTENT_LIMITS } from "../cms-contract/limits.js";
6
+ import { assertResolvedContentContract } from "../cms-contract/validate.js";
7
+ import { readStableRegularFile, StableFileError } from "./stable-file.js";
8
+ export const RESOLVED_CONTENT_CONTRACT_ARTIFACT = ".ginko/content-contract.json";
9
+ const artifactError = (message) => new TypeError(`Resolved Content contract artifact ${message}`);
10
+ function artifactPath(root) {
11
+ if (!root) throw artifactError("requires a project root.");
12
+ return join(resolve(root), ".ginko", "content-contract.json");
13
+ }
14
+ export async function readResolvedContentContract(options) {
15
+ const path = artifactPath(options.root);
16
+ try {
17
+ const directory = join(resolve(options.root), ".ginko");
18
+ const directoryStats = await lstat(directory);
19
+ if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
20
+ throw artifactError("directory is unsafe.");
21
+ }
22
+ const before = await lstat(path);
23
+ const bytes = await readStableRegularFile(path, before, PORTABLE_CONTENT_LIMITS.contractBytes);
24
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
25
+ const contract = assertResolvedContentContract(value);
26
+ return {
27
+ contract,
28
+ sha256: await hashCanonicalJson(contract)
29
+ };
30
+ } catch (error) {
31
+ if (error instanceof StableFileError) {
32
+ throw artifactError(error.reason === "limit" ? "exceeds its byte limit." : "is not a safe regular file.");
33
+ }
34
+ if (error instanceof Error && error.message.startsWith("Resolved Content contract artifact ")) throw error;
35
+ throw artifactError("is missing or invalid.");
36
+ }
37
+ }
38
+ export async function writeResolvedContentContractArtifact(root, contract) {
39
+ const validated = assertResolvedContentContract(contract);
40
+ const canonical = canonicalJsonBytes(validated);
41
+ if (canonical.byteLength + 1 > PORTABLE_CONTENT_LIMITS.contractBytes) {
42
+ throw artifactError("exceeds its byte limit.");
43
+ }
44
+ const directory = join(resolve(root), ".ginko");
45
+ const path = artifactPath(root);
46
+ const temporary = join(directory, `.content-contract.${process.pid}.${randomUUID()}.tmp`);
47
+ await mkdir(directory, { recursive: true, mode: 448 });
48
+ const directoryStats = await lstat(directory);
49
+ if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
50
+ throw artifactError("directory is unsafe.");
51
+ }
52
+ const bytes = new Uint8Array(canonical.byteLength + 1);
53
+ bytes.set(canonical);
54
+ bytes[bytes.length - 1] = 10;
55
+ let handle;
56
+ try {
57
+ handle = await open(temporary, "wx", 384);
58
+ await handle.writeFile(bytes);
59
+ await handle.sync();
60
+ await handle.close();
61
+ handle = void 0;
62
+ await rename(temporary, path);
63
+ } catch (error) {
64
+ if (error instanceof Error && error.message.startsWith("Resolved Content contract artifact ")) throw error;
65
+ throw artifactError("could not be written safely.");
66
+ } finally {
67
+ await handle?.close();
68
+ await rm(temporary, { force: true });
69
+ }
70
+ return {
71
+ contract: validated,
72
+ sha256: await hashCanonicalJson(validated)
73
+ };
74
+ }
@@ -0,0 +1 @@
1
+ export { RESOLVED_CONTENT_CONTRACT_ARTIFACT, readResolvedContentContract, type ReadResolvedContentContractOptions, type ResolvedContentContractArtifact, } from './artifact.js';
@@ -0,0 +1,4 @@
1
+ export {
2
+ RESOLVED_CONTENT_CONTRACT_ARTIFACT,
3
+ readResolvedContentContract
4
+ } from "./artifact.js";
@@ -0,0 +1,7 @@
1
+ import { type Stats } from 'node:fs';
2
+ export type StableFileFailure = 'limit' | 'unsafe';
3
+ export declare class StableFileError extends Error {
4
+ readonly reason: StableFileFailure;
5
+ constructor(reason: StableFileFailure);
6
+ }
7
+ export declare function readStableRegularFile(path: string, before: Stats, maximumBytes: number): Promise<Uint8Array>;
@@ -0,0 +1,44 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ export class StableFileError extends Error {
4
+ constructor(reason) {
5
+ super(`Stable file ${reason}.`);
6
+ this.reason = reason;
7
+ this.name = "StableFileError";
8
+ }
9
+ reason;
10
+ }
11
+ export async function readStableRegularFile(path, before, maximumBytes) {
12
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
13
+ throw new StableFileError("unsafe");
14
+ }
15
+ if (before.size > maximumBytes) throw new StableFileError("limit");
16
+ let handle;
17
+ try {
18
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
19
+ const opened = await handle.stat();
20
+ assertSameFile(before, opened);
21
+ const buffer = new Uint8Array(maximumBytes + 1);
22
+ let offset = 0;
23
+ while (offset < buffer.byteLength) {
24
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
25
+ if (bytesRead === 0) break;
26
+ offset += bytesRead;
27
+ }
28
+ const after = await handle.stat();
29
+ assertSameFile(opened, after);
30
+ if (offset !== after.size) throw new StableFileError("unsafe");
31
+ if (offset > maximumBytes) throw new StableFileError("limit");
32
+ return buffer.slice(0, offset);
33
+ } catch (error) {
34
+ if (error instanceof StableFileError) throw error;
35
+ throw new StableFileError("unsafe");
36
+ } finally {
37
+ await handle?.close();
38
+ }
39
+ }
40
+ function assertSameFile(left, right) {
41
+ if (left.dev !== right.dev || left.ino !== right.ino || left.size !== right.size || left.mtimeMs !== right.mtimeMs || !right.isFile() || right.nlink !== 1) {
42
+ throw new StableFileError("unsafe");
43
+ }
44
+ }
@@ -51,21 +51,4 @@ export declare const lowerRouteToCanonicalCandidates: (route: string, requestedL
51
51
  locale: string;
52
52
  contentPath: string;
53
53
  }[];
54
- /**
55
- * Released CMS-contract adapter returning mounted provider coordinates.
56
- *
57
- * This function GUESSES: it unions locales from the mount map, the requested
58
- * chain, and the default locale, and it substitutes `/` for any locale with no
59
- * declared mount. The content engine deliberately does not call it — route
60
- * lowering goes through `lowerRouteToCandidates`
61
- * (`features/localization/route-projector.ts`), which validates the resolved
62
- * locale's configured mount and fails instead of substituting one.
63
- *
64
- * It exists only so CMS adapters compiled against the released contract keep
65
- * working. New code should use the resolved collection locale policy.
66
- */
67
- export declare const routeToContentPathCandidates: (route: string, requestedLocale: string | undefined, localeChain: string[], defaultLocale?: string, mounts?: RouteMounts) => {
68
- locale: string;
69
- path: string;
70
- }[];
71
54
  export declare const pathHasLocalePrefix: (path: string, locales: string[]) => boolean;
@@ -193,22 +193,4 @@ export const lowerRouteToCanonicalCandidates = (route, requestedLocale, localeCh
193
193
  contentPath
194
194
  }));
195
195
  };
196
- export const routeToContentPathCandidates = (route, requestedLocale, localeChain, defaultLocale, mounts) => {
197
- const locales = Array.from(new Set([...Object.keys(mounts || {}), ...localeChain, defaultLocale].filter(Boolean)));
198
- if (!mounts) {
199
- const stripped = stripLocalePrefix(route, locales, defaultLocale, requestedLocale);
200
- return localeChain.map((locale) => ({ locale, path: stripped.path }));
201
- }
202
- return lowerRouteToCanonicalCandidates(
203
- route,
204
- requestedLocale,
205
- localeChain,
206
- defaultLocale || localeChain[0] || "",
207
- locales,
208
- (locale) => mounts[locale] || "/"
209
- ).map((candidate) => ({
210
- locale: candidate.locale,
211
- path: mountContentPath(candidate.contentPath, candidate.locale, mounts)
212
- }));
213
- };
214
196
  export const pathHasLocalePrefix = isLocalePrefixedPath;
@@ -1,4 +1,4 @@
1
- export type ContentDataSourceErrorCode = 'QUERY_CURSOR_INVALID' | 'BACKEND_FAILURE';
1
+ export type ContentDataSourceErrorCode = 'QUERY_CURSOR_INVALID' | 'QUERY_UNSUPPORTED' | 'BACKEND_FAILURE';
2
2
  export declare const createContentDataSourceError: (code: ContentDataSourceErrorCode) => Error;
3
3
  export declare const isContentDataSourceError: (value: unknown) => value is Error & {
4
4
  code: ContentDataSourceErrorCode;
@@ -1,7 +1,7 @@
1
1
  class ContentDataSourceError extends Error {
2
2
  code;
3
3
  constructor(code) {
4
- super(code === "QUERY_CURSOR_INVALID" ? "Content data-source query cursor is invalid." : "Content data-source operation failed.");
4
+ super(code === "QUERY_CURSOR_INVALID" ? "Content data-source query cursor is invalid." : code === "QUERY_UNSUPPORTED" ? "Content data-source query is unsupported." : "Content data-source operation failed.");
5
5
  this.name = "ContentDataSourceError";
6
6
  this.code = code;
7
7
  }
@@ -4,6 +4,21 @@ import { getGraphCanonicalVariants, resolveGraphCanonicalKey, resolveGraphRouteV
4
4
  import { ensureArray, get, projectDocumentFields, sortList } from "./operators.js";
5
5
  import { createCoreProviderError } from "../provider-errors.js";
6
6
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ const regexFlags = /* @__PURE__ */ new Set(["d", "g", "i", "m", "s", "u", "y"]);
8
+ const parseRegexLiteral = (value) => {
9
+ if (!value.startsWith("/")) return null;
10
+ const closingSlash = value.lastIndexOf("/");
11
+ if (closingSlash === 0) return null;
12
+ const flags = value.slice(closingSlash + 1);
13
+ if ([...flags].some((flag) => !regexFlags.has(flag)) || new Set(flags).size !== flags.length) {
14
+ throw createCoreProviderError(
15
+ "unsupported_query_shape",
16
+ "The query contains a malformed regular-expression literal.",
17
+ { field: "where.$regex" }
18
+ );
19
+ }
20
+ return [value.slice(1, closingSlash), flags];
21
+ };
7
22
  const reviveRegex = (value) => isPlanRegex(value) ? new RegExp(value.source, value.flags) : value;
8
23
  const includesEntry = (haystack, entry) => typeof haystack === "string" ? isPlanRegex(entry) ? reviveRegex(entry).test(haystack) : haystack.includes(String(entry)) : haystack.some((value) => compareOperators.eq(value, entry));
9
24
  const compareOperators = {
@@ -48,8 +63,9 @@ const compareOperators = {
48
63
  "$regex operand must be a plain string or a tagged { __ginkoContentQueryValue: 'RegExp', source, flags } wire value produced by the current PROVIDER_QUERY_VERSION lowering; received an untagged object (likely an old-wire { source, flags } regex predating PROVIDER_QUERY_VERSION)"
49
64
  );
50
65
  }
51
- const matched = String(operand).match(/\/(.*)\/([dgimsuy]*)$/);
52
- const regex = matched?.[1] ? new RegExp(matched[1], matched[2] || "") : new RegExp(String(operand));
66
+ const serialized = String(operand);
67
+ const literal = parseRegexLiteral(serialized);
68
+ const regex = literal ? new RegExp(literal[0], literal[1]) : new RegExp(serialized);
53
69
  return regex.test(String(item || ""));
54
70
  }
55
71
  };
@@ -1,7 +1,14 @@
1
1
  import { isMarkdownRoot, mapMarkdownNode } from "../markdown/tree.js";
2
2
  export const CONTENT_REF_LINK_PREFIX = "$";
3
3
  const MARKDOWN_LINK_PROP_KEYS = ["href", "to"];
4
- export const normalizeReferenceValue = (value) => String(value).replace(/^\/+|\/+$/g, "");
4
+ export const normalizeReferenceValue = (value) => {
5
+ const normalized = String(value);
6
+ let start = 0;
7
+ let end = normalized.length;
8
+ while (start < end && normalized[start] === "/") start += 1;
9
+ while (end > start && normalized[end - 1] === "/") end -= 1;
10
+ return normalized.slice(start, end);
11
+ };
5
12
  export const parseRefLink = (value) => {
6
13
  if (typeof value !== "string" || !value.startsWith(CONTENT_REF_LINK_PREFIX)) {
7
14
  return null;
@@ -1,6 +1,6 @@
1
1
  import { type RuntimeContentI18nInput } from './config';
2
- import { mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, routeRemainder, routeToContentPathCandidates, stripLocalePrefix, type RouteMounts } from '../../core/content/path';
3
- export { mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, routeRemainder, routeToContentPathCandidates, stripLocalePrefix, type RouteMounts };
2
+ import { mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, routeRemainder, stripLocalePrefix, type RouteMounts } from '../../core/content/path';
3
+ export { mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, routeRemainder, stripLocalePrefix, type RouteMounts };
4
4
  export declare const fallbackStem: (path: string) => string;
5
5
  export declare const getContentStem: (path: string, file?: string) => string;
6
6
  export declare const resolveCollectionI18n: (collection: string, content: RuntimeContentI18nInput) => {
@@ -6,7 +6,6 @@ import {
6
6
  pathHasLocalePrefix,
7
7
  prefixPathWithLocale,
8
8
  routeRemainder,
9
- routeToContentPathCandidates,
10
9
  stripLocalePrefix
11
10
  } from "../../core/content/path.js";
12
11
  export {
@@ -15,7 +14,6 @@ export {
15
14
  normalizeRouteMounts,
16
15
  prefixPathWithLocale,
17
16
  routeRemainder,
18
- routeToContentPathCandidates,
19
17
  stripLocalePrefix
20
18
  };
21
19
  const NON_LOCALIZED_PREFIXES = ["/api", "/llms", "/raw"];
@@ -6,4 +6,4 @@ export declare const toCanonicalQueryPlan: (plan: LoweredQueryPlan, policy: Reso
6
6
  /** Convert a closed canonical plan to the mounted provider wire exactly once. */
7
7
  export declare const toContentProviderQueryPlan: (plan: CanonicalQueryPlan, policy: ResolvedCollectionLocalePolicy | undefined) => ContentProviderQueryPlan;
8
8
  /** Filesystem-provider boundary: mounted public wire back to canonical graph coordinates. */
9
- export declare const fromContentProviderQueryPlan: (plan: ContentProviderQueryPlan, policy: ResolvedCollectionLocalePolicy | undefined) => CanonicalQueryPlan;
9
+ export declare const fromContentProviderQueryPlan: (plan: ContentProviderQueryPlan, collection: string | null, policy: ResolvedCollectionLocalePolicy | undefined) => CanonicalQueryPlan;
@@ -62,9 +62,10 @@ export const toCanonicalQueryPlan = (plan, policy) => {
62
62
  };
63
63
  };
64
64
  export const toContentProviderQueryPlan = (plan, policy) => {
65
+ const { collection: _collection, ...withoutCollection } = plan;
65
66
  const selector = plan.variant;
66
67
  if (!selector) {
67
- const { variant: _variant, ...provider } = plan;
68
+ const { variant: _variant, ...provider } = withoutCollection;
68
69
  return provider;
69
70
  }
70
71
  const resolvedPolicy = requirePolicy(policy);
@@ -93,12 +94,16 @@ export const toContentProviderQueryPlan = (plan, policy) => {
93
94
  } else {
94
95
  variant = selector;
95
96
  }
96
- return { ...plan, variant };
97
+ return { ...withoutCollection, variant };
97
98
  };
98
- export const fromContentProviderQueryPlan = (plan, policy) => {
99
+ export const fromContentProviderQueryPlan = (plan, collection, policy) => {
100
+ const planWithCollection = {
101
+ ...plan,
102
+ ...collection ? { collection } : {}
103
+ };
99
104
  const selector = plan.variant;
100
105
  if (!selector) {
101
- const { variant: _variant, ...canonical } = plan;
106
+ const { variant: _variant, ...canonical } = planWithCollection;
102
107
  return canonical;
103
108
  }
104
109
  const resolvedPolicy = requirePolicy(policy);
@@ -106,7 +111,7 @@ export const fromContentProviderQueryPlan = (plan, policy) => {
106
111
  const operation = operationLocales(selector, resolvedPolicy);
107
112
  const { path, ...options } = selector;
108
113
  return {
109
- ...plan,
114
+ ...planWithCollection,
110
115
  variant: {
111
116
  ...options,
112
117
  canonicalPath: unmountProviderContentPath(
@@ -119,7 +124,7 @@ export const fromContentProviderQueryPlan = (plan, policy) => {
119
124
  }
120
125
  if (selector.by === "route") {
121
126
  return {
122
- ...plan,
127
+ ...planWithCollection,
123
128
  variant: {
124
129
  ...selector,
125
130
  candidates: selector.candidates.map((candidate) => ({
@@ -133,5 +138,5 @@ export const fromContentProviderQueryPlan = (plan, policy) => {
133
138
  }
134
139
  };
135
140
  }
136
- return { ...plan, variant: selector };
141
+ return { ...planWithCollection, variant: selector };
137
142
  };
package/dist/module.d.mts CHANGED
@@ -46,7 +46,7 @@ type ContentCollectionSource = string | string[];
46
46
  */
47
47
  type ContentCollectionRouteConfig = string | Record<string, string>;
48
48
  type ContentCollectionKind = 'page' | 'data';
49
- 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';
49
+ 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' | 'icon' | 'code' | 'color' | 'divider' | 'section';
50
50
  interface ContentCmsRelationConfig {
51
51
  collectionId: string;
52
52
  multiple?: boolean;
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-content",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "configKey": "content",
5
5
  "compatibility": {
6
6
  "nuxt": ">=4.5.1 <5"
package/dist/module.mjs CHANGED
@@ -2,6 +2,7 @@ import { addTypeTemplate, addImports, addServerImports, addComponentsDir, getLay
2
2
  import { readFile, readdir, rm } from 'node:fs/promises';
3
3
  import { join as join$1, basename, resolve as resolve$1, dirname, isAbsolute as isAbsolute$1 } from 'node:path';
4
4
  import { buildResolvedContentContract } from '../dist/cms-contract/index.js';
5
+ import { writeResolvedContentContractArtifact } from '../dist/cms-contract-node/artifact.js';
5
6
  import fs, { existsSync } from 'fs';
6
7
  import { createRequire } from 'node:module';
7
8
  import { join, relative, isAbsolute, resolve } from 'pathe';
@@ -33,7 +34,7 @@ import { assertCanonicalHighlightOptionNames } from '../dist/parsers/markdown-pl
33
34
  import { BUILTIN_MARKDOWN_RENDER_CONTRACTS } from '../dist/core/markdown/builtin-render-contracts.js';
34
35
 
35
36
  const name = "@lupinum/ginko-content";
36
- const version = "1.0.0-beta.2";
37
+ const version = "1.0.0-beta.3";
37
38
  const peerDependencies = {
38
39
  nuxt: ">=4.5.1 <5"};
39
40
 
@@ -2155,6 +2156,7 @@ const module$1 = defineNuxtModule({
2155
2156
  getSearchRuntime,
2156
2157
  siteUrl: resolveNuxtSiteUrl(nuxt)
2157
2158
  });
2159
+ await writeResolvedContentContractArtifact(nuxt.options.rootDir, contentContext.contract);
2158
2160
  registerContentContextFinalization({
2159
2161
  nuxt,
2160
2162
  options,