@taprootio/site-authoring 0.24.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -235,8 +235,8 @@ Which verbs apply depends on the site:
235
235
  `pull` snapshots its four settings documents, `theme push`, `footer push`,
236
236
  and `media upload` author them, and `deploy` stages and promotes them.
237
237
  Its pages, navigation, and redirects come from the Docs artifact, so
238
- `pages push`, `nav push`, the `redirects` and `forms` verbs, `approve`, and the
239
- `preview` verbs refuse with `surface.presentation_only` before any request.
238
+ `pages push`, `nav push`, the `redirects`, `forms` and `marketing` verbs, `link build`,
239
+ `approve`, and the `preview` verbs refuse with `surface.presentation_only` before any request.
240
240
  - A **prebuilt Docs** site (`none`) serves its artifact unchanged and takes no
241
241
  verb; each refuses with `surface.none`.
242
242
 
@@ -641,6 +641,54 @@ and takes effect without publishing or a new form version. Delivery requires
641
641
  Taproot's rollout and email provider readiness; opt-in does not enable either
642
642
  or backfill earlier submissions. The CLI never pulls visitor answers.
643
643
 
644
+ ### Campaigns, placements and tracked links
645
+
646
+ A campaign groups the links of one push, and a placement names the spot a
647
+ link appears, such as a newsletter footer. Both are bounded sets of public
648
+ keys the site's release admits, so a page view that carries a matching
649
+ `utm_campaign` or `utm_content` is counted under it. Each is one JSON file:
650
+ `marketing/campaigns.json` is `{ campaigns: [{ key, name, note?, starts_on?,
651
+ ends_on? }] }` and `marketing/placements.json` is `{ placements: [{ key, name
652
+ }] }`.
653
+
654
+ ```bash
655
+ taproot-site marketing pull # write both files, with a baseline
656
+ # edit marketing/campaigns.json and marketing/placements.json
657
+ taproot-site marketing push # create, update and restore
658
+ taproot-site marketing push --archive # also archive what the files leave out
659
+ taproot-site link build --path /recipes/spring-salad --source newsletter \
660
+ --campaign spring-launch --placement nl-footer
661
+ ```
662
+
663
+ `marketing pull` replaces both files with the site's live records, each with
664
+ the read-only `state` (live, publishing, goes_live_with_first_publish,
665
+ not_in_current_release) and `counted_from`, the day its key first went live.
666
+ Archived records are listed and not written. `marketing push` validates both
667
+ files, compares every record with the site, and then creates a key the site
668
+ lacks, updates the labels of an existing key, and restores an archived key a
669
+ file names. A key is public URL text and never changes or returns to use, so a
670
+ changed key is a new record; the old one is archived only when you pass
671
+ `--archive`, and only if this workspace pulled it. A record that changed on the
672
+ site since the pull is refused (`marketing.concurrent_modification`), as is a
673
+ key the site has that this workspace never pulled (`marketing.pull_required`).
674
+ Quota and key refusals are shown as Taproot words them. Like `forms push`, it
675
+ has no plan step: it reads the site, refuses a stale baseline, and writes.
676
+ Creating or restoring a key queues the silent release that starts counting it;
677
+ nothing is deployed or waited for.
678
+
679
+ `link build` prints one address and saves nothing. It uses the Marketing link
680
+ builder's own rules, compiled from the app: the source decides the medium
681
+ (`reader` is not offered), a `utm_source`, `utm_medium`, `utm_campaign` or `utm_content` already on the path is replaced (other `utm_` pairs are kept), and a
682
+ campaign or placement is checked against `marketing/*.json` when the workspace
683
+ has them, and otherwise against the live site. A key that is not live yet is
684
+ allowed, with a note that page views count under the source only until it is
685
+ (a note read from a file is as of the last `marketing pull` or push).
686
+ The host is the signed-in site's public address; pass `--domain <host>` when
687
+ only a site credential is available. The `delegation.content` capability
688
+ carries what these verbs need (`site.analytics.view`, plus the publish
689
+ permission to change anything); `whoami` reports whether the last exchange
690
+ held it. `help marketing` and `help link` state the contracts.
691
+
644
692
  `preview page <page-path-or-id>` renders one persisted draft behind the
645
693
  staging gate and mints a browser handoff URL, reported only in the final JSON
