@taprootio/site-authoring 0.1.0
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/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
4
|
+
import { FOOTER_COLOR_TOKENS } from "./theme-validation.js";
|
|
5
|
+
|
|
6
|
+
// The ten scheme colors theme push owns — light/dark times the color-token
|
|
7
|
+
// registry — so a new footer color changes one executable registry, not a
|
|
8
|
+
// hand-copied list. Every other footer field is footer-push content.
|
|
9
|
+
const THEME_OWNED_SCHEME_COLORS = Object.freeze(Object.keys(FOOTER_COLOR_TOKENS));
|
|
10
|
+
|
|
11
|
+
// Matches the package's document-depth convention (CONTENT_LIMITS.documentDepth):
|
|
12
|
+
// the baseline hashes raw hand-editable JSON, and unbounded nesting would
|
|
13
|
+
// otherwise exhaust the stack into an undiagnosable generic failure.
|
|
14
|
+
const MAXIMUM_CONTENT_DEPTH = 64;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The pull baseline over everything `footer push` owns: a stable, type- and
|
|
18
|
+
* shape-preserving JSON hash of the raw footer document with exactly the ten
|
|
19
|
+
* scheme colors `theme push` overlays removed. The draft hash's canonical
|
|
20
|
+
* form deliberately normalizes types and shapes the way the server does, so
|
|
21
|
+
* it cannot see a hand-edit such as `enabled: "true"` or `bottomLinks: {}`;
|
|
22
|
+
* this baseline must, because `theme push` would otherwise silently discard
|
|
23
|
+
* that edit. Formatting-only differences — whitespace, key order — still
|
|
24
|
+
* hash identically. This hash is CLI-local workspace bookkeeping, not a
|
|
25
|
+
* server concurrency token.
|
|
26
|
+
*/
|
|
27
|
+
export function computeFooterContentHash(settings = {}) {
|
|
28
|
+
requireBoundedDepth(settings);
|
|
29
|
+
let content = settings;
|
|
30
|
+
if (isContentObject(settings)) {
|
|
31
|
+
content = structuredClone(settings);
|
|
32
|
+
for (const scheme of ["light", "dark"]) {
|
|
33
|
+
if (isContentObject(content[scheme])) {
|
|
34
|
+
for (const color of THEME_OWNED_SCHEME_COLORS) delete content[scheme][color];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return createHash("sha256").update(`footer-content-v2${stableJson(content)}`, "utf8").digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isContentObject(value) {
|
|
42
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Iterative breadth-first walk: bounds both the structuredClone and the
|
|
46
|
+
// stableJson recursion below without itself being recursive.
|
|
47
|
+
function requireBoundedDepth(value) {
|
|
48
|
+
let frontier = [value];
|
|
49
|
+
for (let depth = 0; frontier.length > 0; depth += 1) {
|
|
50
|
+
if (depth > MAXIMUM_CONTENT_DEPTH) {
|
|
51
|
+
throw new SiteAuthoringError(
|
|
52
|
+
"footer.document_too_deep",
|
|
53
|
+
`settings/site-publishing-preferences.json nests JSON deeper than ${MAXIMUM_CONTENT_DEPTH} levels. `
|
|
54
|
+
+ "Flatten the footer document before pushing.",
|
|
55
|
+
{ field: "settings/site-publishing-preferences.json" },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const next = [];
|
|
59
|
+
for (const entry of frontier) {
|
|
60
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
61
|
+
const children = Array.isArray(entry) ? entry : Object.values(entry);
|
|
62
|
+
for (const child of children) next.push(child);
|
|
63
|
+
}
|
|
64
|
+
frontier = next;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Deterministic JSON: sorted object keys, JSON.stringify value semantics. */
|
|
69
|
+
function stableJson(value) {
|
|
70
|
+
if (value === null || typeof value !== "object") {
|
|
71
|
+
return JSON.stringify(value) ?? "null";
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return `[${value.map((entry) => stableJson(entry === undefined ? null : entry)).join(",")}]`;
|
|
75
|
+
}
|
|
76
|
+
return `{${
|
|
77
|
+
Object.keys(value)
|
|
78
|
+
.sort()
|
|
79
|
+
.filter((key) => value[key] !== undefined)
|
|
80
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
81
|
+
.join(",")
|
|
82
|
+
}}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Byte-for-byte port of FooterDraftConcurrency.ComputeHash. The token protects
|
|
86
|
+
// the whole footer document while theme push overlays only its ten scheme color
|
|
87
|
+
// fields onto a fresh read. Response-only image URLs deliberately do not enter
|
|
88
|
+
// the canonical form.
|
|
89
|
+
export function computeFooterDraftHash(settings = {}) {
|
|
90
|
+
const canonical = [];
|
|
91
|
+
append(canonical, "footer-draft-v3");
|
|
92
|
+
append(canonical, settings.enabled === true);
|
|
93
|
+
append(canonical, settings.showBrand ?? true);
|
|
94
|
+
append(canonical, settings.showBrandText ?? true);
|
|
95
|
+
|
|
96
|
+
const columns = Array.isArray(settings.linkColumns) ? settings.linkColumns : [];
|
|
97
|
+
append(canonical, columns.length);
|
|
98
|
+
for (const column of columns) {
|
|
99
|
+
append(canonical, normalizeId(column?.id));
|
|
100
|
+
const groups = Array.isArray(column?.groups) ? column.groups : [];
|
|
101
|
+
append(canonical, groups.length);
|
|
102
|
+
for (const group of groups) {
|
|
103
|
+
append(canonical, normalizeId(group?.id));
|
|
104
|
+
append(canonical, group?.heading ?? "");
|
|
105
|
+
appendLinks(canonical, group?.links);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
appendRichText(canonical, settings.asideHeadingContent);
|
|
110
|
+
appendRichText(canonical, settings.asideBodyContent);
|
|
111
|
+
const hasAsideCta = settings.asideCta !== undefined && settings.asideCta !== null;
|
|
112
|
+
append(canonical, hasAsideCta);
|
|
113
|
+
if (hasAsideCta) appendLink(canonical, settings.asideCta);
|
|
114
|
+
appendRichText(canonical, settings.bottomContent);
|
|
115
|
+
appendLinks(canonical, settings.bottomLinks);
|
|
116
|
+
appendScheme(canonical, settings.light);
|
|
117
|
+
appendScheme(canonical, settings.dark);
|
|
118
|
+
|
|
119
|
+
const hasFeatureImage = settings.featureImage !== undefined && settings.featureImage !== null;
|
|
120
|
+
append(canonical, hasFeatureImage);
|
|
121
|
+
if (hasFeatureImage) {
|
|
122
|
+
append(canonical, normalizeId(settings.featureImage.imageId));
|
|
123
|
+
append(canonical, settings.featureImage.alt ?? "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return createHash("sha256").update(canonical.join(""), "utf8").digest("hex");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appendLinks(canonical, rawLinks) {
|
|
130
|
+
const links = Array.isArray(rawLinks) ? rawLinks : [];
|
|
131
|
+
append(canonical, links.length);
|
|
132
|
+
for (const link of links) appendLink(canonical, link);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Coalesce rather than default so the canonical form stays total even for a
|
|
136
|
+
// null entry: a parameter default covers only undefined.
|
|
137
|
+
function appendLink(canonical, rawLink) {
|
|
138
|
+
const link = rawLink ?? {};
|
|
139
|
+
append(canonical, normalizeId(link.id));
|
|
140
|
+
append(canonical, link.label ?? "");
|
|
141
|
+
appendTarget(canonical, link);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function appendTarget(canonical, link = {}) {
|
|
145
|
+
const external = Object.hasOwn(link, "externalUrl");
|
|
146
|
+
append(canonical, external ? 2 : 1);
|
|
147
|
+
append(canonical, external ? "" : normalizeId(link.pageResourceId));
|
|
148
|
+
append(canonical, external ? link.externalUrl ?? "" : "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function appendRichText(canonical, richText) {
|
|
152
|
+
const paragraphs = Array.isArray(richText?.paragraphs) ? richText.paragraphs : [];
|
|
153
|
+
append(canonical, paragraphs.length);
|
|
154
|
+
for (const paragraph of paragraphs) {
|
|
155
|
+
const runs = Array.isArray(paragraph?.runs) ? paragraph.runs : [];
|
|
156
|
+
append(canonical, runs.length);
|
|
157
|
+
for (const run of runs) {
|
|
158
|
+
append(canonical, run?.text ?? "");
|
|
159
|
+
append(canonical, run?.bold === true);
|
|
160
|
+
append(canonical, run?.italic === true);
|
|
161
|
+
append(canonical, run?.underline === true);
|
|
162
|
+
const hasLink = run?.link !== undefined && run.link !== null;
|
|
163
|
+
append(canonical, hasLink);
|
|
164
|
+
if (hasLink) appendTarget(canonical, run.link);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function appendScheme(canonical, rawScheme) {
|
|
170
|
+
const scheme = rawScheme ?? {};
|
|
171
|
+
append(canonical, scheme.backgroundColor ?? "");
|
|
172
|
+
append(canonical, scheme.textColor ?? "");
|
|
173
|
+
append(canonical, scheme.headingColor ?? "");
|
|
174
|
+
append(canonical, scheme.linkColor ?? "");
|
|
175
|
+
append(canonical, scheme.linkHoverColor ?? "");
|
|
176
|
+
append(canonical, normalizeId(scheme.backgroundImageId));
|
|
177
|
+
append(canonical, doubleBits(scheme.backgroundImageOpacity ?? 1));
|
|
178
|
+
append(canonical, presentationValue(scheme.backgroundPresentation));
|
|
179
|
+
append(canonical, scheme.backgroundFade === "FOOTER_FADE_MODE_BOTTOM" ? 1 : 0);
|
|
180
|
+
append(canonical, scheme.backgroundRepeatHeightPx || 160);
|
|
181
|
+
append(canonical, doubleBits(scheme.additionalTopPaddingRem ?? 0));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function presentationValue(value) {
|
|
185
|
+
if (value === "FOOTER_BACKGROUND_PRESENTATION_REPEAT_X") return 1;
|
|
186
|
+
if (value === "FOOTER_BACKGROUND_PRESENTATION_COVER") return 2;
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function doubleBits(value) {
|
|
191
|
+
const buffer = new ArrayBuffer(8);
|
|
192
|
+
new DataView(buffer).setFloat64(0, value === 0 ? 0 : value, false);
|
|
193
|
+
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeId(value) {
|
|
197
|
+
return typeof value === "string" ? value.toLowerCase() : "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function append(canonical, value) {
|
|
201
|
+
const token = typeof value === "boolean" ? (value ? "1" : "0") : String(value);
|
|
202
|
+
canonical.push(`${token.length}:${token}`);
|
|
203
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
2
|
+
import { appearanceImageIds } from "./appearance-contract.js";
|
|
3
|
+
import { footerImageIds } from "./footer-contract.js";
|
|
4
|
+
import { computeFooterContentHash, computeFooterDraftHash } from "./footer-draft-hash.js";
|
|
5
|
+
import { readManifest, readMediaManifest, writeManifest } from "./workspace.js";
|
|
6
|
+
|
|
7
|
+
export const FOOTER_SETTINGS_FILE = "settings/site-publishing-preferences.json";
|
|
8
|
+
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
9
|
+
|
|
10
|
+
function invalid(message, field) {
|
|
11
|
+
throw new SiteAuthoringError("footer.pull_required", message, { field });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function requireFooterMetadata(manifest) {
|
|
15
|
+
const footer = manifest.footer;
|
|
16
|
+
if (footer === null || typeof footer !== "object" || Array.isArray(footer)) {
|
|
17
|
+
invalid("The pull manifest does not record a footer baseline. Run 'taproot-site pull' again.", "footer");
|
|
18
|
+
}
|
|
19
|
+
if (footer.file !== FOOTER_SETTINGS_FILE || !SHA256.test(footer.expectedDraftHash ?? "")) {
|
|
20
|
+
invalid(
|
|
21
|
+
"The pull manifest has an invalid footer concurrency baseline. Run 'taproot-site pull' again.",
|
|
22
|
+
"footer.expectedDraftHash",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(footer.imageIds) || footer.imageIds.some((id) => !isCanonicalUuid(id))) {
|
|
26
|
+
invalid("The pull manifest has invalid footer image identities. Run 'taproot-site pull' again.", "footer.imageIds");
|
|
27
|
+
}
|
|
28
|
+
return footer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireAppearanceMetadata(manifest, { required = true, code = "theme.pull_required" } = {}) {
|
|
32
|
+
const appearance = manifest.appearance;
|
|
33
|
+
if (appearance === undefined && !required) return undefined;
|
|
34
|
+
if (appearance === null || typeof appearance !== "object" || Array.isArray(appearance)) {
|
|
35
|
+
throw new SiteAuthoringError(
|
|
36
|
+
code,
|
|
37
|
+
"The pull manifest does not record an appearance image baseline. Run 'taproot-site pull' again.",
|
|
38
|
+
{ field: "appearance" },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(appearance.imageIds) || appearance.imageIds.some((id) => !isCanonicalUuid(id))) {
|
|
42
|
+
throw new SiteAuthoringError(
|
|
43
|
+
code,
|
|
44
|
+
"The pull manifest has invalid appearance image identities. Run 'taproot-site pull' again.",
|
|
45
|
+
{ field: "appearance.imageIds" },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return appearance;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readFooterWorkspaceContext(workspaceDir, siteId) {
|
|
52
|
+
const manifest = await readManifest(workspaceDir, siteId);
|
|
53
|
+
const footer = requireFooterMetadata(manifest);
|
|
54
|
+
const mediaManifest = await readMediaManifest(workspaceDir, siteId);
|
|
55
|
+
const knownPageResourceIds = new Set(
|
|
56
|
+
manifest.pages.map((page) => page?.resourceId).filter((id) => isCanonicalUuid(id)),
|
|
57
|
+
);
|
|
58
|
+
const appearance = requireAppearanceMetadata(manifest, { required: false, code: "footer.pull_required" });
|
|
59
|
+
const knownImageIds = new Set([...footer.imageIds, ...(appearance?.imageIds ?? [])]);
|
|
60
|
+
for (const value of Object.values(mediaManifest.media)) {
|
|
61
|
+
if (isCanonicalUuid(value?.imageId)) knownImageIds.add(value.imageId);
|
|
62
|
+
}
|
|
63
|
+
return { manifest, footer, knownPageResourceIds, knownImageIds };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readAppearanceWorkspaceContext(workspaceDir, siteId) {
|
|
67
|
+
const context = await readFooterWorkspaceContext(workspaceDir, siteId);
|
|
68
|
+
const appearance = requireAppearanceMetadata(context.manifest);
|
|
69
|
+
return {
|
|
70
|
+
...context,
|
|
71
|
+
appearance,
|
|
72
|
+
knownImageIds: new Set([...context.knownImageIds, ...appearance.imageIds]),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function advanceFooterManifest(workspaceDir, siteId, footerSettings, expectedDraftHash) {
|
|
77
|
+
const manifest = await readManifest(workspaceDir, siteId);
|
|
78
|
+
const prior = requireFooterMetadata(manifest);
|
|
79
|
+
const hash = expectedDraftHash ?? computeFooterDraftHash(footerSettings);
|
|
80
|
+
if (!SHA256.test(hash)) {
|
|
81
|
+
throw new SiteAuthoringError(
|
|
82
|
+
"api.footer_settings_contract",
|
|
83
|
+
"Taproot returned a footer document whose draft hash could not be recorded.",
|
|
84
|
+
{ field: "footerDraftHash" },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
manifest.footer = {
|
|
88
|
+
file: FOOTER_SETTINGS_FILE,
|
|
89
|
+
expectedDraftHash: hash,
|
|
90
|
+
expectedContentHash: computeFooterContentHash(footerSettings),
|
|
91
|
+
imageIds: [...new Set([...prior.imageIds, ...footerImageIds(footerSettings)])].sort(),
|
|
92
|
+
};
|
|
93
|
+
await writeManifest(workspaceDir, manifest);
|
|
94
|
+
return hash;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function footerManifestEntry(footerSettings) {
|
|
98
|
+
return {
|
|
99
|
+
file: FOOTER_SETTINGS_FILE,
|
|
100
|
+
expectedDraftHash: computeFooterDraftHash(footerSettings),
|
|
101
|
+
expectedContentHash: computeFooterContentHash(footerSettings),
|
|
102
|
+
imageIds: footerImageIds(footerSettings),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `theme push` begins with a fresh server read that replaces the workspace
|
|
108
|
+
* footer document, so an unpushed footer-content edit would be silently lost.
|
|
109
|
+
* Refuse while the workspace footer's content hash differs from the recorded
|
|
110
|
+
* pull baseline. The ten scheme colors `theme push` overlays are excluded
|
|
111
|
+
* from the comparison, so a colour-only edit passes. A manifest that predates
|
|
112
|
+
* the baseline cannot prove the workspace is clean and refuses toward pull:
|
|
113
|
+
* assuming "unchanged" is exactly the data loss this guard exists to stop.
|
|
114
|
+
*/
|
|
115
|
+
export function requireFooterContentPushed(footer, workspaceFooterSettings) {
|
|
116
|
+
if (!SHA256.test(footer.expectedContentHash ?? "")) {
|
|
117
|
+
throw new SiteAuthoringError(
|
|
118
|
+
"theme.pull_required",
|
|
119
|
+
"The pull manifest predates the footer-content baseline. Run 'taproot-site footer push' to save the local "
|
|
120
|
+
+ "footer and record it, or 'taproot-site pull' to discard local edits and re-record it, then retry 'theme push'.",
|
|
121
|
+
{ field: "footer.expectedContentHash" },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (computeFooterContentHash(workspaceFooterSettings) !== footer.expectedContentHash) {
|
|
125
|
+
throw new SiteAuthoringError(
|
|
126
|
+
"theme.unpushed_footer_content",
|
|
127
|
+
`${FOOTER_SETTINGS_FILE} carries footer-content edits that 'theme push' would overwrite with the server's copy. `
|
|
128
|
+
+ "Run 'taproot-site footer push' to save them first, or 'taproot-site pull' to discard them.",
|
|
129
|
+
{ field: FOOTER_SETTINGS_FILE },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function appearanceManifestEntry(documents) {
|
|
135
|
+
return { imageIds: appearanceImageIds(documents) };
|
|
136
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Content hashing and pixel-dimension sniffing for `media upload`.
|
|
7
|
+
*
|
|
8
|
+
* `RequestImageUpload` wants the SHA-256 (for the dedup short-circuit) and the
|
|
9
|
+
* width and height before a single byte is uploaded, and the repo's zero
|
|
10
|
+
* runtime dependency rule means the client reads them out of the container
|
|
11
|
+
* header itself. The sniffer is deliberately a *header* reader: it inspects a
|
|
12
|
+
* fixed, bounded prefix of each container and never walks pixel data, so a
|
|
13
|
+
* hostile file cannot turn dimension discovery into a decode.
|
|
14
|
+
*
|
|
15
|
+
* Only the four raster containers the image service accepts are recognized.
|
|
16
|
+
* Everything else — SVG above all, which is a script-execution surface, not a
|
|
17
|
+
* raster — is refused with a stable code rather than uploaded and discovered
|
|
18
|
+
* server-side.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const IMAGE_CONTENT_TYPES = Object.freeze({
|
|
22
|
+
png: "image/png",
|
|
23
|
+
jpeg: "image/jpeg",
|
|
24
|
+
gif: "image/gif",
|
|
25
|
+
webp: "image/webp",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// One JPEG can carry a long run of metadata segments before its frame header.
|
|
29
|
+
// The scan is bounded so a crafted file cannot spin here.
|
|
30
|
+
const MAXIMUM_JPEG_SEGMENTS = 256;
|
|
31
|
+
const MAXIMUM_DIMENSION = 65_535;
|
|
32
|
+
|
|
33
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
34
|
+
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
|
35
|
+
|
|
36
|
+
function unsupportedFormat(fileName) {
|
|
37
|
+
return new SiteAuthoringError(
|
|
38
|
+
"media.unsupported_format",
|
|
39
|
+
`'${fileName}' is not a PNG, JPEG, GIF, or WebP image. Taproot accepts raster uploads only.`,
|
|
40
|
+
{ field: fileName },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function unreadableDimensions(fileName) {
|
|
45
|
+
return new SiteAuthoringError(
|
|
46
|
+
"media.dimensions_unreadable",
|
|
47
|
+
`The image header in '${fileName}' is truncated or malformed, so its dimensions cannot be read.`,
|
|
48
|
+
{ field: fileName },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function startsWithAscii(bytes, text, offset = 0) {
|
|
53
|
+
if (bytes.byteLength < offset + text.length) return false;
|
|
54
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
55
|
+
if (bytes[offset + index] !== text.charCodeAt(index)) return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pngDimensions(bytes, fileName) {
|
|
61
|
+
// Length (4) + "IHDR" (4) + width (4) + height (4) after the 8-byte signature.
|
|
62
|
+
if (bytes.byteLength < 24 || !startsWithAscii(bytes, "IHDR", 12)) throw unreadableDimensions(fileName);
|
|
63
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function gifDimensions(bytes, fileName) {
|
|
67
|
+
if (bytes.byteLength < 10) throw unreadableDimensions(fileName);
|
|
68
|
+
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isJpegFrameMarker(marker) {
|
|
72
|
+
// SOF0-SOF15 minus DHT (0xC4), JPG (0xC8), and DAC (0xCC), none of which
|
|
73
|
+
// carry frame dimensions.
|
|
74
|
+
return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function jpegDimensions(bytes, fileName) {
|
|
78
|
+
let offset = 2;
|
|
79
|
+
for (let segment = 0; segment < MAXIMUM_JPEG_SEGMENTS; segment += 1) {
|
|
80
|
+
// Segments may be padded with any number of 0xFF fill bytes.
|
|
81
|
+
while (offset < bytes.byteLength && bytes[offset] === 0xff) offset += 1;
|
|
82
|
+
if (offset >= bytes.byteLength) throw unreadableDimensions(fileName);
|
|
83
|
+
const marker = bytes[offset];
|
|
84
|
+
offset += 1;
|
|
85
|
+
// Standalone markers carry no length field.
|
|
86
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) continue;
|
|
87
|
+
if (offset + 2 > bytes.byteLength) throw unreadableDimensions(fileName);
|
|
88
|
+
const length = bytes.readUInt16BE(offset);
|
|
89
|
+
if (length < 2) throw unreadableDimensions(fileName);
|
|
90
|
+
if (isJpegFrameMarker(marker)) {
|
|
91
|
+
// length (2) + sample precision (1) + height (2) + width (2).
|
|
92
|
+
if (offset + 7 > bytes.byteLength) throw unreadableDimensions(fileName);
|
|
93
|
+
return { height: bytes.readUInt16BE(offset + 3), width: bytes.readUInt16BE(offset + 5) };
|
|
94
|
+
}
|
|
95
|
+
offset += length;
|
|
96
|
+
// A 0xDA (start of scan) hands off to entropy-coded data, which this
|
|
97
|
+
// reader must never walk.
|
|
98
|
+
if (marker === 0xda) break;
|
|
99
|
+
}
|
|
100
|
+
throw unreadableDimensions(fileName);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function webpDimensions(bytes, fileName) {
|
|
104
|
+
// RIFF (4) + file size (4) + WEBP (4), then one chunk header of fourcc (4)
|
|
105
|
+
// and size (4), so chunk payloads begin at 20.
|
|
106
|
+
if (bytes.byteLength < 30) throw unreadableDimensions(fileName);
|
|
107
|
+
if (startsWithAscii(bytes, "VP8X", 12)) {
|
|
108
|
+
// Flags (1) + reserved (3), then two 24-bit little-endian "minus one" values.
|
|
109
|
+
const width = (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1;
|
|
110
|
+
const height = (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1;
|
|
111
|
+
return { width, height };
|
|
112
|
+
}
|
|
113
|
+
if (startsWithAscii(bytes, "VP8L", 12)) {
|
|
114
|
+
if (bytes[20] !== 0x2f) throw unreadableDimensions(fileName);
|
|
115
|
+
const packed = bytes.readUInt32LE(21);
|
|
116
|
+
return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
|
|
117
|
+
}
|
|
118
|
+
if (startsWithAscii(bytes, "VP8 ", 12)) {
|
|
119
|
+
// Frame tag (3), sync code 0x9D 0x01 0x2A (3), then two 14-bit dimensions.
|
|
120
|
+
if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) throw unreadableDimensions(fileName);
|
|
121
|
+
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
|
122
|
+
}
|
|
123
|
+
throw unreadableDimensions(fileName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The SHA-256 the dedup short-circuit is keyed on. */
|
|
127
|
+
export function contentHash(bytes) {
|
|
128
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reads the container type and pixel dimensions out of an image's header.
|
|
133
|
+
* Returns `{ contentType, width, height }`; throws `media.unsupported_format`
|
|
134
|
+
* for a container Taproot does not accept and `media.dimensions_unreadable`
|
|
135
|
+
* for a recognized container whose header does not hold usable dimensions.
|
|
136
|
+
*/
|
|
137
|
+
export function inspectImageBytes(bytes, fileName = "image") {
|
|
138
|
+
if (!Buffer.isBuffer(bytes)) {
|
|
139
|
+
throw new SiteAuthoringError("media.unreadable", "The image bytes could not be inspected.", { field: fileName });
|
|
140
|
+
}
|
|
141
|
+
let contentType;
|
|
142
|
+
let dimensions;
|
|
143
|
+
if (bytes.byteLength >= 8 && bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
144
|
+
contentType = IMAGE_CONTENT_TYPES.png;
|
|
145
|
+
dimensions = pngDimensions(bytes, fileName);
|
|
146
|
+
} else if (bytes.byteLength >= 2 && bytes.subarray(0, 2).equals(JPEG_SOI)) {
|
|
147
|
+
contentType = IMAGE_CONTENT_TYPES.jpeg;
|
|
148
|
+
dimensions = jpegDimensions(bytes, fileName);
|
|
149
|
+
} else if (startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a")) {
|
|
150
|
+
contentType = IMAGE_CONTENT_TYPES.gif;
|
|
151
|
+
dimensions = gifDimensions(bytes, fileName);
|
|
152
|
+
} else if (startsWithAscii(bytes, "RIFF") && startsWithAscii(bytes, "WEBP", 8)) {
|
|
153
|
+
contentType = IMAGE_CONTENT_TYPES.webp;
|
|
154
|
+
dimensions = webpDimensions(bytes, fileName);
|
|
155
|
+
} else {
|
|
156
|
+
throw unsupportedFormat(fileName);
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
!Number.isSafeInteger(dimensions.width)
|
|
160
|
+
|| !Number.isSafeInteger(dimensions.height)
|
|
161
|
+
|| dimensions.width <= 0
|
|
162
|
+
|| dimensions.height <= 0
|
|
163
|
+
|| dimensions.width > MAXIMUM_DIMENSION
|
|
164
|
+
|| dimensions.height > MAXIMUM_DIMENSION
|
|
165
|
+
) {
|
|
166
|
+
throw unreadableDimensions(fileName);
|
|
167
|
+
}
|
|
168
|
+
return { contentType, width: dimensions.width, height: dimensions.height };
|
|
169
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { runCli } from "./cli.js";
|
|
2
|
+
export { SiteAuthoringError } from "./errors.js";
|
|
3
|
+
export {
|
|
4
|
+
getComponentReference,
|
|
5
|
+
getPageTypeReference,
|
|
6
|
+
listComponentTypeReferences,
|
|
7
|
+
listPageTypeReferences,
|
|
8
|
+
PAGE_TYPES,
|
|
9
|
+
REFERENCE_TOPICS,
|
|
10
|
+
REFERENCE_VERSION,
|
|
11
|
+
} from "./reference-help.js";
|
|
12
|
+
export {
|
|
13
|
+
getAppearanceReference,
|
|
14
|
+
getFooterReference,
|
|
15
|
+
getThemeReference,
|
|
16
|
+
} from "./presentation-reference.js";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const QUOTE = "\"";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A JSON parser that refuses a duplicated object key, shared by the
|
|
7
|
+
* configuration and the machine settings (TR00645).
|
|
8
|
+
*
|
|
9
|
+
* `JSON.parse` silently keeps the last of two identical keys. For a file a
|
|
10
|
+
* person can edit — and the settings file is one — that means the value the
|
|
11
|
+
* file *visibly* declares first is not the value the CLI acts on: a settings
|
|
12
|
+
* file with `apiBaseUrl` twice reads as production at the top and routes a
|
|
13
|
+
* credential to local, or the reverse, and a `TAPROOT_SITE_KEY` would follow
|
|
14
|
+
* it there. The only safe reading of an ambiguous target is to refuse it.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from the configuration loader, which has needed the same guard
|
|
17
|
+
* for `siteId` since it was written; parameterized on the two errors each
|
|
18
|
+
* caller owns rather than copied, so there is one parser to review.
|
|
19
|
+
*/
|
|
20
|
+
export function parseJsonWithDuplicateGuard(text, { onDuplicate, onInvalid }) {
|
|
21
|
+
let offset = 0;
|
|
22
|
+
const whitespace = () => {
|
|
23
|
+
while (/[\t\n\r ]/u.test(text[offset] ?? "")) offset += 1;
|
|
24
|
+
};
|
|
25
|
+
const string = () => {
|
|
26
|
+
if (text[offset] !== QUOTE) throw new Error("Expected a JSON string.");
|
|
27
|
+
const start = offset;
|
|
28
|
+
offset += 1;
|
|
29
|
+
while (offset < text.length) {
|
|
30
|
+
const character = text[offset];
|
|
31
|
+
if (character === QUOTE) {
|
|
32
|
+
offset += 1;
|
|
33
|
+
return JSON.parse(text.slice(start, offset));
|
|
34
|
+
}
|
|
35
|
+
if (character === "\\") {
|
|
36
|
+
offset += 1;
|
|
37
|
+
if (text[offset] === "u") offset += 4;
|
|
38
|
+
}
|
|
39
|
+
offset += 1;
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Unterminated JSON string.");
|
|
42
|
+
};
|
|
43
|
+
const value = (depth = 0) => {
|
|
44
|
+
if (depth > 16) throw new Error("JSON nesting is too deep.");
|
|
45
|
+
whitespace();
|
|
46
|
+
if (text[offset] === QUOTE) return string();
|
|
47
|
+
if (text[offset] === "{") {
|
|
48
|
+
offset += 1;
|
|
49
|
+
whitespace();
|
|
50
|
+
const result = Object.create(null);
|
|
51
|
+
const keys = new Set();
|
|
52
|
+
if (text[offset] === "}") {
|
|
53
|
+
offset += 1;
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
while (offset < text.length) {
|
|
57
|
+
whitespace();
|
|
58
|
+
const key = string();
|
|
59
|
+
if (keys.has(key)) throw onDuplicate(key);
|
|
60
|
+
keys.add(key);
|
|
61
|
+
whitespace();
|
|
62
|
+
if (text[offset] !== ":") throw new Error("Expected ':' after a JSON key.");
|
|
63
|
+
offset += 1;
|
|
64
|
+
result[key] = value(depth + 1);
|
|
65
|
+
whitespace();
|
|
66
|
+
if (text[offset] === "}") {
|
|
67
|
+
offset += 1;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
if (text[offset] !== ",") throw new Error("Expected ',' between JSON fields.");
|
|
71
|
+
offset += 1;
|
|
72
|
+
}
|
|
73
|
+
throw new Error("Unterminated JSON object.");
|
|
74
|
+
}
|
|
75
|
+
if (text[offset] === "[") {
|
|
76
|
+
offset += 1;
|
|
77
|
+
whitespace();
|
|
78
|
+
const result = [];
|
|
79
|
+
if (text[offset] === "]") {
|
|
80
|
+
offset += 1;
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
while (offset < text.length) {
|
|
84
|
+
result.push(value(depth + 1));
|
|
85
|
+
whitespace();
|
|
86
|
+
if (text[offset] === "]") {
|
|
87
|
+
offset += 1;
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
if (text[offset] !== ",") throw new Error("Expected ',' between JSON values.");
|
|
91
|
+
offset += 1;
|
|
92
|
+
}
|
|
93
|
+
throw new Error("Unterminated JSON array.");
|
|
94
|
+
}
|
|
95
|
+
const remaining = text.slice(offset);
|
|
96
|
+
const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/u.exec(remaining)?.[0];
|
|
97
|
+
if (!token) throw new Error("Invalid JSON value.");
|
|
98
|
+
offset += token.length;
|
|
99
|
+
return JSON.parse(token);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const parsed = value();
|
|
104
|
+
whitespace();
|
|
105
|
+
if (offset !== text.length) throw new Error("Unexpected data after the JSON value.");
|
|
106
|
+
return parsed;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof SiteAuthoringError) throw error;
|
|
109
|
+
throw onInvalid();
|
|
110
|
+
}
|
|
111
|
+
}
|