@taprootio/site-authoring 0.6.3 → 0.7.1

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.
@@ -0,0 +1,231 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, realpath, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { loadSiteConfig } from "./config.js";
6
+ import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
7
+ import { FIXTURE_CONTRACT_VERSION, FIXTURE_MANIFEST_FILE_NAME } from "./fixture-contract.js";
8
+ import { appearanceManifestEntry, footerManifestEntry } from "./footer-workspace.js";
9
+ import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "./settings-catalog.js";
10
+ import { validateWorkspacePageSource } from "./verbs/pages-push.js";
11
+ import { validateThemeWorkspace } from "./verbs/theme-push.js";
12
+ import {
13
+ MANIFEST_FILE_NAME,
14
+ MANIFEST_VERSION,
15
+ PAGE_WORKSPACE_MODE_EDITABLE,
16
+ readManifest,
17
+ readMediaManifest,
18
+ readWorkspaceJson,
19
+ WORKSPACE_LIMITS,
20
+ workspaceFileExists,
21
+ writeWorkspaceJson,
22
+ } from "./workspace.js";
23
+
24
+ function refuse(message, field = "fixturePath") {
25
+ throw new SiteAuthoringError("fixture.init_invalid", message, { field });
26
+ }
27
+
28
+ /** Export only authored fixture inputs, never credentials, internal state or deployment receipts. */
29
+ export async function initializeFixture(invocation, validate) {
30
+ const cwd = await realpath(invocation.cwd ?? process.cwd());
31
+ let workspaceDir = cwd;
32
+ let siteId;
33
+ if (invocation.configPath !== undefined || !await workspaceFileExists(cwd, MANIFEST_FILE_NAME)) {
34
+ const config = await loadSiteConfig({ cwd, configPath: invocation.configPath });
35
+ workspaceDir = config.workspaceDir;
36
+ siteId = config.siteId;
37
+ } else {
38
+ siteId = (await readWorkspaceJson(cwd, MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes)).siteId;
39
+ }
40
+ if (!isCanonicalUuid(siteId)) refuse("The source workspace must have a canonical site identity.", "siteId");
41
+ const source = await readManifest(workspaceDir, siteId);
42
+ if (source.manifestVersion !== MANIFEST_VERSION || source.pagesTruncated || source.settingsSkipped?.length) {
43
+ refuse("A complete current pull is required before fixture initialization. Run 'taproot-site pull'.");
44
+ }
45
+ if (typeof invocation.fixturePath !== "string" || !invocation.fixturePath.trim()) {
46
+ refuse("Choose a new fixture directory.");
47
+ }
48
+ const destination = path.resolve(cwd, invocation.fixturePath);
49
+ const sourceRoot = await realpath(workspaceDir);
50
+ if (destination === sourceRoot || destination.startsWith(`${sourceRoot}${path.sep}`)) {
51
+ refuse("Choose a fixture destination outside the source workspace.");
52
+ }
53
+ const parent = path.dirname(destination);
54
+ if (await realpath(parent).catch(() => undefined) !== parent) {
55
+ refuse("The destination parent must exist and contain no symlinks.");
56
+ }
57
+ if (
58
+ await lstat(destination).then(() => true, (error) => {
59
+ if (error.code === "ENOENT") return false;
60
+ throw error;
61
+ })
62
+ ) refuse("The fixture destination already exists; choose a new directory.");
63
+
64
+ const identities = new Map();
65
+ const imageIds = new Set();
66
+ const origins = new Map();
67
+ const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/giu;
68
+ function identity(value) {
69
+ const key = value.toLowerCase();
70
+ if (!identities.has(key)) {
71
+ const n = identities.size + 1;
72
+ identities.set(key, `f0000000-0000-4000-8000-${n.toString(16).padStart(12, "0")}`);
73
+ }
74
+ return identities.get(key);
75
+ }
76
+ function text(value) {
77
+ return value.replace(uuid, identity).replace(/https?:\/\/[^\s<>"'\\)]+/giu, (value) => {
78
+ let url;
79
+ try {
80
+ url = new URL(value);
81
+ } catch {
82
+ return "https://origin.example.test/";
83
+ }
84
+ if (!origins.has(url.origin)) origins.set(url.origin, `https://origin-${origins.size + 1}.example.test`);
85
+ // Query, fragment and userinfo may carry delivery signatures or handoffs.
86
+ return origins.get(url.origin) + url.pathname;
87
+ });
88
+ }
89
+ function sanitize(value, depth = 0) {
90
+ if (depth > 100) refuse("The source exceeds the fixture nesting bound.");
91
+ if (typeof value === "string") return text(value);
92
+ if (Array.isArray(value)) return value.map((child) => sanitize(child, depth + 1));
93
+ if (!value || typeof value !== "object") return value;
94
+ return Object.fromEntries(
95
+ Object.entries(value).map(([key, child]) => {
96
+ if (key === "componentData" && typeof child === "string") {
97
+ return [key, JSON.stringify(sanitize(JSON.parse(child), depth + 1))];
98
+ }
99
+ const result = sanitize(child, depth + 1);
100
+ if (/(?:imageId|logoId|canvasImageId|faviconId)$/iu.test(key) && isCanonicalUuid(result)) imageIds.add(result);
101
+ return [key, result];
102
+ }),
103
+ );
104
+ }
105
+ const fixtureSiteId = identity(siteId);
106
+ const documents = new Map();
107
+ let totalBytes = 0;
108
+ function add(file, value) {
109
+ totalBytes += Buffer.byteLength(JSON.stringify(value), "utf8");
110
+ if (totalBytes > WORKSPACE_LIMITS.pulledBodyBytes) refuse("The fixture exceeds the 64 MiB export bound.");
111
+ documents.set(file, value);
112
+ }
113
+ const pages = [];
114
+ const mediaManifest = await readMediaManifest(workspaceDir, siteId);
115
+ for (const entry of source.pages) {
116
+ if (entry.workspaceMode !== PAGE_WORKSPACE_MODE_EDITABLE) continue;
117
+ if (!isCanonicalUuid(entry.pageId) || !isCanonicalUuid(entry.resourceId)) {
118
+ refuse("A source page identity is invalid.", "pages");
119
+ }
120
+ const page = await validateWorkspacePageSource({
121
+ workspaceDir,
122
+ file: entry.file,
123
+ manifestEntry: entry,
124
+ mediaManifest,
125
+ content: invocation.content,
126
+ });
127
+ const file = `pages/page-${pages.length + 1}.pm.json`;
128
+ pages.push(sanitize({
129
+ pageId: entry.pageId,
130
+ resourceId: entry.resourceId,
131
+ path: page.pagePath,
132
+ title: page.title,
133
+ description: page.declaredDescription ?? "",
134
+ status: entry.status,
135
+ templateType: entry.templateType,
136
+ hasDraft: entry.hasDraft,
137
+ isGenerated: false,
138
+ file,
139
+ sourceFormat: "prosemirror",
140
+ workspaceMode: "editable",
141
+ }));
142
+ add(file, sanitize(page.document));
143
+ }
144
+ const settings = [];
145
+ const appearanceDocuments = {};
146
+ for (const group of SETTINGS_GROUPS) {
147
+ const file = `settings/${group.file}`;
148
+ const original = await readWorkspaceJson(workspaceDir, file, WORKSPACE_LIMITS.settingsBytes);
149
+ if (original.entityId !== siteId || original.settingsType !== group.settingsType) {
150
+ refuse("A settings document belongs to a different site or group.", file);
151
+ }
152
+ const document = sanitize({
153
+ entityId: original.entityId,
154
+ settingsType: original.settingsType,
155
+ settings: Object.fromEntries(
156
+ group.fields.filter((field) => Object.hasOwn(original.settings, field.name))
157
+ .map((field) => [field.name, original.settings[field.name]]),
158
+ ),
159
+ });
160
+ add(file, document);
161
+ settings.push({ settingsType: group.settingsType, file, entityId: document.entityId });
162
+ appearanceDocuments[group.settingsType] = document.settings;
163
+ }
164
+ await validateThemeWorkspace(
165
+ workspaceDir,
166
+ siteId,
167
+ new Set(
168
+ [...identities].filter(([, mapped]) => imageIds.has(mapped)).map(([original]) => original),
169
+ ),
170
+ );
171
+ for (
172
+ const [file, limit] of [["nav.json", WORKSPACE_LIMITS.navigationBytes], [
173
+ "redirects.json",
174
+ WORKSPACE_LIMITS.redirectsBytes,
175
+ ]]
176
+ ) {
177
+ const original = await readWorkspaceJson(workspaceDir, file, limit);
178
+ if (original.siteId !== siteId) refuse("A source document belongs to a different site.", file);
179
+ add(
180
+ file,
181
+ sanitize(
182
+ file === "nav.json"
183
+ ? { siteId: original.siteId, navItems: original.navItems }
184
+ : { siteId: original.siteId, revision: original.revision, entries: original.entries },
185
+ ),
186
+ );
187
+ }
188
+ const redirects = documents.get("redirects.json");
189
+ // A fixture hash is a local placeholder, not authority to mutate the original site.
190
+ const revision = createHash("sha256").update(JSON.stringify(redirects.entries)).digest("hex");
191
+ if (Object.hasOwn(redirects, "revision")) redirects.revision = revision;
192
+ const footer = footerManifestEntry(appearanceDocuments[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES].footerSettings);
193
+ const appearance = appearanceManifestEntry(appearanceDocuments);
194
+ for (const id of [...footer.imageIds, ...appearance.imageIds]) imageIds.add(id);
195
+ add(FIXTURE_MANIFEST_FILE_NAME, {
196
+ manifestVersion: MANIFEST_VERSION,
197
+ siteId: fixtureSiteId,
198
+ pages,
199
+ pagesTruncated: false,
200
+ navigation: { file: "nav.json", items: source.navigation?.items },
201
+ redirects: { file: "redirects.json", revision, entries: redirects.entries?.length },
202
+ settings,
203
+ settingsSkipped: [],
204
+ appearance,
205
+ footer,
206
+ fixture: {
207
+ contractVersion: FIXTURE_CONTRACT_VERSION,
208
+ imageIds: [...imageIds].sort(),
209
+ deliveryOrigins: [...origins.values()],
210
+ },
211
+ });
212
+
213
+ // mkdir is exclusive even if another process wins the path after the earlier inspection.
214
+ try {
215
+ await mkdir(destination, { mode: 0o700 });
216
+ } catch {
217
+ refuse("The fixture destination could not be created exclusively.");
218
+ }
219
+ try {
220
+ for (const [file, value] of documents) await writeWorkspaceJson(destination, file, value);
221
+ const result = await validate({ ...invocation, init: false, cwd, fixturePath: destination });
222
+ invocation.onProgress?.(`Initialized a sanitized fixture with ${pages.length} editable page(s).`);
223
+ return {
224
+ ...result,
225
+ initialized: { directory: destination, pages: pages.length, skippedPages: source.pages.length - pages.length },
226
+ };
227
+ } catch (error) {
228
+ await rm(destination, { recursive: true, force: true });
229
+ throw error;
230
+ }
231
+ }
package/src/output.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "./constants.js";
15
15
  import {
16
16
  hasAsciiControl,
17
+ normalizePreviewDiagnostic,
17
18
  normalizePreviewRecovery,
18
19
  sanitizeDiagnostic,
19
20
  SiteAuthoringError,
@@ -35,6 +36,7 @@ export function failureResult(error) {
35
36
  error: { code: error.code },
36
37
  };
37
38
  if (error.field) result.error.field = error.field;
39
+ if (error.details?.length) result.error.details = error.details;
38
40
  if (error.status) result.error.status = error.status;
39
41
  if (Array.isArray(error.alternatives) && error.alternatives.length > 0) {
40
42
  result.error.alternatives = error.alternatives;
@@ -51,6 +53,8 @@ export function failureResult(error) {
51
53
  if (Array.isArray(error.differences)) {
52
54
  result.error.differences = error.differences;
53
55
  }
56
+ const previewDiagnostic = normalizePreviewDiagnostic(error.previewDiagnostic);
57
+ if (previewDiagnostic) result.error.diagnostic = previewDiagnostic;
54
58
  const previewRecovery = normalizePreviewRecovery(error.previewRecovery);
55
59
  if (previewRecovery) result.error.preview = previewRecovery;
56
60
  const refusal = typeof error.refusalKind === "function" ? error.refusalKind() : undefined;
@@ -134,7 +138,11 @@ export async function writeGithubActionsOutput(outputPath, result) {
134
138
  "GITHUB_OUTPUT must be a regular file, not a link or directory.",
135
139
  );
136
140
  }
137
- const json = requireBoundedJson(result);
141
+ // Browser handoffs are bearer capabilities, never workflow outputs.
142
+ const safeResult = structuredClone(result);
143
+ if (safeResult.verb === "preview page") delete safeResult.url;
144
+ if (safeResult.stagingPreview) delete safeResult.stagingPreview.url;
145
+ const json = requireBoundedJson(safeResult);
138
146
  const delimiter = githubDelimiter(json);
139
147
  const lines = [
140
148
  `${GITHUB_OUTPUT_RESULT_KEY}<<${delimiter}`,
@@ -158,7 +166,9 @@ export function humanFailure(error) {
158
166
  const refusal = typeof kind === "string" && kind !== REFUSAL_UNCLASSIFIED
159
167
  ? ` refusal=${sanitizeDiagnostic(kind, "unknown")}`
160
168
  : "";
161
- return `${CLI_BINARY_NAME} failed [${error.code}]${field}${status}${refusal}: ${sanitizeDiagnostic(error.message)}`;
169
+ const details = error.details?.map((item) => `${item.field}: ${item.message}`).join("\n");
170
+ return `${CLI_BINARY_NAME} failed [${error.code}]${field}${status}${refusal}: ${sanitizeDiagnostic(error.message)}`
171
+ + (details ? `\n${details}` : "");
162
172
  }
163
173
 
164
174
  export function serializeResult(result) {