@taprootio/site-authoring 0.7.2 → 0.8.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.
- package/README.md +36 -13
- package/package.json +2 -2
- package/src/api.js +15 -4
- package/src/cli.js +68 -5
- package/src/config.js +15 -0
- package/src/constants.js +10 -1
- package/src/content/free-form-section-registry.json +44 -1
- package/src/content/tiptap-prosemirror.ts +38 -0
- package/src/content/validate-document.js +17 -0
- package/src/content/vocabulary.js +2 -0
- package/src/fixture-init.js +5 -0
- package/src/reference-help.js +12 -1
- package/src/session.js +83 -10
- package/src/surface.js +152 -0
- package/src/verbs/deploy.js +51 -7
- package/src/verbs/pull.js +54 -24
- package/src/verbs/sites.js +13 -8
- package/src/verbs/status.js +43 -9
- package/src/verbs/use.js +22 -3
- package/src/verbs/whoami.js +9 -1
package/README.md
CHANGED
|
@@ -144,14 +144,35 @@ capabilities the command needs — a content push never holds deploy. The
|
|
|
144
144
|
sign-in expires 24 hours after its last successful exchange, and never past
|
|
145
145
|
any expiry the approving owner chose.
|
|
146
146
|
|
|
147
|
-
`sites` lists
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
147
|
+
`sites` lists every standard and Docs site on the account with the authoring
|
|
148
|
+
verbs each accepts; `use <name or id>` records the choice as `siteId` in
|
|
149
|
+
`taproot-site.json`, together with the site's `authoringSurface`, creating
|
|
150
|
+
the file in the current directory when there is none. `whoami` answers from
|
|
151
|
+
local state alone: which Taproot, which account, which site and surface, what
|
|
152
|
+
the last exchange granted, and when the sign-in expires.
|
|
153
|
+
|
|
154
|
+
Which verbs apply depends on the site:
|
|
155
|
+
|
|
156
|
+
- A **standard** site takes every verb.
|
|
157
|
+
- A **managed Docs** site (`docs-presentation`) takes design and theming only:
|
|
158
|
+
`pull` snapshots its four settings documents, `theme push`, `footer push`,
|
|
159
|
+
and `media upload` author them, and `deploy` stages and promotes them.
|
|
160
|
+
Its pages, navigation, and redirects come from the Docs artifact, so
|
|
161
|
+
`pages push`, `nav push`, the `redirects` verbs, `approve`, and the
|
|
162
|
+
`preview` verbs refuse with `surface.presentation_only` before any request.
|
|
163
|
+
- A **prebuilt Docs** site (`none`) serves its artifact unchanged and takes no
|
|
164
|
+
verb; each refuses with `surface.none`.
|
|
165
|
+
|
|
166
|
+
The recorded surface is a convenience, not the authority: Taproot re-resolves
|
|
167
|
+
it on every write. After a Docs site becomes prebuilt, its sign-in exchange
|
|
168
|
+
answers Not found like any site the account can no longer author, and the
|
|
169
|
+
CLI adds a hint to run `use` again because the recording says the site was
|
|
170
|
+
authorable before.
|
|
171
|
+
|
|
172
|
+
`logout` discards the stored sign-in locally and revokes nothing; the
|
|
173
|
+
credential stays valid until an owner revokes it under Account → Settings →
|
|
174
|
+
API keys. The secret itself is never displayed, logged, or placed in any
|
|
175
|
+
result: credentials are named by id and display prefix only.
|
|
155
176
|
|
|
156
177
|
### `TAPROOT_SITE_KEY` overrides the sign-in
|
|
157
178
|
|
|
@@ -184,15 +205,17 @@ explicitly and bypasses discovery. `login`, `logout`, `sites`, `use`,
|
|
|
184
205
|
{
|
|
185
206
|
"configVersion": 1,
|
|
186
207
|
"siteId": "11111111-1111-4111-8111-111111111111",
|
|
208
|
+
"authoringSurface": "standard",
|
|
187
209
|
"workspaceDir": "site"
|
|
188
210
|
}
|
|
189
211
|
```
|
|
190
212
|
|
|
191
|
-
| Field
|
|
192
|
-
|
|
|
193
|
-
| `configVersion`
|
|
194
|
-
| `siteId`
|
|
195
|
-
| `
|
|
213
|
+
| Field | Rule |
|
|
214
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `configVersion` | Must be `1`. |
|
|
216
|
+
| `siteId` | Optional. The canonical lowercase UUID of the site the next command writes to. `use` writes it; a present but malformed value is refused. |
|
|
217
|
+
| `authoringSurface` | Optional. `standard`, `docs-presentation`, or `none`: the verbs the site accepts, as `use` recorded them. A present but unknown value is refused; Taproot re-resolves the surface on every write. |
|
|
218
|
+
| `workspaceDir` | Required. A relative POSIX path beneath the configuration directory that `pull` writes into. Every existing segment must be a real directory. |
|
|
196
219
|
|
|
197
220
|
The file is a small, closed JSON object. Unknown fields, duplicate keys, links
|
|
198
221
|
in the path, and unsupported versions are refused before any credential is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@taprootio/espalier": "4.
|
|
31
|
+
"@taprootio/espalier": "4.13.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test",
|
package/src/api.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
// would turn a successful mint into an orphaned key.
|
|
22
22
|
import { KEY_PREFIX } from "./credentials.js";
|
|
23
23
|
import { isCanonicalUuid, normalizePreviewDiagnostic, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
24
|
+
import { docsPublicationModeFromWire, siteKindFromWire, surfaceFromWire } from "./surface.js";
|
|
24
25
|
import {
|
|
25
26
|
DEFAULT_REDIRECT_STATUS,
|
|
26
27
|
GONE_STATUS,
|
|
@@ -1740,11 +1741,12 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
|
|
|
1740
1741
|
* a contract error that hides a live credential.
|
|
1741
1742
|
*/
|
|
1742
1743
|
/**
|
|
1743
|
-
* The
|
|
1744
|
+
* The sign-in credential's account sites, each with the authoring surface it
|
|
1745
|
+
* accepts (TR00645, TR00790).
|
|
1744
1746
|
*
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1747
|
+
* Every STANDARD and DOCS site on the account is listed, including a Docs site
|
|
1748
|
+
* that accepts no verb: `use` can select any of them, and the surface is what
|
|
1749
|
+
* tells the next command whether it applies before a request is made.
|
|
1748
1750
|
*/
|
|
1749
1751
|
export async function listAuthorableSites(client, requestOptions = {}) {
|
|
1750
1752
|
const response = requireObject(
|
|
@@ -1759,6 +1761,11 @@ export async function listAuthorableSites(client, requestOptions = {}) {
|
|
|
1759
1761
|
siteId: requireCanonicalUuid(value.siteId, "sites.contract", "siteId"),
|
|
1760
1762
|
name: typeof value.name === "string" ? value.name : "",
|
|
1761
1763
|
primaryDomain: typeof value.primaryDomain === "string" ? value.primaryDomain : "",
|
|
1764
|
+
// The verbs the site accepts (TR00790). Read fail-closed: a value this
|
|
1765
|
+
// CLI does not recognize is a site it cannot claim to author.
|
|
1766
|
+
siteType: siteKindFromWire(value.siteType),
|
|
1767
|
+
docsPublicationMode: docsPublicationModeFromWire(value.docsPublicationMode),
|
|
1768
|
+
authoringSurface: surfaceFromWire(value.authoringSurface),
|
|
1762
1769
|
};
|
|
1763
1770
|
});
|
|
1764
1771
|
}
|
|
@@ -1798,6 +1805,10 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
|
|
|
1798
1805
|
capabilities: Array.isArray(response.capabilities)
|
|
1799
1806
|
? response.capabilities.filter((entry) => typeof entry === "string")
|
|
1800
1807
|
: [],
|
|
1808
|
+
// What the site accepts at the moment of exchange (TR00790). Advisory in
|
|
1809
|
+
// the same sense as the platform switch: the server re-resolves it on
|
|
1810
|
+
// every write, and this lets a verb refuse before the request instead.
|
|
1811
|
+
authoringSurface: surfaceFromWire(response.authoringSurface),
|
|
1801
1812
|
// The sign-in's refreshed deadline. Not a secret, and absent on a server
|
|
1802
1813
|
// that predates the sliding window.
|
|
1803
1814
|
...(typeof response.signInExpiresAt === "string" && response.signInExpiresAt.length > 0
|
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS, CAPABILITY_DESIGN } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesForSurface } from "./surface.js";
|
|
2
3
|
import {
|
|
3
4
|
CLI_BINARY_NAME,
|
|
4
5
|
CLI_NAME,
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
LOGIN_KEY_NAME_MAXIMUM,
|
|
12
13
|
PUBLISH_KEY_ENVIRONMENT_VARIABLE,
|
|
13
14
|
RESULT_SCHEMA_VERSION,
|
|
15
|
+
SURFACE_DOCS_PRESENTATION,
|
|
16
|
+
SURFACE_STANDARD,
|
|
14
17
|
VERB_APPROVE,
|
|
15
18
|
VERB_DEPLOY,
|
|
16
19
|
VERB_ENV,
|
|
@@ -135,11 +138,13 @@ const VERBS = Object.freeze([
|
|
|
135
138
|
{
|
|
136
139
|
name: VERB_SITES,
|
|
137
140
|
tokens: ["sites"],
|
|
138
|
-
summary: "List
|
|
141
|
+
summary: "List this account's sites and the authoring verbs each accepts.",
|
|
139
142
|
configFree: true,
|
|
140
143
|
note: "Runs on the stored sign-in rather than a site credential, and is one of exactly two things that credential "
|
|
141
|
-
+ "can do.
|
|
142
|
-
+ "
|
|
144
|
+
+ "can do. Every standard and Docs site on the account is listed with its kind and authoring surface: a "
|
|
145
|
+
+ "standard site takes every verb; a managed Docs site takes design and theming only (pull, theme push, "
|
|
146
|
+
+ "footer push, media upload, deploy, status), because its pages, navigation, and redirects come from the "
|
|
147
|
+
+ "Docs artifact; a prebuilt Docs site takes no verb at all. 'use' can select any of them.",
|
|
143
148
|
},
|
|
144
149
|
{
|
|
145
150
|
name: VERB_USE,
|
|
@@ -148,8 +153,9 @@ const VERBS = Object.freeze([
|
|
|
148
153
|
configFree: true,
|
|
149
154
|
positionals: "siteSelector",
|
|
150
155
|
note: "Accepts a site id, an exact name, or an unambiguous case-insensitive name, and records the choice as "
|
|
151
|
-
+ "siteId in taproot-site.json — creating that file in the current directory when there is not one yet
|
|
152
|
-
+ "
|
|
156
|
+
+ "siteId in taproot-site.json — creating that file in the current directory when there is not one yet — "
|
|
157
|
+
+ "together with the site's authoringSurface, which is what lets a later verb refuse offline when the site "
|
|
158
|
+
+ "cannot take it. Two sites sharing a name is refused rather than guessed: pass the site id instead.",
|
|
153
159
|
},
|
|
154
160
|
{
|
|
155
161
|
name: VERB_WHOAMI,
|
|
@@ -185,6 +191,7 @@ const VERBS = Object.freeze([
|
|
|
185
191
|
},
|
|
186
192
|
{
|
|
187
193
|
name: VERB_PULL,
|
|
194
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
188
195
|
// Pages and the four settings groups, and every one of those gates on
|
|
189
196
|
// site.theme.manage — so a read-only snapshot still needs Design.
|
|
190
197
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
|
|
@@ -205,6 +212,7 @@ const VERBS = Object.freeze([
|
|
|
205
212
|
},
|
|
206
213
|
{
|
|
207
214
|
name: VERB_PAGES_PUSH,
|
|
215
|
+
surface: SURFACE_STANDARD,
|
|
208
216
|
capabilities: [CAPABILITY_CONTENT],
|
|
209
217
|
tokens: ["pages", "push"],
|
|
210
218
|
summary: "Create and update pages from the local workspace.",
|
|
@@ -231,6 +239,7 @@ const VERBS = Object.freeze([
|
|
|
231
239
|
},
|
|
232
240
|
{
|
|
233
241
|
name: VERB_NAV_PUSH,
|
|
242
|
+
surface: SURFACE_STANDARD,
|
|
234
243
|
// Content is for the read, not the write: every PAGE nav item is checked
|
|
235
244
|
// against the live page list before the tree is replaced, and that list is
|
|
236
245
|
// gated on site.pages.edit_any (TR00691).
|
|
@@ -240,6 +249,7 @@ const VERBS = Object.freeze([
|
|
|
240
249
|
},
|
|
241
250
|
{
|
|
242
251
|
name: VERB_REDIRECTS_CHECK,
|
|
252
|
+
surface: SURFACE_STANDARD,
|
|
243
253
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
244
254
|
tokens: ["redirects", "check"],
|
|
245
255
|
summary: "Check the current redirect map through the authenticated staging edge.",
|
|
@@ -248,6 +258,7 @@ const VERBS = Object.freeze([
|
|
|
248
258
|
},
|
|
249
259
|
{
|
|
250
260
|
name: VERB_REDIRECTS_PULL,
|
|
261
|
+
surface: SURFACE_STANDARD,
|
|
251
262
|
// A redirect is a content path, and both halves of the map — read and
|
|
252
263
|
// replace — are gated on site.pages.edit_any, which only Content carries.
|
|
253
264
|
capabilities: [CAPABILITY_CONTENT],
|
|
@@ -260,6 +271,7 @@ const VERBS = Object.freeze([
|
|
|
260
271
|
},
|
|
261
272
|
{
|
|
262
273
|
name: VERB_REDIRECTS_PUSH,
|
|
274
|
+
surface: SURFACE_STANDARD,
|
|
263
275
|
capabilities: [CAPABILITY_CONTENT],
|
|
264
276
|
tokens: ["redirects", "push"],
|
|
265
277
|
summary: "Validate redirects.json and replace the whole redirect map.",
|
|
@@ -270,6 +282,7 @@ const VERBS = Object.freeze([
|
|
|
270
282
|
},
|
|
271
283
|
{
|
|
272
284
|
name: VERB_THEME_PUSH,
|
|
285
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
273
286
|
capabilities: [CAPABILITY_DESIGN],
|
|
274
287
|
tokens: ["theme", "push"],
|
|
275
288
|
summary: "Validate and push the workspace's complete theme and appearance settings.",
|
|
@@ -278,6 +291,7 @@ const VERBS = Object.freeze([
|
|
|
278
291
|
},
|
|
279
292
|
{
|
|
280
293
|
name: VERB_FOOTER_PUSH,
|
|
294
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
281
295
|
capabilities: [CAPABILITY_DESIGN],
|
|
282
296
|
tokens: ["footer", "push"],
|
|
283
297
|
summary: "Validate and replace the workspace's complete footer document.",
|
|
@@ -286,7 +300,14 @@ const VERBS = Object.freeze([
|
|
|
286
300
|
},
|
|
287
301
|
{
|
|
288
302
|
name: VERB_MEDIA_UPLOAD,
|
|
303
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
289
304
|
capabilities: [CAPABILITY_CONTENT],
|
|
305
|
+
// On a managed Docs site Content is not on offer, and narrowing the
|
|
306
|
+
// request above by intersection would leave nothing — which the exchange
|
|
307
|
+
// reads as "the whole surface envelope", Deployments included. Design
|
|
308
|
+
// carries site.media.manage too, so that is what this verb asks for there
|
|
309
|
+
// (TR00790).
|
|
310
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
290
311
|
tokens: ["media", "upload"],
|
|
291
312
|
summary: "Upload media and wait for processing to finish.",
|
|
292
313
|
positionals: "paths",
|
|
@@ -295,6 +316,7 @@ const VERBS = Object.freeze([
|
|
|
295
316
|
},
|
|
296
317
|
{
|
|
297
318
|
name: VERB_APPROVE,
|
|
319
|
+
surface: SURFACE_STANDARD,
|
|
298
320
|
capabilities: [CAPABILITY_CONTENT],
|
|
299
321
|
tokens: ["approve"],
|
|
300
322
|
summary: "Publish drafts. This stages the site; it does not deploy it.",
|
|
@@ -305,6 +327,7 @@ const VERBS = Object.freeze([
|
|
|
305
327
|
},
|
|
306
328
|
{
|
|
307
329
|
name: VERB_DEPLOY,
|
|
330
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
308
331
|
// Deployments is the only capability this verb *writes* with; the other two
|
|
309
332
|
// are for reads and re-reads the server makes on its behalf (TR00691).
|
|
310
333
|
// Content: staging redirect checks read the current redirect map;
|
|
@@ -322,6 +345,7 @@ const VERBS = Object.freeze([
|
|
|
322
345
|
},
|
|
323
346
|
{
|
|
324
347
|
name: VERB_PREVIEW_PAGE,
|
|
348
|
+
surface: SURFACE_STANDARD,
|
|
325
349
|
capabilities: [CAPABILITY_CONTENT],
|
|
326
350
|
tokens: ["preview", "page"],
|
|
327
351
|
summary: "Render one persisted draft and mint a short-lived staging handoff.",
|
|
@@ -339,6 +363,7 @@ const VERBS = Object.freeze([
|
|
|
339
363
|
},
|
|
340
364
|
{
|
|
341
365
|
name: VERB_PREVIEW_REVOKE,
|
|
366
|
+
surface: SURFACE_STANDARD,
|
|
342
367
|
capabilities: [CAPABILITY_CONTENT],
|
|
343
368
|
tokens: ["preview", "revoke"],
|
|
344
369
|
summary: "Revoke an active authoring preview and schedule its artifacts for cleanup.",
|
|
@@ -348,6 +373,7 @@ const VERBS = Object.freeze([
|
|
|
348
373
|
},
|
|
349
374
|
{
|
|
350
375
|
name: VERB_STATUS,
|
|
376
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
351
377
|
// Readiness and the deployment log are Deployments; the image list is
|
|
352
378
|
// Content, as is the broken-reference report.
|
|
353
379
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
@@ -376,6 +402,30 @@ export const VERB_CAPABILITIES = Object.freeze(Object.fromEntries(
|
|
|
376
402
|
.map((verb) => [verb.name, Object.freeze([...verb.capabilities])]),
|
|
377
403
|
));
|
|
378
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The shipped verb table's surface declarations, keyed by verb name (TR00790):
|
|
407
|
+
* `standard` for verbs only a STANDARD site takes, `docs-presentation` for
|
|
408
|
+
* verbs a managed Docs site takes too. A verb with no entry is not a site verb.
|
|
409
|
+
*/
|
|
410
|
+
export const VERB_SURFACES = Object.freeze(Object.fromEntries(
|
|
411
|
+
VERBS
|
|
412
|
+
.filter((verb) => verb.surface !== undefined)
|
|
413
|
+
.map((verb) => [verb.name, verb.surface]),
|
|
414
|
+
));
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The capabilities each site verb's exchange asks for on a given surface
|
|
418
|
+
* (TR00790): the verb's declared set narrowed to what the surface offers, or
|
|
419
|
+
* the verb's own per-surface declaration where narrowing alone would be wrong.
|
|
420
|
+
* Exported so the tests can pin that no verb ever asks for nothing — an empty
|
|
421
|
+
* request means "everything the surface offers" on the wire.
|
|
422
|
+
*/
|
|
423
|
+
export function verbCapabilitiesForSurface(verbName, surface) {
|
|
424
|
+
const verb = VERBS.find((candidate) => candidate.name === verbName);
|
|
425
|
+
if (verb?.capabilities === undefined) return undefined;
|
|
426
|
+
return verb.surfaceCapabilities?.[surface] ?? capabilitiesForSurface(surface, verb.capabilities);
|
|
427
|
+
}
|
|
428
|
+
|
|
379
429
|
const COMMON_OPTIONS = `Options:
|
|
380
430
|
--config <path> Place before a config-reading verb; bypass parent discovery.
|
|
381
431
|
--quiet Suppress human progress. The JSON result is unchanged.
|
|
@@ -419,6 +469,12 @@ Configuration:
|
|
|
419
469
|
pass --config <path> before the verb. It is a closed JSON object:
|
|
420
470
|
configVersion must be 1
|
|
421
471
|
siteId optional; the canonical lowercase site UUID that 'use' writes
|
|
472
|
+
authoringSurface optional; 'standard', 'docs-presentation', or 'none' — the
|
|
473
|
+
verbs the site accepts, as 'use' recorded them. A verb the
|
|
474
|
+
surface cannot take is refused before any request
|
|
475
|
+
(surface.presentation_only, surface.none); Taproot re-checks
|
|
476
|
+
on every write, so run 'use' again after a Docs site changes
|
|
477
|
+
publication mode.
|
|
422
478
|
workspaceDir a relative POSIX path beneath the configuration directory
|
|
423
479
|
that pull writes into; every existing segment must be a real
|
|
424
480
|
directory
|
|
@@ -902,6 +958,11 @@ function parseArguments(arguments_) {
|
|
|
902
958
|
// `VERB_CAPABILITIES` below is what the tests pin against the routes each
|
|
903
959
|
// verb actually calls.
|
|
904
960
|
capabilities: verb.capabilities,
|
|
961
|
+
// The narrowest site surface the verb applies to (TR00790). `openSession`
|
|
962
|
+
// refuses a verb its site cannot take before any request; `VERB_SURFACES`
|
|
963
|
+
// below is what the tests pin.
|
|
964
|
+
surface: verb.surface,
|
|
965
|
+
surfaceCapabilities: verb.surfaceCapabilities,
|
|
905
966
|
configPath,
|
|
906
967
|
quiet,
|
|
907
968
|
deployTarget,
|
|
@@ -987,6 +1048,8 @@ export async function runCli({
|
|
|
987
1048
|
allowRawHtml: parsed.allowRawHtml,
|
|
988
1049
|
keyName: parsed.keyName,
|
|
989
1050
|
capabilities: parsed.capabilities,
|
|
1051
|
+
surface: parsed.surface,
|
|
1052
|
+
surfaceCapabilities: parsed.surfaceCapabilities,
|
|
990
1053
|
// Positionals land under the verb's own seam name (paths for media
|
|
991
1054
|
// upload, pagePaths for approve) and only when some were given, so a
|
|
992
1055
|
// bare invocation keeps each verb's documented default behavior.
|
package/src/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { lstat, open, realpath } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
AUTHORING_SURFACES,
|
|
6
7
|
CLI_BINARY_NAME,
|
|
7
8
|
CONFIG_FILE_NAME,
|
|
8
9
|
CONFIG_VERSION,
|
|
@@ -13,10 +14,12 @@ import {
|
|
|
13
14
|
} from "./constants.js";
|
|
14
15
|
import { hasAsciiControl, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
|
|
15
16
|
import { parseJsonWithDuplicateGuard } from "./json-guard.js";
|
|
17
|
+
import { isKnownSurface } from "./surface.js";
|
|
16
18
|
|
|
17
19
|
const CONFIG_KEYS = new Set([
|
|
18
20
|
"configVersion",
|
|
19
21
|
"siteId",
|
|
22
|
+
"authoringSurface",
|
|
20
23
|
"workspaceDir",
|
|
21
24
|
]);
|
|
22
25
|
const WINDOWS_DEVICE_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
|
|
@@ -398,6 +401,17 @@ export async function loadSiteConfig({ cwd = process.cwd(), configPath } = {}) {
|
|
|
398
401
|
if (parsed.siteId !== undefined && !isCanonicalUuid(parsed.siteId)) {
|
|
399
402
|
throw new SiteAuthoringError("config.site_id", "siteId must be a canonical lowercase UUID.", { field: "siteId" });
|
|
400
403
|
}
|
|
404
|
+
// The surface `use` recorded (TR00790). Optional, because a configuration
|
|
405
|
+
// written before the field existed still names a site; a verb then learns
|
|
406
|
+
// the surface from its exchange instead. Present-but-unknown is refused:
|
|
407
|
+
// a surface this CLI cannot read is not one it can gate on.
|
|
408
|
+
if (parsed.authoringSurface !== undefined && !isKnownSurface(parsed.authoringSurface)) {
|
|
409
|
+
throw new SiteAuthoringError(
|
|
410
|
+
"config.authoring_surface",
|
|
411
|
+
`authoringSurface must be one of: ${AUTHORING_SURFACES.join(", ")}.`,
|
|
412
|
+
{ field: "authoringSurface" },
|
|
413
|
+
);
|
|
414
|
+
}
|
|
401
415
|
const workspaceDirectoryText = validateWorkspaceDirectoryText(parsed.workspaceDir);
|
|
402
416
|
const configDirectory = path.dirname(canonicalConfigPath);
|
|
403
417
|
const { workspaceDir, workspaceExists } = await resolveWorkspaceDirectory(configDirectory, workspaceDirectoryText);
|
|
@@ -406,6 +420,7 @@ export async function loadSiteConfig({ cwd = process.cwd(), configPath } = {}) {
|
|
|
406
420
|
configPath: canonicalConfigPath,
|
|
407
421
|
configDirectory,
|
|
408
422
|
siteId: parsed.siteId,
|
|
423
|
+
authoringSurface: parsed.authoringSurface,
|
|
409
424
|
workspaceDir,
|
|
410
425
|
workspaceExists,
|
|
411
426
|
});
|
package/src/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CLI_NAME = "@taprootio/site-authoring";
|
|
2
|
-
export const CLI_VERSION = "0.
|
|
2
|
+
export const CLI_VERSION = "0.8.1";
|
|
3
3
|
export const CLI_BINARY_NAME = "taproot-site";
|
|
4
4
|
export const RESULT_SCHEMA_VERSION = 1;
|
|
5
5
|
export const CONFIG_FILE_NAME = "taproot-site.json";
|
|
@@ -98,6 +98,15 @@ export const VERB_USE = "use";
|
|
|
98
98
|
export const VERB_WHOAMI = "whoami";
|
|
99
99
|
export const VERB_ENV = "env";
|
|
100
100
|
|
|
101
|
+
// Which authoring verbs a site accepts (TR00790), as `use` records it in the
|
|
102
|
+
// configuration and as the exchange reports it. Spelled for humans rather
|
|
103
|
+
// than copied from the wire enum, because the configuration is a file people
|
|
104
|
+
// read and edit.
|
|
105
|
+
export const SURFACE_STANDARD = "standard";
|
|
106
|
+
export const SURFACE_DOCS_PRESENTATION = "docs-presentation";
|
|
107
|
+
export const SURFACE_NONE = "none";
|
|
108
|
+
export const AUTHORING_SURFACES = Object.freeze([SURFACE_STANDARD, SURFACE_DOCS_PRESENTATION, SURFACE_NONE]);
|
|
109
|
+
|
|
101
110
|
export const DEPLOY_TARGET_STAGING = "staging";
|
|
102
111
|
export const DEPLOY_TARGET_PRODUCTION = "production";
|
|
103
112
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 5,
|
|
3
3
|
"section": {
|
|
4
4
|
"nodeType": "section",
|
|
5
5
|
"label": "Section",
|
|
@@ -480,6 +480,11 @@
|
|
|
480
480
|
"label": "Section",
|
|
481
481
|
"measure": "wide",
|
|
482
482
|
"placement": "explicit-band"
|
|
483
|
+
},
|
|
484
|
+
"integrationPlacement": {
|
|
485
|
+
"label": "Integration component",
|
|
486
|
+
"measure": "wide",
|
|
487
|
+
"placement": "flow"
|
|
483
488
|
}
|
|
484
489
|
},
|
|
485
490
|
"components": {
|
|
@@ -523,5 +528,43 @@
|
|
|
523
528
|
"measure": "wide",
|
|
524
529
|
"rootPlacement": "root-band"
|
|
525
530
|
}
|
|
531
|
+
},
|
|
532
|
+
"integrationPlacement": {
|
|
533
|
+
"nodeType": "integrationPlacement",
|
|
534
|
+
"label": "Integration component",
|
|
535
|
+
"help": "An installed component whose configuration and sanitized fragment are selected as immutable page revisions.",
|
|
536
|
+
"attrs": {
|
|
537
|
+
"placementId": {
|
|
538
|
+
"type": "uuid",
|
|
539
|
+
"required": true
|
|
540
|
+
},
|
|
541
|
+
"installationId": {
|
|
542
|
+
"type": "uuid",
|
|
543
|
+
"required": true
|
|
544
|
+
},
|
|
545
|
+
"componentId": {
|
|
546
|
+
"type": "identifier",
|
|
547
|
+
"required": true,
|
|
548
|
+
"maximumLength": 64
|
|
549
|
+
},
|
|
550
|
+
"config": {
|
|
551
|
+
"type": "json-string",
|
|
552
|
+
"required": true,
|
|
553
|
+
"maximumBytes": 16384
|
|
554
|
+
},
|
|
555
|
+
"configurationRevision": {
|
|
556
|
+
"type": "integer",
|
|
557
|
+
"required": false,
|
|
558
|
+
"nullable": true,
|
|
559
|
+
"minimum": 1,
|
|
560
|
+
"maximum": 9007199254740991,
|
|
561
|
+
"serverAssigned": true
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
"placement": {
|
|
565
|
+
"root": true,
|
|
566
|
+
"section": true,
|
|
567
|
+
"measure": "wide"
|
|
568
|
+
}
|
|
526
569
|
}
|
|
527
570
|
}
|
|
@@ -47,7 +47,20 @@ export interface ProseMirrorDocument extends ProseMirrorNode {
|
|
|
47
47
|
content?: ProseMirrorNode[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Sanitized immutable page input supplied by Taproot, never trusted from node attributes. */
|
|
51
|
+
export interface SelectedIntegrationPlacement {
|
|
52
|
+
placementId: string;
|
|
53
|
+
installationId: string;
|
|
54
|
+
componentId: string;
|
|
55
|
+
status: string;
|
|
56
|
+
fragmentRevision: number;
|
|
57
|
+
html: string;
|
|
58
|
+
frozen: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export interface RenderProseMirrorOptions {
|
|
62
|
+
integrationPlacements?: readonly SelectedIntegrationPlacement[];
|
|
63
|
+
integrationPreview?: boolean;
|
|
51
64
|
/** Normalize free-form root flow into full-bleed Espalier sections. */
|
|
52
65
|
freeFormSections?: boolean;
|
|
53
66
|
imageDefaults?: {
|
|
@@ -185,6 +198,8 @@ function renderNode(
|
|
|
185
198
|
return renderInlineFacts(node, options, placement);
|
|
186
199
|
case "componentBlock":
|
|
187
200
|
return renderComponentBlock(node);
|
|
201
|
+
case "integrationPlacement":
|
|
202
|
+
return renderIntegrationPlacement(node, options);
|
|
188
203
|
case "section":
|
|
189
204
|
return renderSection(
|
|
190
205
|
node.content ?? [],
|
|
@@ -200,6 +215,29 @@ function renderNode(
|
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
|
|
218
|
+
function renderIntegrationPlacement(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
|
|
219
|
+
const placementId = stringAttr(node.attrs?.placementId);
|
|
220
|
+
const installationId = stringAttr(node.attrs?.installationId);
|
|
221
|
+
const componentId = stringAttr(node.attrs?.componentId);
|
|
222
|
+
const selected = options.integrationPlacements?.find((item) =>
|
|
223
|
+
item.placementId === placementId && item.installationId === installationId && item.componentId === componentId
|
|
224
|
+
);
|
|
225
|
+
if (!selected || selected.status === "removed" || selected.status === "deleted") {
|
|
226
|
+
return options.integrationPreview && !selected
|
|
227
|
+
? "<p role=\"status\">Save this page to request content from the integration.</p>"
|
|
228
|
+
: "";
|
|
229
|
+
}
|
|
230
|
+
if (!selected.fragmentRevision || !selected.html) {
|
|
231
|
+
return options.integrationPreview && !selected.frozen
|
|
232
|
+
? "<p role=\"status\">Content pending from the integration.</p>"
|
|
233
|
+
: "";
|
|
234
|
+
}
|
|
235
|
+
return renderElement("taproot-integration-placement", {
|
|
236
|
+
"data-placement-id": selected.placementId,
|
|
237
|
+
"data-component": selected.componentId,
|
|
238
|
+
}, selected.html);
|
|
239
|
+
}
|
|
240
|
+
|
|
203
241
|
function renderTextNode(node: ProseMirrorNode): string {
|
|
204
242
|
let rendered = escapeHtml(node.text ?? "");
|
|
205
243
|
for (const mark of node.marks ?? []) {
|
|
@@ -277,6 +277,7 @@ const BLOCKS = Object.freeze([
|
|
|
277
277
|
"horizontalRule",
|
|
278
278
|
"taprootImage",
|
|
279
279
|
"componentBlock",
|
|
280
|
+
"integrationPlacement",
|
|
280
281
|
"rawHtml",
|
|
281
282
|
]);
|
|
282
283
|
const SECTION_BLOCKS = Object.freeze([
|
|
@@ -382,6 +383,22 @@ const NODE_RULES = Object.freeze({
|
|
|
382
383
|
horizontalRule: rule({}),
|
|
383
384
|
taprootImage: rule({ attrs: IMAGE_ATTRS, rejectedAttrs: IMAGE_TRANSIENT_ATTRS, imageDelivery: true }),
|
|
384
385
|
componentBlock: rule({ attrs: COMPONENT_ATTRS, component: true }),
|
|
386
|
+
integrationPlacement: rule({
|
|
387
|
+
attrs: Object.freeze({
|
|
388
|
+
placementId: spec((value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value)
|
|
389
|
+
? null : "a UUID belonging only to this host page", { required: true }),
|
|
390
|
+
installationId: spec((value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value)
|
|
391
|
+
? null : "an installed integration UUID", { required: true }),
|
|
392
|
+
componentId: spec((value) => typeof value === "string" && /^[A-Za-z0-9_.-]{1,64}$/u.test(value)
|
|
393
|
+
? null : "a bounded declared component identifier", { required: true }),
|
|
394
|
+
config: spec((value) => {
|
|
395
|
+
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 16384) return "bounded configuration JSON";
|
|
396
|
+
try { JSON.parse(value); return null; } catch { return "valid configuration JSON"; }
|
|
397
|
+
}, { required: true }),
|
|
398
|
+
configurationRevision: spec((value) => value == null || Number.isSafeInteger(value) && value > 0
|
|
399
|
+
? null : "a positive server-assigned configuration revision"),
|
|
400
|
+
}),
|
|
401
|
+
}),
|
|
385
402
|
section: rule({
|
|
386
403
|
attrs: SECTION_ATTRS,
|
|
387
404
|
children: SECTION_BLOCKS,
|
|
@@ -49,6 +49,7 @@ export const NODE_TYPES = Object.freeze([
|
|
|
49
49
|
"horizontalRule",
|
|
50
50
|
"taprootImage",
|
|
51
51
|
"componentBlock",
|
|
52
|
+
"integrationPlacement",
|
|
52
53
|
"section",
|
|
53
54
|
freeFormSectionRegistry.inlineFacts.nodeType,
|
|
54
55
|
"rawHtml",
|
|
@@ -353,6 +354,7 @@ function hasMeaningfulContent(node, depth = 0) {
|
|
|
353
354
|
if (
|
|
354
355
|
node.type === "taprootImage"
|
|
355
356
|
|| node.type === "componentBlock"
|
|
357
|
+
|| node.type === "integrationPlacement"
|
|
356
358
|
|| node.type === freeFormSectionRegistry.inlineFacts.nodeType
|
|
357
359
|
) return true;
|
|
358
360
|
if (!Array.isArray(node.content)) return false;
|
package/src/fixture-init.js
CHANGED
|
@@ -42,6 +42,11 @@ export async function initializeFixture(invocation, validate) {
|
|
|
42
42
|
if (source.manifestVersion !== MANIFEST_VERSION || source.pagesTruncated || source.settingsSkipped?.length) {
|
|
43
43
|
refuse("A complete current pull is required before fixture initialization. Run 'taproot-site pull'.");
|
|
44
44
|
}
|
|
45
|
+
// A managed Docs site's workspace holds settings only (TR00790); the fixture
|
|
46
|
+
// contract binds pages and navigation, which that site never has.
|
|
47
|
+
if (source.authoringSurface !== undefined) {
|
|
48
|
+
refuse("Offline fixtures describe standard sites; a Docs site's workspace holds its settings only.");
|
|
49
|
+
}
|
|
45
50
|
if (typeof invocation.fixturePath !== "string" || !invocation.fixturePath.trim()) {
|
|
46
51
|
refuse("Choose a new fixture directory.");
|
|
47
52
|
}
|
package/src/reference-help.js
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
|
|
52
52
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
53
53
|
|
|
54
|
-
export const REFERENCE_VERSION =
|
|
54
|
+
export const REFERENCE_VERSION = 21;
|
|
55
55
|
export const PAGE_TYPES = Object.freeze(["free-form"]);
|
|
56
56
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
57
57
|
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
|
|
@@ -946,6 +946,17 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
946
946
|
attrs: Object.freeze(sectionAttributeReference()),
|
|
947
947
|
content: "one or more ordinary non-section block nodes; sections may not nest",
|
|
948
948
|
}),
|
|
949
|
+
integrationPlacementNode: Object.freeze({
|
|
950
|
+
type: "integrationPlacement",
|
|
951
|
+
attrs: Object.freeze({
|
|
952
|
+
placementId: "New UUID for a new or copied placement; retain identity when moving within the same page.",
|
|
953
|
+
installationId: "UUID of the eligible installation consented for this site.",
|
|
954
|
+
componentId: "Component identifier from the installation's consented manifest.",
|
|
955
|
+
config: "JSON string validated against the component's bounded configuration schema.",
|
|
956
|
+
configurationRevision: "Server-assigned revision. Preserve when unchanged; omit after editing config or copying.",
|
|
957
|
+
}),
|
|
958
|
+
authority: "The caller needs permission to edit the host page; installation management alone is insufficient.",
|
|
959
|
+
}),
|
|
949
960
|
tableNode: TABLE_REFERENCE,
|
|
950
961
|
inlineFactsNode: INLINE_FACTS_REFERENCE,
|
|
951
962
|
rawHtml: Object.freeze({
|