@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.
- package/README.md +3 -3
- package/dist/cms-contract/build.js +17 -6
- package/dist/cms-contract/index.d.ts +5 -4
- package/dist/cms-contract/index.js +2 -1
- package/dist/cms-contract/path.d.ts +1 -1
- package/dist/cms-contract/path.js +1 -2
- package/dist/cms-contract/provider-wire.d.ts +8 -0
- package/dist/cms-contract/provider-wire.js +12 -3
- package/dist/cms-contract/types.d.ts +3 -2
- package/dist/cms-contract/validate.js +1 -2
- package/dist/cms-contract-node/artifact.d.ts +11 -0
- package/dist/cms-contract-node/artifact.js +74 -0
- package/dist/cms-contract-node/index.d.ts +1 -0
- package/dist/cms-contract-node/index.js +4 -0
- package/dist/cms-contract-node/stable-file.d.ts +7 -0
- package/dist/cms-contract-node/stable-file.js +44 -0
- package/dist/core/content/path.d.ts +0 -17
- package/dist/core/content/path.js +0 -18
- package/dist/core/data-source-error.d.ts +1 -1
- package/dist/core/data-source-error.js +1 -1
- package/dist/core/query/execute.js +18 -2
- package/dist/core/references/resolve.js +8 -1
- package/dist/features/localization/path.d.ts +2 -2
- package/dist/features/localization/path.js +0 -2
- package/dist/features/query/query-plan-boundary.d.ts +1 -1
- package/dist/features/query/query-plan-boundary.js +12 -7
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +3 -1
- package/dist/portability/assets.js +2 -2
- package/dist/portability/documents.js +9 -2
- package/dist/portability-node/index.d.ts +1 -1
- package/dist/portability-node/index.js +1 -0
- package/dist/portability-node/read-directory.d.ts +17 -0
- package/dist/portability-node/read-directory.js +60 -10
- package/dist/portability-node/streams.d.ts +1 -1
- package/dist/portability-node/streams.js +6 -19
- package/dist/public/data-source.d.ts +3 -0
- package/dist/public/data-source.js +48 -0
- package/dist/public/provider-binder.js +19 -40
- package/dist/public/provider-query.d.ts +8 -7
- package/dist/public/provider-query.js +4 -3
- package/dist/runtime/server/providers/filesystem.js +2 -2
- package/dist/testing/data-source-contract.d.ts +32 -1
- package/dist/testing/data-source-contract.js +89 -4
- package/dist/testing/provider-contract.d.ts +27 -1
- package/dist/testing/provider-contract.js +59 -10
- package/dist/testing/provider-fixture.d.ts +3 -0
- package/dist/testing/provider-fixture.js +80 -2
- package/dist/types/config.d.ts +1 -1
- package/dist/types/fields.d.ts +5 -13
- package/dist/types/fields.js +6 -20
- package/dist/web-types.json +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
* -
|
|
11
|
-
*
|
|
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,
|
|
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,
|
|
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';
|
|
@@ -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
|
|
260
|
-
|
|
261
|
-
|
|
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 =
|
|
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' | '
|
|
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"
|
|
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,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
|
|
52
|
-
const
|
|
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) =>
|
|
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,
|
|
3
|
-
export { mountContentPath, normalizeContentPath, normalizeRouteMounts, prefixPathWithLocale, routeRemainder,
|
|
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 } =
|
|
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 { ...
|
|
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 } =
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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 { ...
|
|
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' | '
|
|
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
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.
|
|
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,
|