@lupinum/ginko-content 1.0.0-beta.7 → 1.0.0-beta.8

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 CHANGED
@@ -15,7 +15,7 @@
15
15
  </p>
16
16
 
17
17
  > [!WARNING]
18
- > Version `1.0.0-beta.7` is a prerelease. Install it from npm's `next`
18
+ > Version `1.0.0-beta.8` 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.7
43
+ npx nuxi module add @lupinum/ginko-content@1.0.0-beta.8
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.7
49
+ pnpm add @lupinum/ginko-content@1.0.0-beta.8
50
50
  ```
51
51
 
52
52
  ```ts
package/dist/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { stat, readFile, readdir } from 'node:fs/promises';
4
4
  import { join, extname, relative, resolve, isAbsolute } from 'node:path';
5
5
  import { loadNuxtConfig } from '@nuxt/kit';
6
6
  import { isContentValidationReport } from '../dist/features/validation/report.js';
7
+ import { assessFilesystemPortability, exportFilesystemToPortableDirectory, FilesystemPortabilityAssessmentError } from '../dist/portability-node/filesystem-export.js';
7
8
 
8
9
  const ignoredDirs = /* @__PURE__ */ new Set([
9
10
  ".git",
@@ -902,16 +903,45 @@ if (!command || command === "--help" || command === "-h") {
902
903
  "Commands:",
903
904
  " doctor [root] Validate a Nuxt Content v3 to Ginko migration",
904
905
  " doctor --i18n [root] Validate i18n migration wiring and generated output",
905
- " validate [root] Validate internal content links, anchors, and assets"
906
+ " validate [root] Validate internal content links, anchors, and assets",
907
+ " portable assess [root]",
908
+ " Assess filesystem content for portable V1 export",
909
+ " portable export <destination> [root]",
910
+ " Export filesystem content to a new portable V1 directory"
906
911
  ].join("\n"));
907
912
  process.exit(0);
908
913
  }
