@taprootio/site-authoring 0.24.0 → 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.
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.26.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,29 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.26.0",
5
+ "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.",
6
+ "behavior": [
7
+ "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.",
8
+ "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.",
9
+ "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.",
10
+ "whoami reports whether the last exchange allows campaigns and placements (marketing.access).",
11
+ "The help reference version is 43.",
12
+ "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."
13
+ ],
14
+ "help": [
15
+ "marketing",
16
+ "link"
17
+ ]
18
+ },
19
+ {
20
+ "version": "0.25.0",
21
+ "note": "Redirects from /s/ addresses are ordinary redirects now that tracked share links are retired.",
22
+ "behavior": [
23
+ "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."
24
+ ],
25
+ "help": []
26
+ },
3
27
  {
4
28
  "version": "0.24.0",
5
29
  "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";
@@ -0,0 +1,179 @@
1
+ import { sitePath } from "./api.js";
2
+ import { REFUSAL_UNCLASSIFIED } from "./constants.js";
3
+ import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
4
+ import {
5
+ isCalendarDate,
6
+ isMarketingKey,
7
+ MARKETING_KINDS,
8
+ MARKETING_STATE_ARCHIVED,
9
+ marketingStateFromWire,
10
+ } from "./marketing-contract.js";
11
+ import { ApiError } from "./transport.js";
12
+
13
+ /**
14
+ * The owner-side campaign and placement routes `marketing pull` and `marketing push` call (TR01301).
15
+ *
16
+ * The wire spells a state as a proto enum name and a day as midnight UTC; the workspace file uses a lowercase
17
+ * word and a plain day. The translation lives here so neither side learns the other's vocabulary. A placement
18
+ * is stored as a link label, so its routes and wire names say so.
19
+ */
20
+
21
+ // A site holds at most a few hundred records of a kind, archived ones included; this bounds the reply size.
22
+ const MARKETING_RESPONSE_BYTES = 2 * 1024 * 1024;
23
+ // gRPC FailedPrecondition, which the transcoder carries as the `code` of the error body.
24
+ const GRPC_FAILED_PRECONDITION = 9;
25
+ const MIDNIGHT_UTC = /^(\d{4}-\d{2}-\d{2})T00:00:00(?:\.0+)?Z$/u;
26
+
27
+ function contract(message, field) {
28
+ return new SiteAuthoringError("api.marketing_contract", message, { field });
29
+ }
30
+
31
+ /** A wire timestamp that is midnight UTC as the day it names, or undefined when it is absent. */
32
+ function dayFromWire(value, field) {
33
+ if (value === undefined || value === null || value === "") return undefined;
34
+ const match = typeof value === "string" ? MIDNIGHT_UTC.exec(value) : null;
35
+ if (match === null || !isCalendarDate(match[1])) {
36
+ throw contract("Taproot returned a day this workspace cannot store.", field);
37
+ }
38
+ return match[1];
39
+ }
40
+
41
+ /** The UTC day an instant falls on: when a key first went live, page views under it are counted from that day. */
42
+ function dayOfInstant(value, field) {
43
+ if (value === undefined || value === null || value === "") return undefined;
44
+ const day = typeof value === "string" ? /^(\d{4}-\d{2}-\d{2})T/u.exec(value)?.[1] : undefined;
45
+ if (day === undefined || !isCalendarDate(day)) throw contract("Taproot returned a time this workspace cannot store.", field);
46
+ return day;
47
+ }
48
+
49
+ function dayToWire(day) {
50
+ return day === undefined ? undefined : `${day}T00:00:00Z`;
51
+ }
52
+
53
+ function normalizeRecord(kind, value) {
54
+ const { noun } = MARKETING_KINDS[kind];
55
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
56
+ throw contract(`Taproot returned a ${noun} that is not an object.`, noun);
57
+ }
58
+ if (!isCanonicalUuid(value.id) || !isMarketingKey(value.key)) {
59
+ throw contract(`Taproot returned a ${noun} with no usable id or a key this workspace cannot store.`, noun);
60
+ }
61
+ const state = marketingStateFromWire(kind, value.state);
62
+ if (state === undefined) throw contract(`Taproot returned a ${noun} with a state this CLI does not know.`, "state");
63
+ const record = {
64
+ id: value.id,
65
+ key: value.key,
66
+ name: typeof value.name === "string" ? value.name : "",
67
+ state,
68
+ archived: state === MARKETING_STATE_ARCHIVED || (value.archivedAt !== undefined && value.archivedAt !== null),
69
+ };
70
+ const countedFrom = dayOfInstant(value.releasedAt, "releasedAt");
71
+ if (countedFrom !== undefined) record.counted_from = countedFrom;
72
+ if (MARKETING_KINDS[kind].withLabels) {
73
+ if (typeof value.note === "string" && value.note !== "") record.note = value.note;
74
+ const startsOn = dayFromWire(value.startsOn, "startsOn");
75
+ const endsOn = dayFromWire(value.endsOn, "endsOn");
76
+ if (startsOn !== undefined) record.starts_on = startsOn;
77
+ if (endsOn !== undefined) record.ends_on = endsOn;
78
+ }
79
+ return record;
80
+ }
81
+
82
+ /** The fields an author decides, as the body of a create or an update. */
83
+ function writableBody(kind, record) {
84
+ return {
85
+ name: record.name,
86
+ ...(MARKETING_KINDS[kind].withLabels
87
+ ? { note: record.note ?? "", startsOn: dayToWire(record.starts_on), endsOn: dayToWire(record.ends_on) }
88
+ : {}),
89
+ };
90
+ }
91
+
92
+ /** Every record of this kind on the site, archived ones included, and whether the caller may change them. */
93
+ export async function listMarketingRecords(client, siteId, kind) {
94
+ const { route, wireList, noun } = MARKETING_KINDS[kind];
95
+ let response;
96
+ try {
97
+ response = await client.request(sitePath(siteId, route), { maximumResponseBytes: MARKETING_RESPONSE_BYTES });
98
+ } catch (error) {
99
+ // The site refuses with a precondition when it is not a Standard site or its licence leaves analytics out. The
100
+ // transport keeps only its own words for a refusal that names no field, so this says what both cases mean.
101
+ if (error instanceof ApiError && error.grpcCode === GRPC_FAILED_PRECONDITION && error.fields.length === 0) {
102
+ throw new SiteAuthoringError(
103
+ "marketing.unavailable",
104
+ `This site cannot list ${noun}s: they need a Standard site on a licence that includes analytics.`,
105
+ { status: error.status },
106
+ );
107
+ }
108
+ throw error;
109
+ }
110
+ const list = response?.[wireList];
111
+ if (list !== undefined && !Array.isArray(list)) {
112
+ throw contract(`Taproot returned a ${MARKETING_KINDS[kind].noun} list that is not an array.`, wireList);
113
+ }
114
+ return {
115
+ records: (list ?? []).map((value) => normalizeRecord(kind, value)),
116
+ canManage: response?.canManage === true,
117
+ maxActive: Number.isSafeInteger(response?.maxActive) ? response.maxActive : undefined,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Makes a record. `id` is chosen by the caller, which the site uses to return the record a repeated request made.
123
+ * The CLI sends a create once; a lost answer is settled by the next push finding the record already as the file
124
+ * asks.
125
+ */
126
+ export async function createMarketingRecord(client, siteId, kind, id, record) {
127
+ const { route, wireId } = MARKETING_KINDS[kind];
128
+ return normalizeRecord(
129
+ kind,
130
+ await client.request(sitePath(siteId, route), {
131
+ method: "POST",
132
+ body: { [wireId]: id, key: record.key, ...writableBody(kind, record) },
133
+ maximumResponseBytes: MARKETING_RESPONSE_BYTES,
134
+ }),
135
+ );
136
+ }
137
+
138
+ /** Changes a record's private labels. The key never changes. */
139
+ export async function updateMarketingRecord(client, siteId, kind, id, record) {
140
+ const { route } = MARKETING_KINDS[kind];
141
+ return normalizeRecord(
142
+ kind,
143
+ await client.request(sitePath(siteId, `${route}/${encodeURIComponent(id)}`), {
144
+ method: "PUT",
145
+ body: writableBody(kind, record),
146
+ maximumResponseBytes: MARKETING_RESPONSE_BYTES,
147
+ }),
148
+ );
149
+ }
150
+
151
+ /** Archives or restores a record. Both are idempotent on the site. */
152
+ export async function setMarketingRecordArchived(client, siteId, kind, id, archived) {
153
+ const { route } = MARKETING_KINDS[kind];
154
+ return normalizeRecord(
155
+ kind,
156
+ await client.request(sitePath(siteId, `${route}/${encodeURIComponent(id)}/${archived ? "archive" : "restore"}`), {
157
+ method: "POST",
158
+ body: {},
159
+ maximumResponseBytes: MARKETING_RESPONSE_BYTES,
160
+ }),
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Carries the site's own words for a refused record. A key outside the grammar, a key an archived record still
166
+ * holds, and a quota all arrive as a violated field with a description that names the remedy ("Archive one to
167
+ * make room"); the default message would say only which field. A refusal that means something else (a missing
168
+ * capability, a paused platform, a rejected credential) keeps its own classification.
169
+ */
170
+ export function withSiteWording(error, kind, key) {
171
+ if (!(error instanceof ApiError) || error.refusalKind() !== REFUSAL_UNCLASSIFIED) return error;
172
+ const [, description] = [...error.violationDescriptions][0] ?? [];
173
+ if (description === undefined) return error;
174
+ const { file } = MARKETING_KINDS[kind];
175
+ return new SiteAuthoringError("marketing.refused", `${file}: '${key}': ${description}`, {
176
+ field: `${file}:${key}`,
177
+ status: error.status,
178
+ });
179
+ }