@taprootio/site-authoring 0.2.0 → 0.4.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/README.md +106 -13
- package/examples/riverbend-wellness/manifest.fixture.json +79 -0
- package/examples/riverbend-wellness/nav.json +26 -0
- package/examples/riverbend-wellness/pages/index.md +93 -0
- package/examples/riverbend-wellness/pages/visit.md +33 -0
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/examples/riverbend-wellness/settings/brand.json +7 -0
- package/examples/riverbend-wellness/settings/site-header.json +21 -0
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
- package/package.json +2 -1
- package/src/api.js +203 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +120 -11
- package/src/constants.js +59 -2
- package/src/contact-url.js +63 -0
- package/src/content/components.js +26 -4
- package/src/content/free-form-section-registry.json +3 -2
- package/src/content/inline-facts.js +5 -1
- package/src/content/tiptap-prosemirror.ts +26 -12
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +64 -0
- package/src/footer-contract.js +28 -30
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +5 -3
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +262 -10
- package/src/session.js +161 -7
- package/src/transport.js +111 -6
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +17 -11
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +357 -22
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +93 -18
- package/src/verbs/whoami.js +79 -1
- package/src/workspace.js +153 -15
package/src/workspace.js
CHANGED
|
@@ -4,7 +4,7 @@ import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { atomicWriteFile } from "./atomic-file.js";
|
|
7
|
-
import { hasAsciiControl, SiteAuthoringError } from "./errors.js";
|
|
7
|
+
import { hasAsciiControl, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The local workspace: what `pull` writes, what the push verbs read back, and
|
|
@@ -103,15 +103,141 @@ export function internalPageBaselineFile(pageId) {
|
|
|
103
103
|
: undefined;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Where the revision of a *preserved* remote body is recorded, beside the body
|
|
108
|
+
* itself.
|
|
109
|
+
*
|
|
110
|
+
* The manifest records what this workspace last **reconciled** with, and a
|
|
111
|
+
* refused pull reconciles nothing — it writes no manifest at all, which is what
|
|
112
|
+
* makes the same conflict repeat until somebody resolves it. But the refusal
|
|
113
|
+
* does show the operator the site's version, and after that the site's version
|
|
114
|
+
* is no longer news. Without somewhere to record that, the push refusal added
|
|
115
|
+
* for a concurrent remote edit would also refuse the pull conflict's own
|
|
116
|
+
* documented recovery — "push the local source to make the site match it" —
|
|
117
|
+
* and the only way out of a conflict would be to give up the local source.
|
|
118
|
+
*
|
|
119
|
+
* So this records what the workspace last **saw**, which is a different fact.
|
|
120
|
+
* A push into a page whose site revision equals the one already shown is the
|
|
121
|
+
* operator's informed override; a push into one that has moved *again* is still
|
|
122
|
+
* refused. Any pull that reconciles the page removes it, because the manifest
|
|
123
|
+
* then carries the newer and stronger claim.
|
|
124
|
+
*/
|
|
125
|
+
export function internalPageObservedRevisionFile(pageId) {
|
|
126
|
+
return typeof pageId === "string" && SAFE_SEGMENT.test(pageId)
|
|
127
|
+
? `${INTERNAL_PAGE_BASELINE_DIRECTORY}/${pageId}.revision.json`
|
|
128
|
+
: undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Version 6 records the site's own `baseline.revision` for every tracked page:
|
|
132
|
+
// the opaque revision of the stored authoring state the site reported when this
|
|
133
|
+
// workspace last agreed with it. Version 5 could only compare one pull's body
|
|
134
|
+
// against the next, because the bytes a push sends are not the bytes a read
|
|
135
|
+
// returns — so a push could not tell whether the page it was about to
|
|
136
|
+
// overwrite had moved since, and a change in the API's image-delivery
|
|
137
|
+
// projection between two pulls read as a remote edit. Version 5 made the page
|
|
138
|
+
// list a source registry (`file`, `sourceFormat`, `baseline`); version 4
|
|
139
|
+
// classified pulled files but recorded no registry. The package is in-repo and
|
|
140
|
+
// its manifests are workspace state, so an older one is re-pulled rather than
|
|
141
|
+
// migrated.
|
|
142
|
+
export const MANIFEST_VERSION = 6;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The shape of a page body revision as the site reports it: a scheme name and
|
|
146
|
+
* a hex digest. The CLI never derives one — it only records what it was told
|
|
147
|
+
* and compares it with what it is told next — so this is a containment check
|
|
148
|
+
* on a server string that reaches the manifest and the terminal, not a claim
|
|
149
|
+
* about how the value is computed.
|
|
150
|
+
*/
|
|
151
|
+
export const PAGE_BODY_REVISION = /^[a-z][a-z0-9]{0,15}:[0-9a-f]{16,128}$/u;
|
|
152
|
+
|
|
153
|
+
/** A revision as recorded or reported, or `undefined` when there is not one. */
|
|
154
|
+
export function normalizePageBodyRevision(value) {
|
|
155
|
+
return typeof value === "string" && PAGE_BODY_REVISION.test(value) ? value : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The revision of the remote body this workspace last saw for a page, or
|
|
160
|
+
* `undefined` when it has not seen one it did not also reconcile with.
|
|
161
|
+
*
|
|
162
|
+
* Damaged or absent internal state is simply "nothing seen": it costs the
|
|
163
|
+
* operator one pull, which is the same position a workspace that has never
|
|
164
|
+
* conflicted is in, and is never worth refusing a verb over.
|
|
165
|
+
*/
|
|
166
|
+
export async function readObservedPageRevision(workspaceDir, pageId) {
|
|
167
|
+
return (await readObservedPageRecord(workspaceDir, pageId))?.revision;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Everything a refused pull recorded for a page: the version the operator was
|
|
172
|
+
* shown, and the differences the refusal named beside it.
|
|
173
|
+
*
|
|
174
|
+
* The version is named either way it can be named. A site that reports a
|
|
175
|
+
* revision is recorded by `revision`; a site that predates the revision
|
|
176
|
+
* contract is compared by the canonical hash of its body, and is recorded by
|
|
177
|
+
* `remoteHash` instead — the same value that comparison is made against. A
|
|
178
|
+
* hash-only record therefore reports no revision, which is what keeps
|
|
179
|
+
* `readObservedPageRevision` — and so the push guard it arms — untouched by
|
|
180
|
+
* this: an override is a statement about a revision, and there is none here.
|
|
181
|
+
*
|
|
182
|
+
* The differences are recorded rather than recomputed because the refusal
|
|
183
|
+
* overwrites the preserved baseline with the version it is refusing. On the
|
|
184
|
+
* next pull, with nothing further changed on the site, a fresh comparison would
|
|
185
|
+
* find the preserved copy and the site's document identical and report "the
|
|
186
|
+
* change is in the page's title, path, or description" for what was a body
|
|
187
|
+
* edit. The record is what lets a repeated refusal say the same true thing the
|
|
188
|
+
* first one did; a page that has moved *again* carries a different revision, so
|
|
189
|
+
* the record no longer applies and the comparison is redone.
|
|
190
|
+
*
|
|
191
|
+
* Fail-soft on everything, exactly like the revision read it backs: this is
|
|
192
|
+
* workspace state a hand edit or an interrupted write can reach, and no verb is
|
|
193
|
+
* worth refusing over a diagnostic.
|
|
194
|
+
*/
|
|
195
|
+
export async function readObservedPageRecord(workspaceDir, pageId) {
|
|
196
|
+
const file = internalPageObservedRevisionFile(pageId);
|
|
197
|
+
if (file === undefined) return undefined;
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
// The existence check is inside the guard too: a directory or a symlink
|
|
201
|
+
// where the record should be is "nothing seen", not a refusal.
|
|
202
|
+
if (!await workspaceFileExists(workspaceDir, file)) return undefined;
|
|
203
|
+
parsed = await readWorkspaceJson(workspaceDir, file, WORKSPACE_LIMITS.settingsBytes);
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.pageId !== pageId) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const revision = normalizePageBodyRevision(parsed.revision);
|
|
211
|
+
const remoteHash = typeof parsed.remoteHash === "string" && WORKSPACE_CONTENT_HASH.test(parsed.remoteHash)
|
|
212
|
+
? parsed.remoteHash
|
|
213
|
+
: undefined;
|
|
214
|
+
// A record naming neither version says nothing about what was shown, which is
|
|
215
|
+
// the same position as no record at all.
|
|
216
|
+
if (revision === undefined && remoteHash === undefined) return undefined;
|
|
217
|
+
return { revision, remoteHash, differences: normalizeObservedDifferences(parsed.differences) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* How many recorded paths one record carries back. The same bound
|
|
222
|
+
* `SiteAuthoringError#withDifferences` applies, because these paths reach a
|
|
223
|
+
* refusal's message and its `differences` field by that route.
|
|
224
|
+
*/
|
|
225
|
+
const OBSERVED_DIFFERENCE_PATHS = 100;
|
|
226
|
+
|
|
227
|
+
/** A recorded `{ paths, truncated }`, or `undefined` when there is not one. */
|
|
228
|
+
function normalizeObservedDifferences(value) {
|
|
229
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.paths)) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
paths: value.paths
|
|
234
|
+
.filter((entry) => typeof entry === "string")
|
|
235
|
+
.slice(0, OBSERVED_DIFFERENCE_PATHS)
|
|
236
|
+
.map((entry) => sanitizeDiagnostic(entry, "path")),
|
|
237
|
+
truncated: value.truncated === true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
115
241
|
export const PAGE_WORKSPACE_MODE_EDITABLE = "editable";
|
|
116
242
|
export const PAGE_WORKSPACE_MODE_METADATA_ONLY = "metadata-only";
|
|
117
243
|
export const PAGE_WORKSPACE_MODE_READ_ONLY = "read-only";
|
|
@@ -129,6 +255,10 @@ export const WORKSPACE_LIMITS = Object.freeze({
|
|
|
129
255
|
// One authored page: a Markdown source or a ProseMirror document.
|
|
130
256
|
documentBytes: 2 * 1024 * 1024,
|
|
131
257
|
navigationBytes: 1024 * 1024,
|
|
258
|
+
// The redirect map: sized from the contract's own bounds (entries, path
|
|
259
|
+
// and target bytes, pretty-printed JSON overhead) rather than borrowed from
|
|
260
|
+
// the navigation tree, so the site's own pulled map always reads back.
|
|
261
|
+
redirectsBytes: 8 * 1024 * 1024,
|
|
132
262
|
settingsBytes: 256 * 1024,
|
|
133
263
|
manifestBytes: 8 * 1024 * 1024,
|
|
134
264
|
mediaBytes: 32 * 1024 * 1024,
|
|
@@ -223,10 +353,13 @@ function isDiscoverablePageSource(file) {
|
|
|
223
353
|
* - An entry whose `file` is missing, unrecognized, or not a file the workspace
|
|
224
354
|
* walk could have produced is **dropped**. It names no source this package
|
|
225
355
|
* would ever read, so there is nothing to protect.
|
|
226
|
-
* - A `baseline`
|
|
227
|
-
* reported**. Losing it costs one pull's worth of conflict detection and
|
|
356
|
+
* - A `baseline` carrying no usable hash and no usable revision is **discarded
|
|
357
|
+
* and reported**. Losing it costs one pull's worth of conflict detection and
|
|
228
358
|
* nothing else: the next pull re-establishes it from what it reads, and the
|
|
229
|
-
* authored source is never written over in the meantime.
|
|
359
|
+
* authored source is never written over in the meantime. Individual members
|
|
360
|
+
* are dropped the same way — an unusable `revision` beside two good hashes
|
|
361
|
+
* leaves the entry compared the way version 5 compared it, which is the same
|
|
362
|
+
* position a workspace that has not pulled since is in.
|
|
230
363
|
* - A `sourceFormat` that disagrees with the file's own extension is recorded
|
|
231
364
|
* as `formatMismatch` and the extension wins. `pages push` refuses on it —
|
|
232
365
|
* it is a hand-edited manifest and re-pulling is the fix — while `pull`
|
|
@@ -254,9 +387,14 @@ export function pageSourceRegistry(manifest) {
|
|
|
254
387
|
const declared = entry.baseline;
|
|
255
388
|
const remoteHash = normalizeBaselineHash(declared?.remoteHash);
|
|
256
389
|
const sourceHash = normalizeBaselineHash(declared?.sourceHash);
|
|
257
|
-
const
|
|
390
|
+
const revision = normalizePageBodyRevision(declared?.revision);
|
|
391
|
+
const baseline = remoteHash === undefined && sourceHash === undefined && revision === undefined
|
|
258
392
|
? undefined
|
|
259
|
-
: {
|
|
393
|
+
: {
|
|
394
|
+
...(remoteHash === undefined ? {} : { remoteHash }),
|
|
395
|
+
...(sourceHash === undefined ? {} : { sourceHash }),
|
|
396
|
+
...(revision === undefined ? {} : { revision }),
|
|
397
|
+
};
|
|
260
398
|
const record = {
|
|
261
399
|
pageId: entry.pageId,
|
|
262
400
|
file: entry.file,
|