909
- if (command !== "doctor" && command !== "validate") {
914
+ if (command !== "doctor" && command !== "validate" && command !== "portable") {
910
915
  console.error(`Unknown command: ${command}`);
911
916
  console.error('Run "ginko-content --help" for available commands.');
912
917
  process.exit(1);
913
918
  }
914
919
  try {
920
+ if (command === "portable") {
921
+ const [operation, ...portableArgs] = args;
922
+ const unknownFlag2 = portableArgs.find((arg) => arg.startsWith("-"));
923
+ const hasExtraArguments = operation === "assess" ? portableArgs.length > 1 : portableArgs.length > 2;
924
+ if (unknownFlag2 || hasExtraArguments || operation !== "assess" && operation !== "export") {
925
+ console.error(unknownFlag2 ? `Unknown option: ${unknownFlag2}` : "Usage: ginko-content portable assess [root] | portable export <destination> [root]");
926
+ process.exit(1);
927
+ }
928
+ if (operation === "assess") {
929
+ const result3 = await assessFilesystemPortability({ rootDir: portableArgs[0] || process.cwd() });
930
+ for (const item of result3.diagnostics) console.error(`${item.severity.toUpperCase()} ${item.code}${item.file ? ` ${item.file}` : ""}${item.field ? `:${item.field}` : ""}: ${item.message} ${item.resolution}`);
931
+ if (result3.evidence) console.log(`Filesystem portability ${result3.ok ? "passed" : "failed"}: ${result3.summary.documents} document(s), ${result3.summary.assets} managed asset(s), input ${result3.evidence.inputHash}.`);
932
+ process.exit(result3.ok ? 0 : 2);
933
+ }
934
+ if (!portableArgs[0]) {
935
+ console.error("Usage: ginko-content portable export <destination> [root]");
936
+ process.exit(1);
937
+ }
938
+ const result2 = await exportFilesystemToPortableDirectory({
939
+ destination: portableArgs[0],
940
+ rootDir: portableArgs[1] || process.cwd()
941
+ });
942
+ console.log(`Portable V1 written to ${result2.directory}: ${result2.documents} document(s), ${result2.assets} managed asset(s), manifest ${result2.manifestSha256}.`);
943
+ process.exit(0);
944
+ }
915
945
  if (command === "validate") {
916
946
  const unknownFlag2 = args.find((arg) => arg.startsWith("-"));
917
947
  if (unknownFlag2) {
@@ -934,6 +964,10 @@ try {
934
964
  process.stdout.write(formatDoctorResult(result));
935
965
  process.exit(result.exitCode);
936
966
  } catch (error) {
967
+ if (error instanceof FilesystemPortabilityAssessmentError) {
968
+ for (const item of error.assessment.diagnostics) console.error(`${item.severity.toUpperCase()} ${item.code}${item.file ? ` ${item.file}` : ""}${item.field ? `:${item.field}` : ""}: ${item.message} ${item.resolution}`);
969
+ process.exit(2);
970
+ }
937
971
  console.error(error instanceof Error ? error.message : error);
938
972
  process.exit(1);
939
973
  }
@@ -219,6 +219,7 @@ export function isSafePublicMarkdownUrl(value, kind = "href") {
219
219
  });
220
220
  if (!input || input.startsWith("//") || input.includes("\\") || hasControlCharacter) return false;
221
221
  if (kind === "href" && input.startsWith("#")) return true;
222
+ if (kind === "href" && /^\$[^\s\\?#]+(?:#[^\s\\]*)?$/u.test(input)) return true;
222
223
  try {
223
224
  if (input.startsWith("/") || input.startsWith("./") || input.startsWith("../")) {
224
225
  return new URL(input, "https://ginko.invalid").origin === "https://ginko.invalid";
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-content",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "configKey": "content",
5
5
  "compatibility": {
6
6
  "nuxt": ">=4.5.1 <5"
package/dist/module.mjs CHANGED
@@ -34,7 +34,7 @@ import { assertCanonicalHighlightOptionNames } from '../dist/parsers/markdown-pl
34
34
  import { BUILTIN_MARKDOWN_RENDER_CONTRACTS } from '../dist/core/markdown/builtin-render-contracts.js';
35
35
 
36
36
  const name = "@lupinum/ginko-content";
37
- const version = "1.0.0-beta.7";
37
+ const version = "1.0.0-beta.8";
38
38
  const peerDependencies = {
39
39
  nuxt: ">=4.5.1 <5"};
40
40
 
@@ -0,0 +1,62 @@
1
+ import type { ResolvedContentContractV1 } from '../cms-contract/types.js';
2
+ import { GinkoBoundaryError, type PortabilityErrorCode, type PortabilityOperation } from '../portability/errors.js';
3
+ import type { PortableDocumentV1, PortableManifestV1 } from '../portability/model.js';
4
+ import { type PortableAssetWriteInput } from './write-directory.js';
5
+ export interface FilesystemPortabilityDiagnostic {
6
+ severity: 'error' | 'warning';
7
+ code: PortabilityErrorCode;
8
+ operation: PortabilityOperation;
9
+ collection: string | null;
10
+ file: string | null;
11
+ field: string | null;
12
+ message: string;
13
+ resolution: string;
14
+ }
15
+ export interface FilesystemPortabilityEvidence {
16
+ inputHash: string;
17
+ contractSha256: string;
18
+ packageVersion: string;
19
+ files: Array<{
20
+ file: string;
21
+ bytes: number;
22
+ sha256: string;
23
+ }>;
24
+ }
25
+ export interface FilesystemPortabilityAssessment {
26
+ ok: boolean;
27
+ rootDir: string;
28
+ contract: ResolvedContentContractV1 | null;
29
+ documents: PortableDocumentV1[];
30
+ assets: PortableAssetWriteInput[];
31
+ diagnostics: FilesystemPortabilityDiagnostic[];
32
+ evidence: FilesystemPortabilityEvidence | null;
33
+ summary: {
34
+ documents: number;
35
+ assets: number;
36
+ collections: string[];
37
+ locales: string[];
38
+ };
39
+ }
40
+ export interface FilesystemPortabilityExportResult {
41
+ directory: string;
42
+ inputHash: string;
43
+ contractSha256: string;
44
+ manifestSha256: string;
45
+ manifest: PortableManifestV1;
46
+ documents: number;
47
+ assets: number;
48
+ collections: string[];
49
+ locales: string[];
50
+ }
51
+ export declare class FilesystemPortabilityAssessmentError extends GinkoBoundaryError {
52
+ readonly assessment: FilesystemPortabilityAssessment;
53
+ constructor(assessment: FilesystemPortabilityAssessment);
54
+ }
55
+ export declare function assessFilesystemPortability(options: {
56
+ rootDir: string;
57
+ }): Promise<FilesystemPortabilityAssessment>;
58
+ export declare function exportFilesystemToPortableDirectory(options: {
59
+ rootDir: string;
60
+ destination: string;
61
+ expectedInputHash?: string;
62
+ }): Promise<FilesystemPortabilityExportResult>;
@@ -0,0 +1,740 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { resolve, join } from "node:path";
3
+ import defu from "defu";
4
+ import { globby } from "globby";
5
+ import jiti from "jiti";
6
+ import { buildResolvedContentContract } from "../cms-contract/build.js";
7
+ import { verifyPublicImageBytes } from "../cms-contract/asset-bytes.js";
8
+ import { canonicalJsonBytes, hashCanonicalJson, sha256Hex } from "../cms-contract/hash.js";
9
+ import { PORTABLE_CONTENT_LIMITS } from "../cms-contract/limits.js";
10
+ import { buildContentGraph } from "../core/content/graph.js";
11
+ import { expandDataLocaleVariants } from "../core/content/locale.js";
12
+ import { resolveCollections } from "../core/content/collection.js";
13
+ import { isNavigationFile } from "../core/content/structural.js";
14
+ import { resolveLocalePolicy } from "../features/localization/locale-policy.js";
15
+ import { resolveCollectionI18nConfig } from "../features/localization/config.js";
16
+ import { buildCanonicalNavigation } from "../features/navigation/build.js";
17
+ import { resolveSitemapCollections } from "../features/sitemap/counts.js";
18
+ import { parseComark } from "../core/markdown/parse-comark.js";
19
+ import csvParser from "../parsers/csv.js";
20
+ import jsonParser from "../parsers/json.js";
21
+ import pathMeta from "../parsers/path-meta.js";
22
+ import { stripReservedContentKeys } from "../parsers/reserved.js";
23
+ import yamlParser from "../parsers/yaml.js";
24
+ import { validatePortableDocument } from "../portability/documents.js";
25
+ import {
26
+ assertPortableAssetReference,
27
+ collectPortableAssetReferences,
28
+ collectPortableMdcAssetReferences
29
+ } from "../portability/assets.js";
30
+ import { GinkoBoundaryError } from "../portability/errors.js";
31
+ import { validatePortableReferences } from "../portability/references.js";
32
+ import { validateCollectionDocument, validateContentGraph, validateDocumentJsonPurity } from "../storage/validation.js";
33
+ import { readResolvedContentContract } from "../cms-contract-node/artifact.js";
34
+ import { readStableRegularFile, StableFileError } from "../cms-contract-node/stable-file.js";
35
+ import { writePortableDirectory } from "./write-directory.js";
36
+ import { readPortableDirectory } from "./read-directory.js";
37
+ const CONFIG_NAMES = ["content.config.ts", "content.config.mts", "content.config.js", "content.config.mjs", "content.config.cjs"];
38
+ const NUXT_CONFIG_NAMES = ["nuxt.config.ts", "nuxt.config.mts", "nuxt.config.js", "nuxt.config.mjs"];
39
+ const SOURCE_EXTENSIONS = /* @__PURE__ */ new Set(["md", "json", "json5", "yml", "yaml", "csv"]);
40
+ const filesystemExportDefaults = {
41
+ i18n: true,
42
+ sitemap: true,
43
+ search: false,
44
+ navigation: { fields: [] },
45
+ transformers: [],
46
+ yaml: {},
47
+ csv: { delimiter: ",", json: true },
48
+ respectPathCase: false
49
+ };
50
+ export class FilesystemPortabilityAssessmentError extends GinkoBoundaryError {
51
+ constructor(assessment) {
52
+ super({
53
+ operation: "directory.write",
54
+ code: "DOCUMENT_INVALID",
55
+ message: `Filesystem content is not portable (${assessment.diagnostics.filter((item) => item.severity === "error").length} error(s)).`,
56
+ details: { errors: assessment.diagnostics.filter((item) => item.severity === "error").length }
57
+ });
58
+ this.assessment = assessment;
59
+ }
60
+ assessment;
61
+ }
62
+ export async function assessFilesystemPortability(options) {
63
+ const rootDir = resolve(options.rootDir);
64
+ const diagnostics = [];
65
+ let contract = null;
66
+ let contractSha256 = "";
67
+ let packageVersion = "";
68
+ let collections = {};
69
+ let localePolicy = null;
70
+ let moduleOptions = filesystemExportDefaults;
71
+ try {
72
+ const loaded = await loadProjectConfig(rootDir);
73
+ collections = loaded.collections;
74
+ localePolicy = loaded.localePolicy;
75
+ moduleOptions = loaded.moduleOptions;
76
+ packageVersion = loaded.packageVersion;
77
+ const artifact = await readResolvedContentContract({ root: rootDir });
78
+ contract = artifact.contract;
79
+ contractSha256 = artifact.sha256;
80
+ const rebuilt = buildResolvedContentContract(
81
+ { collections },
82
+ {
83
+ defaultLocale: localePolicy.defaultLocale,
84
+ locales: [...localePolicy.locales],
85
+ localeFallbacks: Object.fromEntries(Object.entries(localePolicy.fallback).map(([key, value]) => [key, [...value]])),
86
+ translatedSlugs: localePolicy.translatedSlugs,
87
+ componentPolicy: moduleOptions.componentPolicy
88
+ }
89
+ );
90
+ const rebuiltHash = await hashCanonicalJson(rebuilt);
91
+ if (rebuiltHash !== artifact.sha256) {
92
+ diagnostic(
93
+ diagnostics,
94
+ "CONTRACT_HASH_MISMATCH",
95
+ "portability.hash",
96
+ null,
97
+ ".ginko/content-contract.json",
98
+ null,
99
+ "The resolved Content contract is stale for the current configuration.",
100
+ "Run the app's Nuxt prepare or generate command, then assess again."
101
+ );
102
+ }
103
+ if (loaded.provider !== "filesystem") {
104
+ diagnostic(
105
+ diagnostics,
106
+ "CONTRACT_INVALID",
107
+ "portability.hash",
108
+ null,
109
+ "content.config",
110
+ "provider",
111
+ `The configured provider is "${loaded.provider}"; this boundary exports filesystem content only.`,
112
+ "Switch to the filesystem source before export, or use the provider-owned export path."
113
+ );
114
+ }
115
+ if (moduleOptions.transformers?.length) {
116
+ diagnostic(
117
+ diagnostics,
118
+ "DOCUMENT_INVALID",
119
+ "portability.parse",
120
+ null,
121
+ "nuxt.config",
122
+ "content.transformers",
123
+ "Custom filesystem transformers cannot be reproduced by the portable source boundary.",
124
+ "Remove the transformer dependency or define a versioned portable representation for its output."
125
+ );
126
+ }
127
+ } catch (error) {
128
+ diagnostic(
129
+ diagnostics,
130
+ boundaryCode(error, "CONTRACT_INVALID"),
131
+ "portability.hash",
132
+ null,
133
+ null,
134
+ null,
135
+ message(error),
136
+ "Fix the project configuration and regenerate its resolved Content contract."
137
+ );
138
+ }
139
+ if (!contract || !localePolicy) return emptyAssessment(rootDir, diagnostics);
140
+ const sourceRoot = join(rootDir, "content");
141
+ const sourceFiles = [];
142
+ try {
143
+ const paths = (await globby("**/*", { cwd: sourceRoot, onlyFiles: true, dot: true, followSymbolicLinks: false })).sort();
144
+ if (paths.length > PORTABLE_CONTENT_LIMITS.documents + 1e3) {
145
+ diagnostic(
146
+ diagnostics,
147
+ "LIMIT_EXCEEDED",
148
+ "portability.parse",
149
+ null,
150
+ "content",
151
+ null,
152
+ "The filesystem source exceeds the bounded file count.",
153
+ "Reduce the source set or split the migration."
154
+ );
155
+ } else {
156
+ for (const file of paths) {
157
+ const extension = file.split(".").pop()?.toLowerCase() ?? "";
158
+ if (!SOURCE_EXTENSIONS.has(extension)) continue;
159
+ try {
160
+ const path = join(sourceRoot, ...file.split("/"));
161
+ const before = await lstat(path);
162
+ const bytes = await readStableRegularFile(path, before, PORTABLE_CONTENT_LIMITS.documentBytes);
163
+ sourceFiles.push({ file, bytes, sha256: await sha256Hex(bytes) });
164
+ } catch (error) {
165
+ diagnostic(
166
+ diagnostics,
167
+ error instanceof StableFileError && error.reason === "limit" ? "LIMIT_EXCEEDED" : "PATH_INVALID",
168
+ "portability.parse",
169
+ null,
170
+ `content/${file}`,
171
+ null,
172
+ "The source is not a stable, bounded regular file.",
173
+ "Replace symlinks and retry after source writes finish."
174
+ );
175
+ }
176
+ }
177
+ }
178
+ } catch (error) {
179
+ diagnostic(diagnostics, "PATH_INVALID", "portability.parse", null, "content", null, message(error), "Ensure the content directory exists and is readable.");
180
+ }
181
+ const parsed = [];
182
+ const rawById = /* @__PURE__ */ new Map();
183
+ for (const source of sourceFiles) {
184
+ const matches = resolveCollections(source.file, collections, [...contract.locales]);
185
+ const navigation = /(?:^|\/)\.navigation\.ya?ml$/u.test(source.file);
186
+ if (!navigation && matches.length === 0) {
187
+ diagnostic(
188
+ diagnostics,
189
+ "DOCUMENT_INVALID",
190
+ "portability.parse",
191
+ null,
192
+ `content/${source.file}`,
193
+ null,
194
+ "The source file does not match a configured collection.",
195
+ "Add it to one collection source or exclude/remove it intentionally."
196
+ );
197
+ continue;
198
+ }
199
+ if (matches.length > 1) {
200
+ diagnostic(
201
+ diagnostics,
202
+ "IDENTITY_CONFLICT",
203
+ "portability.parse",
204
+ null,
205
+ `content/${source.file}`,
206
+ null,
207
+ `The source file matches multiple collections: ${matches.join(", ")}.`,
208
+ "Make collection source globs unambiguous."
209
+ );
210
+ continue;
211
+ }
212
+ try {
213
+ const id = `content:${source.file}`;
214
+ const value = new TextDecoder("utf-8", { fatal: true }).decode(source.bytes);
215
+ rawById.set(id, value);
216
+ const document = await parseFilesystemSource(id, value, {
217
+ yaml: moduleOptions.yaml,
218
+ csv: moduleOptions.csv,
219
+ markdown: { plugins: [], tags: {}, anchorLinks: { depth: 4, exclude: [1] } },
220
+ pathMeta: {
221
+ locales: [...contract.locales],
222
+ defaultLocale: contract.defaultLocale,
223
+ translatedSlugs: localePolicy.translatedSlugs,
224
+ respectPathCase: moduleOptions.respectPathCase,
225
+ collections,
226
+ localePolicy: localePolicy.collections,
227
+ collectionResolver: (file) => resolveCollections(file, collections, [...contract.locales])[0]
228
+ }
229
+ });
230
+ const matched = document.collection ? collections[document.collection] : void 0;
231
+ const variants = expandDataLocaleVariants(document, matched?.i18n && matched.i18n !== true ? matched.i18n : void 0);
232
+ for (const variant of variants) {
233
+ const schema = validateCollectionDocument(variant, collections);
234
+ if (!schema.ok) throw schema.error;
235
+ const pure = validateDocumentJsonPurity(schema.value);
236
+ if (!pure.ok) throw pure.error;
237
+ parsed.push(pure.value);
238
+ }
239
+ } catch (error) {
240
+ diagnostic(
241
+ diagnostics,
242
+ boundaryCode(error, "DOCUMENT_INVALID"),
243
+ "portability.parse",
244
+ matches[0] ?? null,
245
+ `content/${source.file}`,
246
+ fieldFrom(error),
247
+ message(error),
248
+ "Fix the source document so it passes the configured parser and schema."
249
+ );
250
+ }
251
+ }
252
+ const graph = buildContentGraph(parsed, {
253
+ locales: [...contract.locales],
254
+ defaultLocale: contract.defaultLocale
255
+ });
256
+ const graphValidation = validateContentGraph(parsed, {
257
+ collections,
258
+ locales: [...contract.locales],
259
+ translatedSlugs: localePolicy.translatedSlugs,
260
+ localePolicy
261
+ });
262
+ if (!graphValidation.ok) {
263
+ const code = graphValidation.error.code === "INVALID_REF_VALUE" || graphValidation.error.code === "SCHEMA_VALIDATION_FAILED" ? "DOCUMENT_INVALID" : "IDENTITY_CONFLICT";
264
+ diagnostic(
265
+ diagnostics,
266
+ code,
267
+ "portability.validateReferences",
268
+ null,
269
+ fieldFile(graphValidation.error),
270
+ fieldFrom(graphValidation.error),
271
+ graphValidation.error.message,
272
+ "Resolve the identity, locale, route, or reference conflict in source content."
273
+ );
274
+ }
275
+ const navigationFields = moduleOptions.navigation === false ? [] : moduleOptions.navigation?.fields ?? [];
276
+ const navigationByVariant = buildNavigationMetadata(parsed, collections, contract, navigationFields);
277
+ const sitemapCollections = new Set(resolveSitemapCollections(collections, moduleOptions.sitemap === false ? false : normalizeSitemap(moduleOptions.sitemap)));
278
+ const documents = [];
279
+ for (const document of parsed) {
280
+ if (isFilesystemNavigationFile(document)) continue;
281
+ if (document.partial) {
282
+ diagnostic(
283
+ diagnostics,
284
+ "DOCUMENT_INVALID",
285
+ "portability.parse",
286
+ document.collection ?? null,
287
+ fieldFile(document),
288
+ null,
289
+ "Portable V1 cannot represent a standalone partial document.",
290
+ "Inline the partial, exclude it from the migration, or define a versioned portable contract change."
291
+ );
292
+ continue;
293
+ }
294
+ if (document.draft) {
295
+ diagnostic(
296
+ diagnostics,
297
+ "DOCUMENT_INVALID",
298
+ "portability.parse",
299
+ document.collection ?? null,
300
+ fieldFile(document),
301
+ "draft",
302
+ "Portable V1 does not encode source draft state.",
303
+ "Publish or remove the draft, or define a versioned portable contract change before migration."
304
+ );
305
+ continue;
306
+ }
307
+ const collectionId = document.collection;
308
+ const canonicalKey = document.canonicalKey;
309
+ const locale = document.locale;
310
+ if (!collectionId || !canonicalKey || !locale || !contract.collections[collectionId]) {
311
+ diagnostic(
312
+ diagnostics,
313
+ "DOCUMENT_INVALID",
314
+ "portability.parse",
315
+ collectionId ?? null,
316
+ fieldFile(document),
317
+ null,
318
+ "The parsed document lacks a collection, canonical key, or locale.",
319
+ "Make the source match one configured collection and locale."
320
+ );
321
+ continue;
322
+ }
323
+ try {
324
+ const collection = contract.collections[collectionId];
325
+ const fields = await portableFields(document, collection, graph, diagnostics);
326
+ const routeBacked = collection.kind === "page" && collection.routing.mode === "route";
327
+ const navigationKey = variantKey(collectionId, canonicalKey, locale);
328
+ const nav = navigationByVariant.get(navigationKey);
329
+ if (nav) {
330
+ for (const field of collection.fields.filter((item) => item.role !== "body")) {
331
+ if (!(field.key in nav)) continue;
332
+ (field.localized ? fields.localized : fields.shared)[field.key] = nav[field.key];
333
+ }
334
+ }
335
+ const body = document.type === "markdown" ? { kind: "mdc", source: markdownBody(requiredRawSource(rawById, document.id)) } : null;
336
+ const portable = {
337
+ format: "ginko-content-document",
338
+ version: 1,
339
+ collection: collectionId,
340
+ canonicalKey,
341
+ locale,
342
+ slug: routeBacked ? routeSlug(document, collection) : "",
343
+ parentCanonicalKey: routeBacked && collection.structure === "tree" ? treeParent(canonicalKey) : null,
344
+ order: routeBacked && collection.structure === "tree" ? treeOrder(canonicalKey) : null,
345
+ shared: fields.shared,
346
+ localized: fields.localized,
347
+ body,
348
+ visibility: routeBacked ? {
349
+ navigation: moduleOptions.navigation !== false && Boolean(nav),
350
+ search: searchVisible(moduleOptions.search, collectionId),
351
+ sitemap: sitemapCollections.has(collectionId) && document.sitemap !== false
352
+ } : { navigation: false, search: false, sitemap: false }
353
+ };
354
+ documents.push(validatePortableDocument(portable, contract));
355
+ } catch (error) {
356
+ diagnostic(
357
+ diagnostics,
358
+ boundaryCode(error, "DOCUMENT_INVALID"),
359
+ "portability.parse",
360
+ collectionId,
361
+ fieldFile(document),
362
+ fieldFrom(error),
363
+ message(error),
364
+ "Align the source value and collection contract; no value was discarded."
365
+ );
366
+ }
367
+ }
368
+ try {
369
+ validatePortableReferences(documents, contract);
370
+ } catch (error) {
371
+ diagnostic(
372
+ diagnostics,
373
+ boundaryCode(error, "REFERENCE_MISSING"),
374
+ "portability.validateReferences",
375
+ null,
376
+ null,
377
+ null,
378
+ message(error),
379
+ "Add the referenced entry or correct the source reference."
380
+ );
381
+ }
382
+ const sharedByIdentity = /* @__PURE__ */ new Map();
383
+ for (const document of documents) {
384
+ const identity = `${document.collection}\0${document.canonicalKey}`;
385
+ const hash = await hashCanonicalJson(document.shared);
386
+ const previous = sharedByIdentity.get(identity);
387
+ if (previous && previous !== hash) {
388
+ diagnostic(
389
+ diagnostics,
390
+ "SHARED_FIELD_DIVERGENCE",
391
+ "directory.verify",
392
+ document.collection,
393
+ null,
394
+ null,
395
+ `Shared fields differ between locale variants of "${document.canonicalKey}".`,
396
+ "Move translated values to localized fields or make the shared source values identical."
397
+ );
398
+ }
399
+ sharedByIdentity.set(identity, hash);
400
+ }
401
+ documents.sort((left, right) => variantKey(left.collection, left.canonicalKey, left.locale).localeCompare(variantKey(right.collection, right.canonicalKey, right.locale)));
402
+ const assets = await materializeManagedAssets(rootDir, documents, contract, diagnostics);
403
+ const evidenceFiles = sourceFiles.map(({ file, bytes, sha256 }) => ({ file: `content/${file}`, bytes: bytes.byteLength, sha256 }));
404
+ evidenceFiles.push(...assets.map((asset) => ({ file: asset.file, bytes: asset.bytes, sha256: asset.sha256 })));
405
+ for (const name of [...CONFIG_NAMES, ...NUXT_CONFIG_NAMES, ".ginko/content-contract.json", "package.json"]) {
406
+ try {
407
+ const path = join(rootDir, ...name.split("/"));
408
+ const before = await lstat(path);
409
+ const bytes = await readStableRegularFile(path, before, PORTABLE_CONTENT_LIMITS.contractBytes);
410
+ evidenceFiles.push({ file: name, bytes: bytes.byteLength, sha256: await sha256Hex(bytes) });
411
+ } catch {
412
+ }
413
+ }
414
+ evidenceFiles.sort((left, right) => left.file.localeCompare(right.file));
415
+ const inputHash = await hashCanonicalJson({ contractSha256, packageVersion, files: evidenceFiles });
416
+ const summary = summarize(documents, assets);
417
+ return {
418
+ ok: !diagnostics.some((item) => item.severity === "error"),
419
+ rootDir,
420
+ contract,
421
+ documents,
422
+ assets,
423
+ diagnostics,
424
+ evidence: { inputHash, contractSha256, packageVersion, files: evidenceFiles },
425
+ summary
426
+ };
427
+ }
428
+ async function parseFilesystemSource(id, source, options) {
429
+ const extension = id.slice(id.lastIndexOf(".")).toLowerCase();
430
+ let parsed;
431
+ if (extension === ".md") {
432
+ const tree = await parseComark(source);
433
+ const frontmatter = stripReservedContentKeys(tree.frontmatter, id);
434
+ parsed = {
435
+ ...frontmatter,
436
+ description: typeof frontmatter.description === "string" ? frontmatter.description : "",
437
+ id,
438
+ type: "markdown",
439
+ body: { type: "root", children: [] }
440
+ };
441
+ } else {
442
+ const parser = extension === ".json" || extension === ".json5" ? jsonParser : extension === ".yml" || extension === ".yaml" ? yamlParser : extension === ".csv" ? csvParser : null;
443
+ if (!parser?.parse) throw new TypeError(`Unsupported filesystem extension "${extension}".`);
444
+ parsed = await parser.parse(id, source, extension === ".csv" ? options.csv : options.yaml);
445
+ }
446
+ if (!pathMeta.transform) throw new TypeError("The canonical path transformer is unavailable.");
447
+ return await pathMeta.transform(parsed, options.pathMeta);
448
+ }
449
+ export async function exportFilesystemToPortableDirectory(options) {
450
+ const assessment = await assessFilesystemPortability({ rootDir: options.rootDir });
451
+ if (!assessment.ok || !assessment.contract || !assessment.evidence) throw new FilesystemPortabilityAssessmentError(assessment);
452
+ if (options.expectedInputHash && options.expectedInputHash !== assessment.evidence.inputHash) {
453
+ throw new GinkoBoundaryError({
454
+ operation: "directory.write",
455
+ code: "CONTRACT_HASH_MISMATCH",
456
+ message: "Filesystem content changed after assessment; export was refused.",
457
+ details: { expectedInputHash: options.expectedInputHash, actualInputHash: assessment.evidence.inputHash }
458
+ });
459
+ }
460
+ const destination = resolve(options.rootDir, options.destination);
461
+ await writePortableDirectory(destination, { contract: assessment.contract, documents: assessment.documents, assets: assessment.assets });
462
+ const bundle = await readPortableDirectory(destination);
463
+ return {
464
+ directory: destination,
465
+ inputHash: assessment.evidence.inputHash,
466
+ contractSha256: assessment.evidence.contractSha256,
467
+ manifestSha256: await hashCanonicalJson(bundle.manifest),
468
+ manifest: bundle.manifest,
469
+ ...assessment.summary
470
+ };
471
+ }
472
+ async function loadProjectConfig(rootDir) {
473
+ const configPath = await firstExisting(rootDir, CONFIG_NAMES);
474
+ const nuxtPath = await firstExisting(rootDir, NUXT_CONFIG_NAMES);
475
+ if (!configPath || !nuxtPath) throw new TypeError("A content.config.* and nuxt.config.* are required.");
476
+ const importer = jiti(rootDir, { interopDefault: true, moduleCache: false });
477
+ const contentConfig = unwrap(await importer.import(configPath));
478
+ const nuxtConfig = unwrap(await importer.import(nuxtPath));
479
+ if (!contentConfig.collections || !Object.keys(contentConfig.collections).length) throw new TypeError("content.config.* must declare collections.");
480
+ const moduleOptions = defu(nuxtConfig.content ?? {}, filesystemExportDefaults);
481
+ const modules = Array.isArray(nuxtConfig.modules) ? nuxtConfig.modules : [];
482
+ const i18n = nuxtConfig.i18n ?? {};
483
+ const moduleI18n = moduleOptions.i18n === false || moduleOptions.i18n === true ? {} : moduleOptions.i18n ?? {};
484
+ const localePolicy = resolveLocalePolicy({
485
+ nuxtI18n: {
486
+ installed: modules.some((item) => (Array.isArray(item) ? item[0] : item) === "@nuxtjs/i18n"),
487
+ locales: i18n.locales?.map((item) => typeof item === "string" ? item : item.code).filter((item) => Boolean(item)),
488
+ defaultLocale: i18n.defaultLocale,
489
+ strategy: i18n.strategy
490
+ },
491
+ content: {
492
+ locales: moduleI18n.locales,
493
+ defaultLocale: moduleI18n.defaultLocale,
494
+ fallback: moduleI18n.fallback,
495
+ translatedSlugs: moduleI18n.translatedSlugs
496
+ },
497
+ collections: Object.entries(contentConfig.collections).map(([name, collection]) => ({
498
+ name,
499
+ localized: Boolean(collection.i18n),
500
+ ...collection.i18n && collection.i18n !== true ? { locales: collection.i18n.locales, defaultLocale: collection.i18n.defaultLocale } : {},
501
+ route: collection.route
502
+ }))
503
+ });
504
+ const collections = Object.fromEntries(Object.entries(contentConfig.collections).map(([name, collection]) => [name, {
505
+ ...collection,
506
+ i18n: collection.i18n === false ? false : resolveCollectionI18nConfig(collection, { defaultLocale: localePolicy.defaultLocale, locales: [...localePolicy.locales] })
507
+ }]));
508
+ let packageVersion = "unknown";
509
+ try {
510
+ const appManifest = unwrap(await importer.import(join(rootDir, "node_modules/@lupinum/ginko-content/package.json")));
511
+ packageVersion = appManifest.version ?? packageVersion;
512
+ } catch {
513
+ }
514
+ return { collections, localePolicy, moduleOptions, provider: contentConfig.provider ?? "filesystem", packageVersion };
515
+ }
516
+ function buildNavigationMetadata(documents, collections, contract, fields) {
517
+ const result = /* @__PURE__ */ new Map();
518
+ for (const [collection, config] of Object.entries(collections)) {
519
+ const pages = documents.filter((item) => item.collection === collection && !item.partial && !isFilesystemNavigationFile(item));
520
+ const locales = new Set(pages.map((item) => item.locale).filter((item) => Boolean(item)));
521
+ for (const locale of locales) {
522
+ const variants = pages.filter((item) => item.locale === locale);
523
+ const configs = {};
524
+ const overrides = /* @__PURE__ */ new Map();
525
+ for (const navigation of documents.filter((item) => isFilesystemNavigationFile(item) && item.locale === locale)) {
526
+ const directory = sourceDirectory(navigation.file?.path);
527
+ const indexPage = variants.find((item) => sourceDirectory(item.file?.path) === directory && /(?:^|\/)\d*\.?index\.[^.]+$/u.test(item.file?.path ?? ""));
528
+ if (indexPage?.path && indexPage.canonicalKey) {
529
+ configs[indexPage.path] = { ...navigation, path: indexPage.path };
530
+ overrides.set(indexPage.canonicalKey, navigation);
531
+ }
532
+ }
533
+ const portableFields2 = contract.collections[collection]?.fields.map((field) => field.key) ?? [];
534
+ for (const node of flattenNavigation(buildCanonicalNavigation(variants, configs, [.../* @__PURE__ */ new Set([...fields, ...portableFields2])]))) {
535
+ if (!node.canonicalKey) continue;
536
+ const configured = overrides.get(node.canonicalKey);
537
+ result.set(variantKey(collection, node.canonicalKey, locale), configured ? Object.fromEntries(portableFields2.filter((key) => key in configured).map((key) => [key, configured[key]])) : {});
538
+ }
539
+ }
540
+ void config;
541
+ }
542
+ return result;
543
+ }
544
+ function flattenNavigation(items) {
545
+ return items.flatMap((item) => [item, ...flattenNavigation(item.children ?? [])]);
546
+ }
547
+ function isFilesystemNavigationFile(document) {
548
+ return isNavigationFile(document) || /(?:^|\/)\.navigation\.ya?ml$/u.test(document.file?.path ?? "");
549
+ }
550
+ function sourceDirectory(file) {
551
+ if (!file) return "";
552
+ const separator = file.lastIndexOf("/");
553
+ return separator < 0 ? "" : file.slice(0, separator);
554
+ }
555
+ async function portableFields(document, collection, graph, diagnostics) {
556
+ const shared = {};
557
+ const localized = {};
558
+ for (const field of collection.fields) {
559
+ if (field.role === "body" || !(field.key in document)) continue;
560
+ const value = await portableFieldValue(field, document[field.key], graph, document, diagnostics);
561
+ (field.localized ? localized : shared)[field.key] = value;
562
+ }
563
+ return { shared, localized };
564
+ }
565
+ async function portableFieldValue(field, value, graph, document, diagnostics) {
566
+ if (field.type === "relation" || field.type === "relations") {
567
+ const values = field.type === "relations" ? value : [value];
568
+ const refs = values.map((item) => {
569
+ if (typeof item !== "string") throw new TypeError(`Field "${field.key}" must contain source reference strings.`);
570
+ const canonicalKey = graph.referenceTargetsByCollection[field.relation.collection]?.get(item);
571
+ if (!canonicalKey) throw new TypeError(`Field "${field.key}" contains unresolved reference "${item}".`);
572
+ return { collection: field.relation.collection, canonicalKey };
573
+ });
574
+ return field.type === "relations" ? refs : refs[0];
575
+ }
576
+ if (field.type === "image" || field.type === "images") {
577
+ const values = field.type === "images" ? value : [value];
578
+ const refs = values.map((item) => {
579
+ if (typeof item === "string" && item.startsWith("https://")) return { kind: "external", url: item };
580
+ if (item && typeof item === "object") return assertPortableAssetReference(item);
581
+ throw new TypeError(`Field "${field.key}" uses a site-owned or unsupported asset; portable managed assets require explicit /ginko-assets identity and verified bytes.`);
582
+ });
583
+ return field.type === "images" ? refs : refs[0];
584
+ }
585
+ if ((field.type === "object" || field.type === "array" || field.type === "blocks") && field.fields) {
586
+ const convert = async (input) => {
587
+ if (!input || typeof input !== "object" || Array.isArray(input)) return input;
588
+ const output = {};
589
+ for (const child of field.fields) if (child.key in input) output[child.key] = await portableFieldValue(child, input[child.key], graph, document, diagnostics);
590
+ return output;
591
+ };
592
+ return Array.isArray(value) ? await Promise.all(value.map(convert)) : await convert(value);
593
+ }
594
+ canonicalJsonBytes(value);
595
+ void document;
596
+ void diagnostics;
597
+ return value;
598
+ }
599
+ async function materializeManagedAssets(rootDir, documents, contract, diagnostics) {
600
+ const referenced = /* @__PURE__ */ new Map();
601
+ for (const document of documents) {
602
+ const collection = contract.collections[document.collection];
603
+ if (!collection) continue;
604
+ try {
605
+ for (const reference of collectPortableAssetReferences(collection.fields, { ...document.shared, ...document.localized })) {
606
+ if (reference.kind === "local") referenced.set(reference.path, { sha256: reference.sha256, mediaType: reference.mediaType, collection: document.collection });
607
+ }
608
+ if (document.body) {
609
+ for (const reference of await collectPortableMdcAssetReferences(document.body.source, collection.componentPolicy)) {
610
+ referenced.set(reference.path, { ...reference, collection: document.collection });
611
+ }
612
+ }
613
+ } catch (error) {
614
+ diagnostic(
615
+ diagnostics,
616
+ boundaryCode(error, "DOCUMENT_INVALID"),
617
+ "portability.validateAssets",
618
+ document.collection,
619
+ null,
620
+ null,
621
+ message(error),
622
+ "Correct the managed asset reference in the source document."
623
+ );
624
+ }
625
+ }
626
+ const assets = [];
627
+ for (const [publicPath, reference] of [...referenced].sort(([left], [right]) => left.localeCompare(right))) {
628
+ const file = `public${publicPath}`;
629
+ try {
630
+ const path = join(rootDir, ...file.split("/"));
631
+ const before = await lstat(path);
632
+ const content = await readStableRegularFile(path, before, PORTABLE_CONTENT_LIMITS.assetBytes);
633
+ const verified = await verifyPublicImageBytes(content, reference.mediaType);
634
+ if (verified.sha256 !== reference.sha256) throw new TypeError("The asset bytes do not match the SHA-256 identity in the source reference.");
635
+ assets.push({ sha256: verified.sha256, file, bytes: verified.bytes, mediaType: verified.mediaType, content });
636
+ } catch (error) {
637
+ diagnostic(
638
+ diagnostics,
639
+ error instanceof StableFileError && error.reason === "limit" ? "LIMIT_EXCEEDED" : "ASSET_INTEGRITY_FAILED",
640
+ "portability.validateAssets",
641
+ reference.collection,
642
+ file,
643
+ null,
644
+ message(error),
645
+ "Restore the exact verified image at this public path, or correct the source reference."
646
+ );
647
+ }
648
+ }
649
+ return assets;
650
+ }
651
+ function markdownBody(source) {
652
+ const normalized = source.replace(/\r\n?/g, "\n");
653
+ if (!normalized.startsWith("---\n")) return normalized;
654
+ const end = normalized.indexOf("\n---\n", 4);
655
+ if (end < 0) {
656
+ if (normalized.endsWith("\n---")) return "";
657
+ throw new TypeError("Markdown frontmatter is not closed.");
658
+ }
659
+ return normalized.slice(end + 5);
660
+ }
661
+ function requiredRawSource(rawById, id) {
662
+ const source = rawById.get(id);
663
+ if (source === void 0) throw new TypeError(`Raw Markdown source is unavailable for "${id}".`);
664
+ return source;
665
+ }
666
+ function routeSlug(document, collection) {
667
+ if (collection.routing.singleton && collection.routing.rootSlug) return collection.routing.rootSlug;
668
+ const parts = (document.path ?? "").split("/").filter(Boolean);
669
+ const slug = parts[parts.length - 1];
670
+ if (!slug) throw new TypeError("A route-backed portable document requires a non-empty slug.");
671
+ return slug;
672
+ }
673
+ function treeParent(canonicalKey) {
674
+ const parts = canonicalKey.split("/").filter(Boolean);
675
+ return parts.length > 1 ? parts.slice(0, -1).join("/") : null;
676
+ }
677
+ function treeOrder(canonicalKey) {
678
+ const parts = canonicalKey.split("/");
679
+ const segment = parts[parts.length - 1] ?? "";
680
+ return /^\d+$/u.test(segment) ? BigInt(segment).toString(16).toUpperCase().padStart(16, "0") : null;
681
+ }
682
+ function searchVisible(search, collection) {
683
+ if (!search) return false;
684
+ if (search?.filterQuery && JSON.stringify(search.filterQuery) !== JSON.stringify({ partial: false })) {
685
+ throw new TypeError("Custom content.search.filterQuery cannot be represented as per-document portable visibility without evaluating the application query.");
686
+ }
687
+ return !search.collections || search.collections.includes(collection);
688
+ }
689
+ function normalizeSitemap(value) {
690
+ if (value === true) return { include: void 0, exclude: [] };
691
+ return { include: value.include, exclude: value.exclude ?? [] };
692
+ }
693
+ function diagnostic(target, code, operation, collection, file, field, message2, resolution) {
694
+ target.push({ severity: "error", code, operation, collection, file, field, message: message2, resolution });
695
+ }
696
+ function boundaryCode(error, fallback) {
697
+ return error instanceof GinkoBoundaryError ? error.code : fallback;
698
+ }
699
+ function message(error) {
700
+ return error instanceof Error ? error.message : String(error);
701
+ }
702
+ function fieldFrom(error) {
703
+ const context = error?.context;
704
+ if (Array.isArray(context?.path)) return context.path.join(".");
705
+ if (typeof context?.details === "string") return context.details.split(":", 1)[0] ?? null;
706
+ return null;
707
+ }
708
+ function fieldFile(value) {
709
+ if (value && typeof value === "object") {
710
+ const item = value;
711
+ return item.file?.path ? `content/${item.file.path}` : item.context?.file ? `content/${item.context.file}` : null;
712
+ }
713
+ return null;
714
+ }
715
+ function variantKey(collection, canonicalKey, locale) {
716
+ return `${collection}\0${canonicalKey}\0${locale}`;
717
+ }
718
+ function summarize(documents, assets) {
719
+ return {
720
+ documents: documents.length,
721
+ assets: assets.length,
722
+ collections: [...new Set(documents.map((item) => item.collection))].sort(),
723
+ locales: [...new Set(documents.map((item) => item.locale))].sort()
724
+ };
725
+ }
726
+ function emptyAssessment(rootDir, diagnostics) {
727
+ return { ok: false, rootDir, contract: null, documents: [], assets: [], diagnostics, evidence: null, summary: { documents: 0, assets: 0, collections: [], locales: [] } };
728
+ }
729
+ async function firstExisting(rootDir, names) {
730
+ for (const name of names) try {
731
+ const path = join(rootDir, name);
732
+ if ((await lstat(path)).isFile()) return path;
733
+ } catch {
734
+ }
735
+ return null;
736
+ }
737
+ function unwrap(value) {
738
+ const record = value;
739
+ return record && typeof record === "object" && "default" in record ? record.default : value;
740
+ }
@@ -1,3 +1,4 @@
1
1
  export { readPortableDirectory, readPortableDirectoryForPlanning, verifyPortableDirectoryBounded, rebuildPortableDirectoryManifest, type PortableDirectoryAsset, type PortableDirectoryBundle, type PortableDirectoryDocument, type PortableDirectoryPlanningBundle, type PortableDirectoryPlanningDocument, type PortableDirectoryPlanningLimits, type PortableDirectoryVerification, } from './read-directory.js';
2
2
  export { writePortableDirectory, type PortableAssetWriteInput, type WritePortableDirectoryInput, } from './write-directory.js';
3
3
  export { assertPortablePathSet, validatePortableRelativePath } from './safe-path.js';
4
+ export { assessFilesystemPortability, exportFilesystemToPortableDirectory, FilesystemPortabilityAssessmentError, type FilesystemPortabilityAssessment, type FilesystemPortabilityDiagnostic, type FilesystemPortabilityEvidence, type FilesystemPortabilityExportResult, } from './filesystem-export.js';
@@ -8,3 +8,8 @@ export {
8
8
  writePortableDirectory
9
9
  } from "./write-directory.js";
10
10
  export { assertPortablePathSet, validatePortableRelativePath } from "./safe-path.js";
11
+ export {
12
+ assessFilesystemPortability,
13
+ exportFilesystemToPortableDirectory,
14
+ FilesystemPortabilityAssessmentError
15
+ } from "./filesystem-export.js";
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "framework": "vue",
3
3
  "name": "@lupinum/ginko-content",
4
- "version": "1.0.0-beta.7",
4
+ "version": "1.0.0-beta.8",
5
5
  "contributions": {
6
6
  "html": {
7
7
  "description-markup": "markdown",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-content",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "Collection-first content engine for Nuxt",
5
5
  "homepage": "https://ginko-content.lupinum.com",
6
6
  "bugs": {
@@ -144,7 +144,7 @@
144
144
  "globby": "^16.2.0",
145
145
  "h3": "1.15.11",
146
146
  "jiti": "^2.7.0",
147
- "js-yaml": "^4.3.1",
147
+ "js-yaml": "^4.3.2",
148
148
  "json5": "^2.2.3",
149
149
  "knitwork": "^1.3.0",
150
150
  "minisearch": "^7.2.0",