@taprootio/site-authoring 0.23.1 → 0.26.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.
@@ -50,6 +50,13 @@ import {
50
50
  FORM_SINKS,
51
51
  FORMS_DIRECTORY,
52
52
  } from "./forms-contract.js";
53
+ import {
54
+ LINK_SOURCES,
55
+ MARKETING_KEY_RULE,
56
+ MARKETING_KINDS,
57
+ MARKETING_STATES,
58
+ maximumActive,
59
+ } from "./marketing-contract.js";
53
60
  import {
54
61
  formatPresentationReference,
55
62
  getAppearanceReference,
@@ -88,7 +95,7 @@ import {
88
95
 
89
96
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
90
97
 
91
- export const REFERENCE_VERSION = 41;
98
+ export const REFERENCE_VERSION = 43;
92
99
 
93
100
  // The date-limit examples `help forms` prints; tests run them through the form validator.
94
101
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -389,7 +396,7 @@ export function getDesignBlueprint(type) {
389
396
  return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
390
397
  }
391
398
  export const REFERENCE_TOPICS = Object.freeze([
392
- Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and the read-only legal page." }),
399
+ Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and legal draft pages." }),
393
400
  Object.freeze({
394
401
  name: "page",
395
402
  usage: `${CLI_BINARY_NAME} help page <page-type>`,
@@ -422,6 +429,16 @@ export const REFERENCE_TOPICS = Object.freeze([
422
429
  usage: `${CLI_BINARY_NAME} help forms`,
423
430
  summary: "Author a form as forms/<key>.json: file shape, field types, validate, pull, push, and placing it on a page.",
424
431
  }),
432
+ Object.freeze({
433
+ name: "marketing",
434
+ usage: `${CLI_BINARY_NAME} help marketing`,
435
+ summary: "Keep a site's campaigns and placements as marketing/*.json: file shape, pull, push, archive, and what an agent may do.",
436
+ }),
437
+ Object.freeze({
438
+ name: "link",
439
+ usage: `${CLI_BINARY_NAME} help link`,
440
+ summary: "Build a tracked address: path, source, optional campaign and placement; what counts and when it goes live.",
441
+ }),
425
442
  Object.freeze({
426
443
  name: "media",
427
444
  usage: `${CLI_BINARY_NAME} help media`,
@@ -903,6 +920,95 @@ const WORKFLOW_REFERENCES = Object.freeze({
903
920
  }),
904
921
  }),
905
922
  }),
923
+ marketing: Object.freeze({
924
+ title: "Campaigns and placements workspace contract",
925
+ summary: "A site's campaigns and placements are two JSON files under marketing/. 'marketing pull' reads the site's, "
926
+ + "'marketing push' creates, updates, restores and (with --archive) archives to match them, and 'link build' prints "
927
+ + "an address that uses them.",
928
+ usage: `${CLI_BINARY_NAME} marketing pull | ${CLI_BINARY_NAME} marketing push [--archive]`,
929
+ details: Object.freeze([
930
+ "Run 'pull' once first: both verbs need the pulled workspace's manifest, which records what each record looked "
931
+ + "like when this workspace last read it. The site's licence must include analytics, and the site must be a Standard site.",
932
+ `A campaign groups links from one push; a placement names the spot a link appears, such as a newsletter footer. `
933
+ + `Both are public keys the site's release admits, so page views carry them: a campaign as utm_campaign, a placement as `
934
+ + `utm_content. ${MARKETING_KINDS.campaigns.file} is { campaigns: [ { key, name, note?, starts_on?, ends_on? } ] }; `
935
+ + `${MARKETING_KINDS.placements.file} is { placements: [ { key, name } ] }. Records are listed by key.`,
936
+ `A key is ${MARKETING_KEY_RULE}. It is public URL text and never changes or returns to use, even after the record is `
937
+ + "archived, because page views already counted under it keep the key. A name (at most "
938
+ + `${MARKETING_KINDS.campaigns.rules.nameMaxLength} characters), a campaign's note (at most ${MARKETING_KINDS.campaigns.rules.noteMaxLength}) `
939
+ + "and its starts_on and ends_on days (YYYY-MM-DD, labels only) are private and can change.",
940
+ `A pull also writes two read-only fields: state (${MARKETING_STATES.join(", ")}) and counted_from, the day the key first `
941
+ + "went live and page views began to count under it. A push ignores both. A record is live once the silent release "
942
+ + "that carries its key has run; until then page views count under the source only.",
943
+ "'marketing push' validates both files and compares every record with the site before the first write. "
944
+ + "A key the file names that the site lacks is created. A record whose labels differ is updated. A key the file "
945
+ + "names that the site has archived is restored (and updated if its labels differ). Nothing else is touched: a "
946
+ + "record the file leaves out stays unless you pass --archive, and --archive archives only records this workspace "
947
+ + "pulled. A changed key is therefore a new record plus, if you want the old one gone, an explicit --archive.",
948
+ "A record that changed on the site since the last pull is refused as marketing.concurrent_modification, a key "
949
+ + "the site has that this workspace never pulled as marketing.pull_required; reconcile with 'marketing pull' and "
950
+ + "retry. Taproot refuses a key outside the grammar, a key already used by an archived record, and a record over "
951
+ + `the quota (${maximumActive("campaigns")} unarchived campaigns, ${maximumActive("placements")} unarchived placements, and a larger cap on archived ones `
952
+ + "together); the refusal is shown as Taproot returns it. Archive one to make room, or restore an archived one.",
953
+ "'marketing pull' reports, per kind, file, total, items ([{ key, state }], at most 200), archived keys (at most 200, "
954
+ + "with archivedTotal and archivedTruncated), canManage and maxActive. 'marketing push' reports, per kind, the keys it "
955
+ + "created, updated, restored, archived, left unchanged, left on the site because the file omits them (unlisted) and found "
956
+ + "on the site but never pulled (unpulled), each list capped at 50 with the true totals in counts and the capped lists "
957
+ + "named in truncated; written is { items, count } across both kinds.",
958
+ "Creating or restoring a key queues a silent production release that starts admitting it; no 'deploy' is needed and "
959
+ + "none is waited for. 'marketing pull' later shows the state. Archiving queues no release: the key stops being admitted from the next one.",
960
+ "The delegation.content capability carries the permissions this needs: site.analytics.view to read and also "
961
+ + "site.pages.publish_any to change, because a new key publishes a release. Platform-wide, an administrator can pause external "
962
+ + "authoring; a push is then refused with the rollout field. A refusal names a missing permission.",
963
+ ]),
964
+ example: Object.freeze({
965
+ campaigns: Object.freeze({
966
+ campaigns: Object.freeze([
967
+ Object.freeze({
968
+ key: "spring-launch",
969
+ name: "Spring launch",
970
+ note: "Newsletter and social push",
971
+ starts_on: "2026-03-01",
972
+ ends_on: "2026-03-31",
973
+ }),
974
+ ]),
975
+ }),
976
+ placements: Object.freeze({
977
+ placements: Object.freeze([Object.freeze({ key: "nl-footer", name: "Newsletter footer" })]),
978
+ }),
979
+ commands: Object.freeze([
980
+ `${CLI_BINARY_NAME} marketing pull`,
981
+ `${CLI_BINARY_NAME} marketing push`,
982
+ `${CLI_BINARY_NAME} link build --path /recipes/spring-salad --source newsletter --campaign spring-launch --placement nl-footer`,
983
+ ]),
984
+ }),
985
+ }),
986
+ link: Object.freeze({
987
+ title: "Tracked link builder",
988
+ summary: "'link build' prints one address that counts under a source, and optionally a campaign and a placement. It saves nothing.",
989
+ usage: `${CLI_BINARY_NAME} link build --path <page> --source <key> [--campaign <key>] [--placement <key>] [--domain <host>]`,
990
+ details: Object.freeze([
991
+ "--path is the page on this site, such as /recipes/spring-salad; / or an empty value is the home page. A query or #fragment "
992
+ + "is kept; a full address is accepted only if it is on this site's own host. --source is where the link will be posted: "
993
+ + `${LINK_SOURCES.map((source) => source.key).join(", ")}. The medium (social or email) is set by the source and cannot be chosen. 'reader' is not offered: `
994
+ + "a published page's own share action sets it.",
995
+ "The address is https://<public host><path>?utm_source=<source>&utm_medium=<medium>[&utm_campaign=<campaign>][&utm_content=<placement>]. "
996
+ + "A utm_source, utm_medium, utm_campaign or utm_content already on the path is replaced, never duplicated. Page views carry these values into the site's "
997
+ + "analytics only for campaigns and placements the site admits; anything else is dropped.",
998
+ "The host is the site's public address, read from the signed-in site; a site credential alone cannot read it, so "
999
+ + "pass --domain <host> then. A site with no public address yet has nothing to build a link to.",
1000
+ "A campaign or placement is checked first. When the workspace has marketing/campaigns.json or marketing/placements.json "
1001
+ + "(from 'marketing pull'), the file is the record of what exists; otherwise the live site is asked. A key it does not "
1002
+ + "hold, or one the site has archived, is refused, because it would not be counted. A key that exists but is not live yet is "
1003
+ + "allowed, and the result carries a note: 'publishing' while its release runs, 'goes live with your first publish' for a site "
1004
+ + "that has not published, or 'not in the current release' after a rollback. Until it is live, page views count under the source only. A note read from a file says it is as of the last marketing pull or push, so run 'marketing pull' for the current state.",
1005
+ "The result is { link: { address, host, path, source, medium, campaign?, placement? }, notes? }.",
1006
+ ]),
1007
+ example: Object.freeze({
1008
+ command: `${CLI_BINARY_NAME} link build --path /recipes/spring-salad --source newsletter --campaign spring-launch --placement nl-footer`,
1009
+ address: "https://example.com/recipes/spring-salad?utm_source=newsletter&utm_medium=email&utm_campaign=spring-launch&utm_content=nl-footer",
1010
+ }),
1011
+ }),
906
1012
  media: Object.freeze({
907
1013
  title: "Media upload contract",
908
1014
  summary: "Upload workspace-root-relative raster files and videos; images are recorded in .taproot-site-media.json and videos report their ids.",
@@ -1728,7 +1834,7 @@ const FREE_FORM_REFERENCE = Object.freeze({
1728
1834
  legalAndPrivacyPages: Object.freeze({
1729
1835
  ...LEGAL_PLATFORM_LINK_GUIDANCE,
1730
1836
  choosing:
1731
- `A policy that needs a version and an effective date is a legal page, which the CLI cannot author: see '${CLI_BINARY_NAME} help page legal'.`,
1837
+ `A policy that needs a version and an effective date is a legal page; see '${CLI_BINARY_NAME} help page legal' for draft editing and app approval.`,
1732
1838
  }),
1733
1839
  componentNode: Object.freeze({
1734
1840
  type: "componentBlock",
package/src/session.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  ROLLOUT_REFUSAL_FIELD,
12
12
  VERB_LOGIN,
13
13
  VERB_USE,
14
+ SURFACE_STANDARD,
14
15
  } from "./constants.js";
15
16
  import { loadSiteConfig } from "./config.js";
16
17
  import { cliOutdatedError, compareCliVersions, isBehindLatest } from "./cli-release.js";
@@ -85,7 +86,7 @@ function keyMissingError() {
85
86
  * The exchange's own answer is checked again afterwards, because the
86
87
  * recording can be stale.
87
88
  */
88
- function requireRecordedSurface(invocation, config) {
89
+ export function requireRecordedSurface(invocation, config) {
89
90
  if (invocation.surface === undefined || config.authoringSurface === undefined) return;
90
91
  if (!surfaceAllows(config.authoringSurface, invocation.surface)) {
91
92
  throw surfaceRefusal(invocation.verb ?? "This verb", config.authoringSurface, { source: "config" });
@@ -219,6 +220,7 @@ export async function resolveApiBaseUrl(environment) {
219
220
  }
220
221
 
221
222
  export async function openSession(invocation = {}) {
223
+ if (invocation.resolvedSession !== undefined) return invocation.resolvedSession;
222
224
  const environment = invocation.environment ?? process.env;
223
225
  const onProgress = progressSink(invocation);
224
226
  const now = invocation.now ?? Date.now;
@@ -231,6 +233,9 @@ export async function openSession(invocation = {}) {
231
233
  // at all.
232
234
  const environmentToken = environment?.[PUBLISH_KEY_ENVIRONMENT_VARIABLE];
233
235
  if (environmentToken !== undefined) {
236
+ if (invocation.legalDrafts) {
237
+ throw new SiteAuthoringError("auth.legal_environment_override", `Unset ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} and run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' to use the selected site's legal draft grant.`);
238
+ }
234
239
  const apiBaseUrl = await resolveApiBaseUrl(environment);
235
240
  const config = await loadConfig(invocation);
236
241
  if (!config.siteId) throw siteMissingError();
@@ -244,6 +249,7 @@ export async function openSession(invocation = {}) {
244
249
  return {
245
250
  config,
246
251
  client,
252
+ legalDrafts: false,
247
253
  siteId: config.siteId,
248
254
  surface: config.authoringSurface,
249
255
  platform: UNKNOWN_PLATFORM,
@@ -253,6 +259,32 @@ export async function openSession(invocation = {}) {
253
259
  };
254
260
  }
255
261
 
262
+ if (invocation.legalDrafts) {
263
+ const apiBaseUrl = await resolveApiBaseUrl(environment);
264
+ const config = await loadConfig(invocation);
265
+ if (!config.siteId) throw siteMissingError();
266
+ if (config.authoringSurface !== undefined && config.authoringSurface !== SURFACE_STANDARD) {
267
+ throw new SiteAuthoringError("auth.legal_site_required", "Legal draft access requires a standard site.");
268
+ }
269
+ requireRecordedSurface(invocation, config);
270
+ const store = await readCredentialStore(environment);
271
+ const credential = selectCredential(store.credentials, credentialApiOrigin(apiBaseUrl), { siteId: config.siteId });
272
+ if (credential === undefined) {
273
+ throw new SiteAuthoringError("auth.legal_grant_missing", `Legal draft access is missing. Run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.`);
274
+ }
275
+ if (Date.parse(credential.keyExpiresAt) <= now()) {
276
+ onProgress(
277
+ `The stored legal draft grant expired at ${credential.keyExpiresAt}. `
278
+ + `If Taproot refuses it, run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.`,
279
+ );
280
+ }
281
+ return {
282
+ config, client: invocation.client ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, credential.key)),
283
+ legalDrafts: true, siteId: config.siteId, surface: config.authoringSurface,
284
+ platform: UNKNOWN_PLATFORM, release: UNKNOWN_CLI_RELEASE, now, onProgress,
285
+ };
286
+ }
287
+
256
288
  let config;
257
289
  let apiBaseUrl;
258
290
  const signIn = await requireSignIn(environment, async () => {
@@ -280,6 +312,7 @@ export async function openSession(invocation = {}) {
280
312
  return {
281
313
  config,
282
314
  client: invocation.client,
315
+ legalDrafts: false,
283
316
  siteId: config.siteId,
284
317
  surface: config.authoringSurface,
285
318
  platform: UNKNOWN_PLATFORM,
@@ -403,6 +436,7 @@ export async function openSession(invocation = {}) {
403
436
  config,
404
437
  client,
405
438
  siteId: config.siteId,
439
+ legalDrafts: false,
406
440
  surface: exchanged.authoringSurface,
407
441
  exchanged: {
408
442
  keyId: exchanged.keyId,
@@ -36,7 +36,6 @@ export const SITE_CHECK_WORDING = Object.freeze({
36
36
  "redirect.source_invalid": () => "has a source that is not an absolute site path",
37
37
  "redirect.source_index_html": () => "has a source ending in index.html",
38
38
  "redirect.source_integration_data": () => "has a source under the integration data root",
39
- "redirect.source_share_link": () => "has a source under the share-link root",
40
39
  "redirect.source_runtime_mirror": () => "has a source under the published-site runtime",
41
40
  "redirect.source_generated_file": () => "has a source naming a file the site generates",
42
41
  "redirect.source_not_storable": () => "has a source the publisher cannot store",
@@ -16,7 +16,6 @@
16
16
  { "id": "redirect.source_invalid", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "none" },
17
17
  { "id": "redirect.source_index_html", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
18
18
  { "id": "redirect.source_integration_data", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
19
- { "id": "redirect.source_share_link", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
20
19
  { "id": "redirect.source_runtime_mirror", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
21
20
  { "id": "redirect.source_generated_file", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
22
21
  { "id": "redirect.source_not_storable", "severity": "error", "scope": "site", "runsAt": ["save", "validate"], "subject": "redirect_source", "detail": "first_segment" },
@@ -19,7 +19,6 @@ export const RULE_FORM_KEY_UNKNOWN = "form.key_unknown";
19
19
  export const RULE_REDIRECT_SOURCE_INVALID = "redirect.source_invalid";
20
20
  export const RULE_REDIRECT_SOURCE_INDEX_HTML = "redirect.source_index_html";
21
21
  export const RULE_REDIRECT_SOURCE_INTEGRATION_DATA = "redirect.source_integration_data";
22
- export const RULE_REDIRECT_SOURCE_SHARE_LINK = "redirect.source_share_link";
23
22
  export const RULE_REDIRECT_SOURCE_RUNTIME_MIRROR = "redirect.source_runtime_mirror";
24
23
  export const RULE_REDIRECT_SOURCE_GENERATED_FILE = "redirect.source_generated_file";
25
24
  export const RULE_REDIRECT_SOURCE_NOT_STORABLE = "redirect.source_not_storable";
@@ -282,7 +281,6 @@ export function normalizeRedirectPath(value) {
282
281
  }
283
282
  const GENERATED_ROOT_FILES = ["404.html", "sitemap.xml", "robots.txt", "favicon.ico"];
284
283
  const INTEGRATION_DATA_ROOT = "integrations";
285
- const SHARE_LINK_ROOT = "s";
286
284
  const MAX_MARKER_SEGMENT_BYTES = 200;
287
285
  const MAX_OUTPUT_PATH_LENGTH = 1_024;
288
286
  const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
@@ -317,10 +315,6 @@ export function redirectSourceRule(edgePath) {
317
315
  }
318
316
  if (first.toLowerCase() === INTEGRATION_DATA_ROOT)
319
317
  return refuse(RULE_REDIRECT_SOURCE_INTEGRATION_DATA);
320
- const shareSegments = decoded.replace(/^\/+|\/+$/gu, "").split("/");
321
- if (shareSegments.length > 1 && shareSegments[1].length > 0 && shareSegments[0].toLowerCase() === SHARE_LINK_ROOT) {
322
- return refuse(RULE_REDIRECT_SOURCE_SHARE_LINK);
323
- }
324
318
  if (isRuntimeMirrorPath(decoded))
325
319
  return refuse(RULE_REDIRECT_SOURCE_RUNTIME_MIRROR);
326
320
  if (GENERATED_ROOT_FILES.includes(first.toLowerCase()))
@@ -323,12 +323,6 @@ const TYPE_DETAILS = Object.freeze({
323
323
  },
324
324
  });
325
325
 
326
- /**
327
- * The legal page type (TR01221). It is described here and nowhere else in the
328
- * CLI: it has no template name in `typed-pages.js` because a site-authoring
329
- * key cannot author it, so `pull` records it as a metadata-only page and
330
- * `pages push` never sees a source for it.
331
- */
332
326
  export const LEGAL_PAGE_TYPE = "legal";
333
327
 
334
328
  export const TYPED_PAGE_TYPES = Object.freeze([...Object.keys(TYPE_DETAILS), LEGAL_PAGE_TYPE]);
@@ -355,15 +349,14 @@ function legalReference() {
355
349
  displayName: "Legal page",
356
350
  summary:
357
351
  "A versioned legal document such as a privacy policy or terms: an automatic h1, a version and effective date, and "
358
- + "permanent links to each version. The CLI sees it as metadata only; it is created and published in the app.",
359
- readOnly: true,
352
+ + "permanent links to each version. The CLI can edit drafts with an explicit site grant; approval and publication happen in the app.",
353
+ readOnly: false,
360
354
  workspace: Object.freeze({
361
355
  directory: `${PAGES_DIRECTORY}/`,
362
356
  templateType: "TEMPLATE_TYPE_LEGAL",
363
357
  sourceRule:
364
- "A legal page has no source file. 'pull' lists it in .taproot-site-manifest.json with templateType "
365
- + "TEMPLATE_TYPE_LEGAL and workspaceMode \"metadata-only\" and writes nothing under pages/ for it, so 'pages push' has "
366
- + "nothing to send.",
358
+ "Pull writes a .pm.json source containing template legal and data with documentKey, documentRole, summaryOfChanges, material and body. "
359
+ + "Edit that source to change the draft; Markdown is not supported. To create one, add a .pm.json source and a manifest pages entry with file, title and path, without a pageId. Version, effectiveDate and supersedesVersion are assigned by Taproot and must not be authored.",
367
360
  }),
368
361
  fields: Object.freeze([
369
362
  Object.freeze({ name: "title", description: "The page title. It prints as the page's h1, so the body should not open with its own title." }),
@@ -374,13 +367,13 @@ function legalReference() {
374
367
  Object.freeze({
375
368
  name: "documentKey",
376
369
  description:
377
- "The document's stable identity, set in the app. The permanent version address exists only once the page has "
370
+ "The document's stable identity, fixed after creation. The permanent version address exists only once the page has "
378
371
  + "a document key and a version.",
379
372
  }),
380
373
  Object.freeze({
381
374
  name: "documentRole",
382
375
  description:
383
- "The typed purpose of the document, such as membership_terms or privacy_notice. Selected in the app; fixed for membership terms.",
376
+ "The typed purpose of the document, such as membership_terms or privacy_notice. Fixed after creation.",
384
377
  }),
385
378
  Object.freeze({ name: "summaryOfChanges", description: "A short note on what this version changed, written by the owner." }),
386
379
  Object.freeze({
@@ -402,14 +395,11 @@ function legalReference() {
402
395
  ]),
403
396
  authoring: Object.freeze({
404
397
  cli:
405
- "Read-only. 'pull' records the page's identity, path, title and status. No verb creates, edits, converts, "
406
- + "approves or publishes one. A bare 'approve' stages every tracked page with a draft, a pending legal draft "
407
- + "included, and the request holding it is refused (up to 100 pages go in one request): name the pages ('approve <path>').",
398
+ "Pull reads legal pages with ordinary Content access. Run 'taproot-site login --legal-drafts', then "
399
+ + "'taproot-site --legal-drafts pages push' to create or edit legal drafts. Approval and deletion are refused by the CLI; use the app. A bare 'approve' skips legal drafts; naming a legal page refuses the whole selection.",
408
400
  key:
409
- "A site-authoring key cannot create or publish a legal page, and a production deploy whose selected pages include "
410
- + "one is refused for a key, because the permission that governs legal documents (site.legal_documents.manage) is "
411
- + "outside the key's delegation.content, delegation.design and delegation.deployments capabilities. Until a "
412
- + "person promotes an app-approved legal page, every production promotion of a release staged after that approval is refused. This is how keys work today; whether a key should ever author a legal page is an open owner decision.",
401
+ "Only an explicit site-specific legal-drafts grant can save legal drafts. It does not grant site.legal_documents.manage, "
402
+ + "and cannot approve, delete or deploy legal documents.",
413
403
  app:
414
404
  "A signed-in person who may manage the site's legal documents creates the page, writes its body, and publishes it "
415
405
  + "with a production deployment in the app. Taproot assigns the version and effective date at that point.",
@@ -421,18 +411,21 @@ function legalReference() {
421
411
  freeForm:
422
412
  "A free-form page is an ordinary page. It has no version history, no effective date, no permanent version "
423
413
  + "addresses and no automatic heading: open its Markdown with a '# ' line holding the title (see "
424
- + `'${CLI_BINARY_NAME} help page free-form'). The CLI cannot author a legal page, so a policy written here is an ordinary `
425
- + "page, free-form unless another template fits: put the date it was last updated in its text, and tell the owner it is not versioned.",
414
+ + `'${CLI_BINARY_NAME} help page free-form'). A policy written as free-form is an ordinary page; put its last-updated date in its text and tell the owner it is not versioned.`,
426
415
  }),
427
416
  platformLink: LEGAL_PLATFORM_LINK_GUIDANCE,
428
417
  workflow: Object.freeze([
429
418
  Object.freeze({
430
419
  command: `${CLI_BINARY_NAME} pull`,
431
- result: "Records the page as metadata-only in the manifest; nothing to edit.",
420
+ result: "Writes the legal draft as a .pm.json source and records its identity in the manifest.",
421
+ }),
422
+ Object.freeze({
423
+ command: `${CLI_BINARY_NAME} login --legal-drafts`,
424
+ result: "Request a site-specific grant; a person approves it in the app.",
432
425
  }),
433
426
  Object.freeze({
434
- command: "the app",
435
- result: "A person with the legal permission edits the draft and promotes it with a production deployment.",
427
+ command: `${CLI_BINARY_NAME} --legal-drafts pages push`,
428
+ result: "Saves changed legal drafts. A person approves and publishes them in the app.",
436
429
  }),
437
430
  ]),
438
431
  });
@@ -516,7 +509,7 @@ function formatField(field) {
516
509
  }
517
510
 
518
511
  function formatLegalReference(page) {
519
- return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields (set in the app):\n${
512
+ return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields:\n${
520
513
  page.fields.map((field) => ` ${field.name}: ${field.description}`).join("\n")
521
514
  }\n\nHow it renders:\n${page.rendering.map((line) => ` - ${line}`).join("\n")}\n\nWho can author it:\n CLI ${page.authoring.cli}\n site-authoring key ${page.authoring.key}\n app ${page.authoring.app}\n\nLegal page or free-form page:\n legal ${page.choosing.legal}\n free-form ${page.choosing.freeForm}\n\nLinking Taproot: ${page.platformLink.rule}\n example ${page.platformLink.example}\n\nWorkflow:\n${
522
515
  page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
@@ -30,6 +30,7 @@ export const TEMPLATE_ARTICLE = "article";
30
30
  export const TEMPLATE_RECIPE = "recipe";
31
31
  export const TEMPLATE_ALBUM = "album";
32
32
  export const TEMPLATE_PLACE_REVIEW = "place-review";
33
+ export const TEMPLATE_LEGAL = "legal";
33
34
  /**
34
35
  * A page Taproot generates (a tag, a month, a folder). It is update-only: the
35
36
  * owner's title, breadcrumb, description and introduction are authored, and
@@ -44,6 +45,7 @@ export const PAGE_TEMPLATES = Object.freeze({
44
45
  [TEMPLATE_RECIPE]: Object.freeze({ wireType: "TEMPLATE_TYPE_RECIPE", dataKey: "recipeData" }),
45
46
  [TEMPLATE_ALBUM]: Object.freeze({ wireType: "TEMPLATE_TYPE_ALBUM", dataKey: "albumData" }),
46
47
  [TEMPLATE_PLACE_REVIEW]: Object.freeze({ wireType: "TEMPLATE_TYPE_PLACE_REVIEW", dataKey: "placeReviewData" }),
48
+ [TEMPLATE_LEGAL]: Object.freeze({ wireType: "TEMPLATE_TYPE_LEGAL", dataKey: "legalDocumentData" }),
47
49
  [TEMPLATE_GENERATED]: Object.freeze({ wireType: "TEMPLATE_TYPE_GENERATED", dataKey: "generatedPageData" }),
48
50
  });
49
51
 
@@ -77,6 +79,7 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
77
79
  [TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
78
80
  // Generated pages have no Markdown form.
79
81
  [TEMPLATE_GENERATED]: Object.freeze([]),
82
+ [TEMPLATE_LEGAL]: Object.freeze([]),
80
83
  });
81
84
 
82
85
  export const FRONT_MATTER_KEYS = Object.freeze(
@@ -172,7 +175,17 @@ export function isAuthorableTemplateType(wireType) {
172
175
  return templateNameForWireType(wireType) !== undefined;
173
176
  }
174
177
 
175
- /** Whether a wire `templateType` is a generated page. */
178
+ /** Whether this wire template type is a legal document. */
179
+ export function isLegalTemplateType(wireType) {
180
+ return wireType === PAGE_TEMPLATES[TEMPLATE_LEGAL].wireType;
181
+ }
182
+
183
+ /** The legal identity fields that remain fixed after creation. */
184
+ export function legalIdentity(data) {
185
+ return { documentKey: data.documentKey, documentRole: data.documentRole };
186
+ }
187
+
188
+ /** Whether this wire template type is a generated page. */
176
189
  export function isGeneratedTemplateType(wireType) {
177
190
  return wireType === PAGE_TEMPLATES[TEMPLATE_GENERATED].wireType;
178
191
  }
@@ -342,6 +355,13 @@ function canonicalIngredient(value) {
342
355
  * server's JSON omits defaults and may gain fields).
343
356
  */
344
357
  const DATA_BUILDERS = Object.freeze({
358
+ [TEMPLATE_LEGAL]: (raw) => ({
359
+ documentKey: raw.documentKey ?? "",
360
+ documentRole: raw.documentRole ?? "",
361
+ summaryOfChanges: raw.summaryOfChanges ?? "",
362
+ material: raw.material === true,
363
+ body: raw.body,
364
+ }),
345
365
  [TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
346
366
  [TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
347
367
  // Defaults are written out, so a page with no custom title reads the same on every pull.
@@ -415,6 +435,16 @@ function optionalPlace(value, file, path) {
415
435
  /** Strict checks on a source document's `data`, returning it in canonical form. */
416
436
  function canonicalSourceData(template, data, file) {
417
437
  const path = "data";
438
+ if (template === TEMPLATE_LEGAL) {
439
+ requireKeys(data, ["documentKey", "documentRole", "summaryOfChanges", "material", "body"], file, path);
440
+ for (const key of ["documentKey", "documentRole", "summaryOfChanges"]) {
441
+ if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
442
+ }
443
+ if (data.material !== undefined && typeof data.material !== "boolean") {
444
+ throw fail("pages.document_shape", `'${file}' ${path}.material must be true or false.`, file);
445
+ }
446
+ return DATA_BUILDERS[template]({ ...data, body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }) });
447
+ }
418
448
  if (template === TEMPLATE_ARTICLE) {
419
449
  requireKeys(data, ["body", "place"], file, path);
420
450
  return DATA_BUILDERS[template]({
@@ -564,10 +594,10 @@ export function typedDocumentFromJson(parsed, file) {
564
594
  file,
565
595
  );
566
596
  }
567
- if (template === TEMPLATE_GENERATED && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
597
+ if ((template === TEMPLATE_GENERATED || template === TEMPLATE_LEGAL) && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
568
598
  throw fail(
569
599
  "pages.document_shape",
570
- `'${file}' is a generated page, which has no displayDate or coverImageId of its own: its document is `
600
+ `'${file}' is a ${template} page, which has no displayDate or coverImageId of its own: its document is `
571
601
  + "{ template, data }.",
572
602
  file,
573
603
  );
@@ -602,6 +632,16 @@ const pagePlaceId = (value) =>
602
632
 
603
633
  /** Lenient projections of what the site returned for each template's data. */
604
634
  const PAGE_PROJECTIONS = Object.freeze({
635
+ [TEMPLATE_LEGAL]: (raw) => {
636
+ const body = presentDocument(raw.body);
637
+ return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_LEGAL]({
638
+ documentKey: typeof raw.documentKey === "string" ? raw.documentKey : "",
639
+ documentRole: typeof raw.documentRole === "string" ? raw.documentRole : "",
640
+ summaryOfChanges: typeof raw.summaryOfChanges === "string" ? raw.summaryOfChanges : "",
641
+ material: raw.material === true,
642
+ body,
643
+ });
644
+ },
605
645
  [TEMPLATE_ARTICLE]: (raw) => {
606
646
  const body = presentDocument(raw.body);
607
647
  return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body, place: pagePlaceId(raw.place) });
@@ -666,7 +706,7 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
666
706
  const data = PAGE_PROJECTIONS[name](raw);
667
707
  if (data === undefined) return undefined;
668
708
  // A generated page's date and cover are not part of its source: a push leaves them as the site holds them.
669
- const generated = name === TEMPLATE_GENERATED;
709
+ const generated = name === TEMPLATE_GENERATED || name === TEMPLATE_LEGAL;
670
710
  return typedDocument(name, {
671
711
  displayDate: !generated && typeof page.displayDate === "string" && page.displayDate !== ""
672
712
  ? page.displayDate
@@ -705,7 +745,7 @@ export function contentDocuments(document_) {
705
745
  /** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
706
746
  export function sentDisplayDate(wireType) {
707
747
  const name = templateNameForWireType(wireType);
708
- return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED;
748
+ return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED && name !== TEMPLATE_LEGAL;
709
749
  }
710
750
 
711
751
  // The nodes the API accepts in an album or recipe introduction and in a recipe
@@ -903,7 +943,7 @@ export function wireTemplate(document_) {
903
943
  * an update, and an empty string means "clear", which is what the API does.
904
944
  */
905
945
  export function wirePageFields(document_) {
906
- if (documentTemplate(document_) === TEMPLATE_FREE_FORM) return {};
946
+ if ([TEMPLATE_FREE_FORM, TEMPLATE_LEGAL].includes(documentTemplate(document_))) return {};
907
947
  return definedEntries([
908
948
  ["displayDate", document_.displayDate],
909
949
  ["coverImageId", document_.coverImageId],
@@ -1111,6 +1151,10 @@ function parseFlag(fields, key, file) {
1111
1151
  }
1112
1152
 
1113
1153
  /** Generated pages are edited in their .pm.json source only. */
1154
+ export function legalMarkdownFault(file) {
1155
+ return fail("pages.legal_markdown", `'${file}' declares the legal template, which has no Markdown form. Use a .pm.json document.`, file);
1156
+ }
1157
+
1114
1158
  export function generatedMarkdownFault(file) {
1115
1159
  return fail(
1116
1160
  "pages.generated_markdown",
@@ -1149,6 +1193,7 @@ export function templateFrontMatterFault(template, fieldNames, file) {
1149
1193
  */
1150
1194
  export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
1151
1195
  if (template === TEMPLATE_GENERATED) throw generatedMarkdownFault(file);
1196
+ if (template === TEMPLATE_LEGAL) throw legalMarkdownFault(file);
1152
1197
  const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
1153
1198
  let coverImageId;
1154
1199
  if (fields.has("coverImage")) {