646
694
  result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.24.0",
3
+ "version": "0.27.0",
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": "6.5.0",
31
+ "@taprootio/espalier": "6.7.0",
32
32
  "mediabunny": "1.61.1"
33
33
  },
34
34
  "scripts": {
@@ -0,0 +1,31 @@
1
+ {
2
+ "version": 1,
3
+ "keyPattern": "^[a-z0-9-]{3,32}$",
4
+ "keyMinLength": 3,
5
+ "keyMaxLength": 32,
6
+ "maxActivePerSite": 50,
7
+ "artifactPath": "_taproot/analytics-campaigns.json",
8
+ "artifactVersion": 1,
9
+ "collectorMarker": "c1",
10
+ "maxDigitRun": 8,
11
+ "maxTotalPerSite": 500,
12
+ "nameMaxLength": 80,
13
+ "noteMaxLength": 500,
14
+ "vectors": [
15
+ {"value": "spring-26", "valid": true},
16
+ {"value": "abc", "valid": true},
17
+ {"value": "ab", "valid": false},
18
+ {"value": "a-very-long-campaign-key-that-is-over-32", "valid": false},
19
+ {"value": "Spring-26", "valid": false},
20
+ {"value": "spring_26", "valid": false},
21
+ {"value": "spring 26", "valid": false},
22
+ {"value": "spring|26", "valid": false},
23
+ {"value": "spring.26", "valid": false},
24
+ {"value": "", "valid": false},
25
+ {"value": "-leading", "valid": true},
26
+ {"value": "x12345678y", "valid": true},
27
+ {"value": "promo-123456789", "valid": false},
28
+ {"value": "spring-26\n", "valid": false},
29
+ {"value": "0123456789", "valid": false}
30
+ ]
31
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": 1,
3
+ "keyGrammar": "campaign",
4
+ "artifactPath": "_taproot/analytics-links.json",
5
+ "artifactVersion": 1,
6
+ "collectorMarker": "c2",
7
+ "maxActivePerSite": 200,
8
+ "maxTotalPerSite": 2000,
9
+ "nameMaxLength": 80,
10
+ "vectors": [
11
+ {"value": "nl-footer", "valid": true},
12
+ {"value": "abc", "valid": true},
13
+ {"value": "ab", "valid": false},
14
+ {"value": "NL-footer", "valid": false},
15
+ {"value": "nl_footer", "valid": false},
16
+ {"value": "nl footer", "valid": false},
17
+ {"value": "nl|footer", "valid": false},
18
+ {"value": "a-label-key-that-is-longer-than-32", "valid": false},
19
+ {"value": "promo-123456789", "valid": false},
20
+ {"value": "", "valid": false}
21
+ ]
22
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "version": 1,
3
+ "media": ["social", "email", "share"],
4
+ "sources": [
5
+ { "key": "linkedin", "name": "LinkedIn", "medium": "social", "aliases": ["linkedin.com", "lnkd.in", "com.linkedin.android"] },
6
+ { "key": "x", "name": "X", "medium": "social", "aliases": ["x.com", "twitter.com", "t.co", "com.twitter.android"] },
7
+ { "key": "facebook", "name": "Facebook", "medium": "social", "aliases": ["facebook.com", "fb.com", "fb.me", "com.facebook.katana", "com.facebook.lite"] },
8
+ { "key": "instagram", "name": "Instagram", "medium": "social", "aliases": ["instagram.com", "com.instagram.android"] },
9
+ { "key": "threads", "name": "Threads", "medium": "social", "aliases": ["threads.net", "threads.com", "com.instagram.barcelona"] },
10
+ { "key": "bluesky", "name": "Bluesky", "medium": "social", "aliases": ["bsky.app", "bsky.social", "xyz.blueskyweb.app"] },
11
+ { "key": "mastodon", "name": "Mastodon", "medium": "social", "aliases": ["mastodon.social", "mastodon.online", "org.joinmastodon.android"] },
12
+ { "key": "reddit", "name": "Reddit", "medium": "social", "aliases": ["reddit.com", "redd.it", "com.reddit.frontpage"] },
13
+ { "key": "youtube", "name": "YouTube", "medium": "social", "aliases": ["youtube.com", "youtu.be", "com.google.android.youtube"] },
14
+ { "key": "newsletter", "name": "Newsletter", "medium": "email", "aliases": [] },
15
+ { "key": "reader", "name": "Reader shares", "medium": "share", "aliases": [] }
16
+ ]
17
+ }
@@ -24,7 +24,7 @@ export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
24
24
 
25
25
  /** What each capability lets an exchanged credential do, for help and `whoami`. */
26
26
  export const SITE_AUTHORING_CAPABILITY_SUMMARIES = Object.freeze({
27
- "delegation.content": "write pages, media, and form definitions",
27
+ "delegation.content": "write pages, media, form definitions, campaigns, and placements",
28
28
  "delegation.design": "set the theme, navigation, and footer",
29
29
  "delegation.deployments": "stage and deploy",
30
30
  });
@@ -1,5 +1,43 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.27.0",
5
+ "note": "Embedded forms inherit configurable background, border and shadow defaults from the site, with per-form overrides.",
6
+ "behavior": [
7
+ "Form definitions accept sparse presentation.background, presentation.border and presentation.shadow boolean overrides. Omitted values inherit the site; explicit false disables the decoration. An empty presentation object is omitted by the form editor.",
8
+ "Site publishing preferences expose formBoxBackground, formBoxBorder and formBoxShadow. Background and shadow default on; border defaults off. Stage publishing preferences and the form version to apply appearance changes.",
9
+ "Forms pull and push preserve appearance overrides, including explicit false.",
10
+ "The help reference version is 44."
11
+ ],
12
+ "help": [
13
+ "forms",
14
+ "settings"
15
+ ]
16
+ },
17
+ {
18
+ "version": "0.26.0",
19
+ "note": "marketing pull and marketing push keep a site's campaigns and placements as files, and link build prints a tracked address, so an agent can run a site's attribution over the CLI.",
20
+ "behavior": [
21
+ "New verbs: taproot-site marketing pull, marketing push [--archive] and link build --path <page> --source <key> [--campaign <key>] [--placement <key>] [--domain <host>]. A key now carries the permissions they need: delegation.content includes site.analytics.view, so a credential minted before this release gains read access to the site's campaigns and placements and, with the publish permission it already held, may change them.",
22
+ "marketing pull writes marketing/campaigns.json and marketing/placements.json (key, name, a campaign's note, starts_on and ends_on, and the read-only state and counted_from) and records a baseline in the pull manifest; a plain pull carries it forward. marketing push validates both files, compares every record with the site, and creates a new key, updates the labels of an existing one, or restores an archived key the file names. A key is public and never changes, so a changed key is a new record; a record the file leaves out is archived only with --archive, and only if the workspace pulled it. A record that changed on the site since the pull is refused (marketing.concurrent_modification, marketing.pull_required). Quota and key refusals are shown as Taproot returns them. There is no plan or apply step, as with forms push.",
23
+ "link build prints one address and saves nothing, with the Marketing link builder's own rules: the source decides the medium, the reader source is not offered, a utm_source, utm_medium, utm_campaign or utm_content on the path is replaced, and a campaign or placement is checked against marketing/*.json when the workspace has it, otherwise against the site. One that is not live yet is allowed with the builder's note that page views count under the source only until it is. The host is the signed-in site's public address; pass --domain when only a site credential is available.",
24
+ "whoami reports whether the last exchange allows campaigns and placements (marketing.access).",
25
+ "The help reference version is 43.",
26
+ "Built against Espalier 6.7.0. The default site theme is rebuilt against it and its content is unchanged except for the recorded Espalier version, so pulled and validated themes behave as before."
27
+ ],
28
+ "help": [
29
+ "marketing",
30
+ "link"
31
+ ]
32
+ },
33
+ {
34
+ "version": "0.25.0",
35
+ "note": "Redirects from /s/ addresses are ordinary redirects now that tracked share links are retired.",
36
+ "behavior": [
37
+ "A redirect whose source is /s/<anything> saves and validates like any other source. The redirect.source_share_link site-check rule is gone, so offline checks and the server no longer refuse it."
38
+ ],
39
+ "help": []
40
+ },
3
41
  {
4
42
  "version": "0.24.0",
5
43
  "note": "Site-specific consent enables legal draft authoring through JSON; legal approval and publication remain in the app.",
package/src/cli.js CHANGED
@@ -33,8 +33,11 @@ import {
33
33
  VERB_FORMS_PUSH,
34
34
  VERB_FORMS_VALIDATE,
35
35
  VERB_HELP,
36
+ VERB_LINK_BUILD,
36
37
  VERB_LOGIN,
37
38
  VERB_LOGOUT,
39
+ VERB_MARKETING_PULL,
40
+ VERB_MARKETING_PUSH,
38
41
  VERB_MEDIA_UPLOAD,
39
42
  VERB_NAV_PUSH,
40
43
  VERB_PAGES_DELETE,
@@ -88,12 +91,15 @@ import {
88
91
  REFERENCE_VERSION,
89
92
  } from "./reference-help.js";
90
93
  import { FONT_CATEGORIES, getFontCatalogReference } from "./font-catalog.js";
94
+ import { LINK_SOURCES } from "./marketing-contract.js";
91
95
  import { assertCliCurrent } from "./session.js";
92
96
  import { ApiError } from "./transport.js";
93
97
  import { formatThemeResolve } from "./theme-resolve.js";
94
98
  import { MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS } from "./theme-validation.js";
95
99
  import { VERB_HANDLERS } from "./verbs/index.js";
96
100
 
101
+ const LINK_SOURCE_KEYS = LINK_SOURCES.map((source) => source.key);
102
+
97
103
  /**
98
104
  * The verbs the local version gate applies to (TR00703).
99
105
  *
@@ -591,6 +597,45 @@ const VERBS = Object.freeze([
591
597
  + "that the caller may write forms. "
592
598
  + "See 'taproot-site help forms'.",
593
599
  },
600
+ {
601
+ name: VERB_MARKETING_PULL,
602
+ surface: SURFACE_STANDARD,
603
+ // Campaigns and placements ride the content capability, which carries the analytics-view permission that
604
+ // reads them and the publish permission that changes them (TR01301).
605
+ capabilities: [CAPABILITY_CONTENT],
606
+ tokens: ["marketing", "pull"],
607
+ summary: "Write the site's live campaigns and placements to marketing/ and record what was read.",
608
+ note: "Needs a pulled workspace ('pull' first). It replaces marketing/campaigns.json and marketing/placements.json "
609
+ + "with the site's records, each with its read-only state and the day its key was first counted from, and records "
610
+ + "a baseline that 'marketing push' fences against. Archived records are listed and not written. "
611
+ + "See 'taproot-site help marketing'.",
612
+ },
613
+ {
614
+ name: VERB_MARKETING_PUSH,
615
+ surface: SURFACE_STANDARD,
616
+ capabilities: [CAPABILITY_CONTENT],
617
+ tokens: ["marketing", "push"],
618
+ archiveOption: true,
619
+ summary: "Create, update and restore the campaigns and placements the marketing files name.",
620
+ note: "Every file is validated and compared with the site before anything is written. A key is public URL text "
621
+ + "and never changes, so a new key is a new record; a record the file leaves out is archived only with "
622
+ + "--archive, and only if this workspace pulled it. A file that names an archived key restores it. Creating or "
623
+ + "restoring a key queues the silent release that starts counting it; run 'marketing pull' later to see it live. "
624
+ + "A quota or key refusal is reported as Taproot returns it. See 'taproot-site help marketing'.",
625
+ },
626
+ {
627
+ name: VERB_LINK_BUILD,
628
+ surface: SURFACE_STANDARD,
629
+ capabilities: [CAPABILITY_CONTENT],
630
+ tokens: ["link", "build"],
631
+ linkOptions: true,
632
+ summary: "Print one tracked address for a page, a source, and an optional campaign and placement.",
633
+ note: "Saves nothing. The medium is set by the source ('reader' is not offered: a published page's own share "
634
+ + "action sets it). The host is the signed-in site's public address; pass --domain when only a site credential "
635
+ + "is available. A campaign or placement is checked against marketing/*.json when the workspace has the file, "
636
+ + "and otherwise against the live site; one that is not live yet is allowed with a note that page views count "
637
+ + "under the source only until it is. See 'taproot-site help link'.",
638
+ },
594
639
  {
595
640
  name: VERB_THEME_PUSH,
596
641
  surface: SURFACE_DOCS_PRESENTATION,
@@ -978,6 +1023,17 @@ function verbHelp(verb) {
978
1023
  const dryRunOption = verb.dryRun
979
1024
  ? "\n --dry-run Read the site and report what a push would change, without writing."
980
1025
  : "";
1026
+ const archiveOption = verb.archiveOption
1027
+ ? "\n --archive Archive a campaign or placement the file leaves out (never done without this)."
1028
+ : "";
1029
+ const linkOptions = verb.linkOptions
1030
+ ? `\n --path <page> The page on this site; / or '' for the home page. A query or #fragment you add is kept (required).
1031
+ --source <key> Where you will post the link: ${LINK_SOURCE_KEYS.join(", ")} (required).
1032
+ --campaign <key> A campaign key to group links from one push (optional).
1033
+ --placement <key> A placement key naming the spot the link will appear (optional).
1034
+ Write --option=<value> for a value that starts with hyphens.
1035
+ --domain <host> The site's public host; for a site credential with no sign-in to read it from.`
1036
+ : "";
981
1037
  const planOption = verb.planHash
982
1038
  ? "\n --plan <hash> The planHash 'plan' reported; required."
983
1039
  : "";
@@ -1060,7 +1116,7 @@ function verbHelp(verb) {
1060
1116
  ${verb.summary}
1061
1117
  ${boundary}
1062
1118
 
1063
- ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${planOption}${nameOption}${note}
1119
+ ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${archiveOption}${linkOptions}${planOption}${nameOption}${note}
1064
1120
  `;
1065
1121
  }
1066
1122
 
@@ -1077,6 +1133,39 @@ function matchVerb(arguments_) {
1077
1133
  return matched;
1078
1134
  }
1079
1135
 
1136
+ /** The `link build` options and the invocation field each fills. */
1137
+ const LINK_OPTION_FIELDS = Object.freeze({
1138
+ "--path": "linkPath",
1139
+ "--source": "linkSource",
1140
+ "--campaign": "linkCampaign",
1141
+ "--placement": "linkPlacement",
1142
+ "--domain": "linkDomain",
1143
+ });
1144
+
1145
+ /**
1146
+ * The value after a `link build` option. An option-like value is refused so a missing value never swallows the
1147
+ * next flag; only --path may be empty, which names the home page.
1148
+ */
1149
+ function parseLinkOption(rest, index, argument) {
1150
+ // `--campaign=--launch` is the one way to pass a value that starts with `--`, which a valid key may (a key may
1151
+ // begin with a hyphen); a separated value that looks like an option is refused so a missing value never swallows
1152
+ // the next flag.
1153
+ const [name, inline] = argument.includes("=") ? [argument.slice(0, argument.indexOf("=")), argument.slice(argument.indexOf("=") + 1)] : [argument, undefined];
1154
+ const value = inline ?? rest[index + 1];
1155
+ const field = name.slice(2);
1156
+ if (typeof value !== "string" || (inline === undefined && value.startsWith("--")) || (value === "" && name !== "--path")) {
1157
+ throw usageError("cli.option_value", `${name} requires a value.`, { field });
1158
+ }
1159
+ if (Buffer.byteLength(value, "utf8") > LIMITS.configPathBytes || hasAsciiControl(value)) {
1160
+ throw usageError(
1161
+ "cli.option_value",
1162
+ `${name} must be at most ${LIMITS.configPathBytes} bytes with no control characters.`,
1163
+ { field },
1164
+ );
1165
+ }
1166
+ return { name, value, consumed: inline === undefined ? 1 : 0 };
1167
+ }
1168
+
1080
1169
  function usageError(code, message, options = {}) {
1081
1170
  return new SiteAuthoringError(code, message, { ...options, exitCode: 2 });
1082
1171
  }
@@ -1126,6 +1215,8 @@ function parseReferenceArguments(arguments_) {
1126
1215
  || topic === "fixture"
1127
1216
  || topic === "redirects"
1128
1217
  || topic === "forms"
1218
+ || topic === "marketing"
1219
+ || topic === "link"
1129
1220
  || topic === "walkthrough"
1130
1221
  || topic === "delivery"
1131
1222
  || topic === "import"
@@ -1197,6 +1288,8 @@ function referenceResult(parsed) {
1197
1288
  case "nav":
1198
1289
  case "redirects":
1199
1290
  case "forms":
1291
+ case "marketing":
1292
+ case "link":
1200
1293
  case "media":
1201
1294
  case "preview":
1202
1295
  case "fixture":
@@ -1451,6 +1544,8 @@ function parseArguments(arguments_) {
1451
1544
  let fromSite = false;
1452
1545
  let keyName;
1453
1546
  let planHash;
1547
+ let archive = false;
1548
+ const linkValues = {};
1454
1549
  let metaTitle;
1455
1550
  let metaDescription;
1456
1551
  let metaAuthor;
@@ -1553,6 +1648,21 @@ function parseArguments(arguments_) {
1553
1648
  index += 1;
1554
1649
  continue;
1555
1650
  }
1651
+ if (verb.archiveOption && argument === "--archive") {
1652
+ if (archive) throw usageError("cli.duplicate_option", "--archive may be supplied only once.");
1653
+ archive = true;
1654
+ continue;
1655
+ }
1656
+ if (verb.linkOptions && Object.hasOwn(LINK_OPTION_FIELDS, argument.split("=")[0])) {
1657
+ const { name, value, consumed } = parseLinkOption(rest, index, argument);
1658
+ const field = LINK_OPTION_FIELDS[name];
1659
+ if (linkValues[field] !== undefined) {
1660
+ throw usageError("cli.duplicate_option", `${name} may be supplied only once.`, { field: name.slice(2) });
1661
+ }
1662
+ linkValues[field] = value;
1663
+ index += consumed;
1664
+ continue;
1665
+ }
1556
1666
  if (verb.dryRun && argument === "--dry-run") {
1557
1667
  if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
1558
1668
  dryRun = true;
@@ -1796,6 +1906,8 @@ function parseArguments(arguments_) {
1796
1906
  ...(themeContext === undefined ? {} : { themeContext }),
1797
1907
  ...(fromSite ? { fromSite } : {}),
1798
1908
  ...(planHash === undefined ? {} : { planHash }),
1909
+ ...(archive ? { archive } : {}),
1910
+ ...linkValues,
1799
1911
  ...(metaTitle === undefined ? {} : { metaTitle }),
1800
1912
  ...(metaDescription === undefined ? {} : { metaDescription }),
1801
1913
  ...(metaAuthor === undefined ? {} : { metaAuthor }),
@@ -1893,6 +2005,8 @@ export async function runCli({
1893
2005
  ...(parsed.themeContext === undefined ? {} : { themeContext: parsed.themeContext }),
1894
2006
  ...(parsed.fromSite === undefined ? {} : { fromSite: parsed.fromSite }),
1895
2007
  ...(parsed.planHash === undefined ? {} : { planHash: parsed.planHash }),
2008
+ ...(parsed.archive ? { archive: true } : {}),
2009
+ ...Object.fromEntries(Object.values(LINK_OPTION_FIELDS).filter((field) => parsed[field] !== undefined).map((field) => [field, parsed[field]])),
1896
2010
  // Only when given, like the other optional fields, so each verb keeps its default (TR00925).
1897
2011
  ...(parsed.deliveryUrl === undefined ? {} : { deliveryUrl: parsed.deliveryUrl }),
1898
2012
  ...(parsed.propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds: parsed.propagationWaitSeconds }),
package/src/constants.js CHANGED
@@ -94,6 +94,9 @@ export const VERB_REDIRECTS_PUSH = "redirects push";
94
94
  export const VERB_FORMS_PULL = "forms pull";
95
95
  export const VERB_FORMS_PUSH = "forms push";
96
96
  export const VERB_FORMS_VALIDATE = "forms validate";
97
+ export const VERB_MARKETING_PULL = "marketing pull";
98
+ export const VERB_MARKETING_PUSH = "marketing push";
99
+ export const VERB_LINK_BUILD = "link build";
97
100
  export const VERB_THEME_PUSH = "theme push";
98
101
  export const VERB_THEME_RESOLVE = "theme resolve";
99
102
  export const VERB_FOOTER_PUSH = "footer push";
@@ -22,6 +22,17 @@
22
22
  "type": "string",
23
23
  "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
24
24
  },
25
+ "presentation": {
26
+ "description": "Sparse embedded-form appearance choices. Omitted properties inherit the site's staged defaults; explicit false disables a choice. Omit the object when all choices inherit.",
27
+ "type": "object",
28
+ "minProperties": 1,
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "background": { "type": "boolean" },
32
+ "border": { "type": "boolean" },
33
+ "shadow": { "type": "boolean" }
34
+ }
35
+ },
25
36
  "submit_label": {
26
37
  "description": "The text on the submit button. Omit it for Taproot's default. Must contain more than whitespace; the validator also rejects a blank label, which a schema cannot express.",
27
38
  "type": "string",
@@ -106,7 +106,7 @@ export function contactFieldId(definition) {
106
106
  const emails = definition.fields.filter((field) => field.type === "email");
107
107
  return emails.length === 1 ? emails[0].id : undefined;
108
108
  }
109
- const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit", "newsletter_audience_id"];
109
+ const DEFINITION_KEYS = ["fields", "contact_field", "submit_label", "after_submit", "newsletter_audience_id", "presentation"];
110
110
  const BASE_FIELD_KEYS = ["id", "type", "label", "hint"];
111
111
  const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
112
112
  const isInteger = (value) => typeof value === "number" && Number.isInteger(value);
@@ -427,6 +427,22 @@ export function validateDefinition(definition) {
427
427
  }
428
428
  /** The button text and the after-submit choice, which sit beside the fields in a definition. */
429
429
  function validateFormSettings(definition, errors) {
430
+ if ("presentation" in definition) {
431
+ const presentation = definition.presentation;
432
+ if (!isPlainObject(presentation) || Object.keys(presentation).length === 0) {
433
+ errors.push({ path: "presentation", code: "invalid_presentation" });
434
+ }
435
+ else {
436
+ for (const key of sortedKeys(presentation)) {
437
+ if (key !== "background" && key !== "border" && key !== "shadow") {
438
+ errors.push({ path: `presentation.${key}`, code: "unknown_property" });
439
+ }
440
+ else if (typeof presentation[key] !== "boolean") {
441
+ errors.push({ path: `presentation.${key}`, code: "invalid_presentation_value" });
442
+ }
443
+ }
444
+ }
445
+ }
430
446
  const audience = definition.newsletter_audience_id;
431
447
  if (audience !== undefined && (typeof audience !== "string" || !isResourceId(audience))) {
432
448
  errors.push({ path: "newsletter_audience_id", code: "invalid_newsletter_audience" });
@@ -6,7 +6,7 @@ import { contactFieldId, validateDefinition } from "./field-validation.js";
6
6
  *
7
7
  * A form is one JSON file, `forms/<key>.json`, holding what an author decides:
8
8
  * its key, name, sink, retention, owner-notification preference and definition. The definition holds the fields
9
- * and the form's own settings (`contact_field`, `submit_label`, `after_submit`),
9
+ * and the form's own settings (`contact_field`, `submit_label`, `after_submit`, `presentation`),
10
10
  * exactly as the site stores it, so one validator reads one shape. The
11
11
  * definition's own rules belong to `shared/field-validation.ts` and are run
12
12
  * here through the generated copy, so this file never restates them. The one
@@ -289,6 +289,20 @@ export function projectFormForWorkspace(form, pathById) {
289
289
  if (form.definition.after_submit !== undefined) {
290
290
  definition.after_submit = canonicalAfterSubmit(form.definition.after_submit, pathById);
291
291
  }
292
+ if (form.definition.newsletter_audience_id !== undefined) {
293
+ definition.newsletter_audience_id = form.definition.newsletter_audience_id;
294
+ }
295
+ if (form.definition.presentation !== undefined) {
296
+ const presentation = {};
297
+ for (const name of ["background", "border", "shadow", ...Object.keys(form.definition.presentation).sort()]) {
298
+ if (form.definition.presentation[name] !== undefined) presentation[name] = form.definition.presentation[name];
299
+ }
300
+ definition.presentation = presentation;
301
+ }
302
+ // Preserve future properties on pull so an older workspace cannot silently erase them.
303
+ for (const name of Object.keys(form.definition).sort()) {
304
+ if (name !== "contact_field" && definition[name] === undefined) definition[name] = form.definition[name];
305
+ }
292
306
  return {
293
307
  key: form.key,
294
308
  ...projectFormSettings(form),