@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 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 what the sign-in may author; `use <name or id>` records the
148
- choice as `siteId` in `taproot-site.json`, creating the file in the current
149
- directory when there is none. `whoami` answers from local state alone: which
150
- Taproot, which account, which site, what the last exchange granted, and when
151
- the sign-in expires. `logout` discards the stored sign-in locally and revokes
152
- nothing; the credential stays valid until an owner revokes it under
153
- Account → Settings → API keys. The secret itself is never displayed, logged,
154
- or placed in any result: credentials are named by id and display prefix only.
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 | Rule |
192
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
193
- | `configVersion` | Must be `1`. |
194
- | `siteId` | Optional. The canonical lowercase UUID of the site the next command writes to. `use` writes it; a present but malformed value is refused. |
195
- | `workspaceDir` | Required. A relative POSIX path beneath the configuration directory that `pull` writes into. Every existing segment must be a real directory. |
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.7.2",
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.12.0"
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 sites the sign-in credential's account may author (TR00645).
1744
+ * The sign-in credential's account sites, each with the authoring surface it
1745
+ * accepts (TR00645, TR00790).
1744
1746
  *
1745
- * The server has already filtered these to sites an exchange would accept, so
1746
- * anything listed here is a site `use` can select and the next command can
1747
- * author — a picker that offered otherwise would be worse than no picker.
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 the sites this sign-in may author.",
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. The list is already filtered to sites an exchange would accept, so anything shown here is a site "
142
- + "'use' can select and the next command can author.",
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
- + "Two sites sharing a name is refused rather than guessed: pass the site id instead.",
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.7.2";
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": 4,
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;
@@ -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
  }
@@ -51,7 +51,7 @@ import {
51
51
 
52
52
  export { getAppearanceReference, getFooterReference, getThemeReference };
53
53
 
54
- export const REFERENCE_VERSION = 20;
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({