@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 +3 -3
- package/dist/cli.mjs +36 -2
- package/dist/cms-contract/render-policy.js +1 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/portability-node/filesystem-export.d.ts +62 -0
- package/dist/portability-node/filesystem-export.js +740 -0
- package/dist/portability-node/index.d.ts +1 -0
- package/dist/portability-node/index.js +5 -0
- package/dist/web-types.json +1 -1
- package/package.json +2 -2
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.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.
|
|
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.
|
|
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
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.
|
|
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";
|
package/dist/web-types.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lupinum/ginko-content",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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.
|
|
147
|
+
"js-yaml": "^4.3.2",
|
|
148
148
|
"json5": "^2.2.3",
|
|
149
149
|
"knitwork": "^1.3.0",
|
|
150
150
|
"minisearch": "^7.2.0",
|