@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.
@@ -0,0 +1,289 @@
1
+ import campaignRules from "./analytics-campaign.json" with { type: "json" };
2
+ import vocabulary from "./analytics-source-vocabulary.json" with { type: "json" };
3
+ import linkLabelRules from "./analytics-link-label.json" with { type: "json" };
4
+ import { SiteAuthoringError } from "./errors.js";
5
+
6
+ /**
7
+ * The workspace contract for a site's campaigns and placements (TR01301).
8
+ *
9
+ * Each kind is one JSON file under `marketing/`: a closed object holding a list of records. A record
10
+ * is what an author decides (key, name, and for a campaign a note and two label dates) plus two
11
+ * read-only facts the site reports (`state`, `counted_from`), which a pull writes and a push ignores.
12
+ * The key grammar and the name and note limits come from the shared rule files that the API, the
13
+ * Worker and the app read too; their copies here are written by `scripts/sync-field-validation.mjs`.
14
+ * Records the site has archived are not written: archiving keeps history, so a key is never reused,
15
+ * and a file that names an archived key asks to restore it.
16
+ */
17
+
18
+ export const MARKETING_DIRECTORY = "marketing";
19
+
20
+ export const MARKETING_STATE_LIVE = "live";
21
+ export const MARKETING_STATE_ARCHIVED = "archived";
22
+ /** The words a creator reads for each state, in the app's order. */
23
+ export const MARKETING_STATES = Object.freeze([
24
+ MARKETING_STATE_LIVE,
25
+ "publishing",
26
+ "goes_live_with_first_publish",
27
+ "not_in_current_release",
28
+ MARKETING_STATE_ARCHIVED,
29
+ ]);
30
+
31
+ const MARKETING_STATE_WIRE = Object.freeze({
32
+ campaigns: Object.freeze({
33
+ SITE_CAMPAIGN_STATE_LIVE: MARKETING_STATE_LIVE,
34
+ SITE_CAMPAIGN_STATE_PUBLISHING: "publishing",
35
+ SITE_CAMPAIGN_STATE_GOES_LIVE_WITH_FIRST_PUBLISH: "goes_live_with_first_publish",
36
+ SITE_CAMPAIGN_STATE_NOT_IN_CURRENT_RELEASE: "not_in_current_release",
37
+ SITE_CAMPAIGN_STATE_ARCHIVED: MARKETING_STATE_ARCHIVED,
38
+ }),
39
+ placements: Object.freeze({
40
+ SITE_LINK_LABEL_STATE_LIVE: MARKETING_STATE_LIVE,
41
+ SITE_LINK_LABEL_STATE_PUBLISHING: "publishing",
42
+ SITE_LINK_LABEL_STATE_GOES_LIVE_WITH_FIRST_PUBLISH: "goes_live_with_first_publish",
43
+ SITE_LINK_LABEL_STATE_NOT_IN_CURRENT_RELEASE: "not_in_current_release",
44
+ SITE_LINK_LABEL_STATE_ARCHIVED: MARKETING_STATE_ARCHIVED,
45
+ }),
46
+ });
47
+
48
+ /**
49
+ * The two kinds. A placement is stored as a link label, so its routes and wire names say so; every word
50
+ * a person reads says placement.
51
+ */
52
+ export const MARKETING_KINDS = Object.freeze({
53
+ campaigns: Object.freeze({
54
+ kind: "campaigns",
55
+ noun: "campaign",
56
+ file: `${MARKETING_DIRECTORY}/campaigns.json`,
57
+ listProperty: "campaigns",
58
+ route: "campaigns",
59
+ wireList: "campaigns",
60
+ wireId: "campaignId",
61
+ rules: campaignRules,
62
+ withLabels: true,
63
+ }),
64
+ placements: Object.freeze({
65
+ kind: "placements",
66
+ noun: "placement",
67
+ file: `${MARKETING_DIRECTORY}/placements.json`,
68
+ listProperty: "placements",
69
+ route: "link-labels",
70
+ wireList: "linkLabels",
71
+ wireId: "linkLabelId",
72
+ rules: linkLabelRules,
73
+ withLabels: false,
74
+ }),
75
+ });
76
+
77
+ export const MARKETING_KIND_NAMES = Object.freeze(Object.keys(MARKETING_KINDS));
78
+
79
+ /**
80
+ * The sources a tracked link can name: the shared vocabulary without `reader`, which the published page's own
81
+ * share action sets. The medium is not chosen; the source decides it.
82
+ */
83
+ export const LINK_SOURCES = Object.freeze(vocabulary.sources.filter((source) => source.key !== "reader"));
84
+
85
+ /** The longest name either kind accepts; the shared rule files give each kind's own. */
86
+ function nameMaximum(kind) {
87
+ return MARKETING_KINDS[kind].rules.nameMaxLength;
88
+ }
89
+
90
+ // A placement shares the campaign key grammar (shared/analytics-link-label.json delegates to it).
91
+ const KEY_RULES = campaignRules;
92
+ const KEY_PATTERN = new RegExp(KEY_RULES.keyPattern, "u");
93
+ const LONG_DIGIT_RUN = new RegExp(`[0-9]{${KEY_RULES.maxDigitRun + 1},}`, "u");
94
+ const NOTE_MAXIMUM = campaignRules.noteMaxLength;
95
+ const FILE_BYTES = 512 * 1024;
96
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/u;
97
+
98
+ export const MARKETING_LIMITS = Object.freeze({ fileBytes: FILE_BYTES });
99
+
100
+ export const MARKETING_KEY_RULE = `${KEY_RULES.keyMinLength} to ${KEY_RULES.keyMaxLength} lowercase letters, `
101
+ + `numbers or hyphens, with no more than ${KEY_RULES.maxDigitRun} numbers in a row`;
102
+
103
+ /** True for a public key: the one grammar campaigns and placements share. */
104
+ export function isMarketingKey(value) {
105
+ return typeof value === "string" && KEY_PATTERN.test(value) && !LONG_DIGIT_RUN.test(value);
106
+ }
107
+
108
+ /** The most unarchived records a site can hold of this kind, from the shared rule file. */
109
+ export function maximumActive(kind) {
110
+ return MARKETING_KINDS[kind].rules.maxActivePerSite;
111
+ }
112
+
113
+ function maximumTotal(kind) {
114
+ return MARKETING_KINDS[kind].rules.maxTotalPerSite;
115
+ }
116
+
117
+ function invalid(file, message, field) {
118
+ return new SiteAuthoringError("marketing.file_invalid", `${file}: ${message}`, { field: `${file}:${field}` });
119
+ }
120
+
121
+ function isPlainObject(value) {
122
+ return value !== null && typeof value === "object" && !Array.isArray(value);
123
+ }
124
+
125
+ /** A real calendar day written YYYY-MM-DD. */
126
+ export function isCalendarDate(value) {
127
+ if (typeof value !== "string") return false;
128
+ const match = DATE.exec(value);
129
+ if (match === null) return false;
130
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
131
+ // Year 0 is not a protobuf timestamp (0001 is the first), so the site could never hold it.
132
+ if (year < 1) return false;
133
+ // setUTCFullYear, not Date.UTC, which maps years 0 to 99 to 1900 to 1999 and would refuse a typo year the site keeps.
134
+ const parsed = new Date(0);
135
+ parsed.setUTCFullYear(year, month - 1, day);
136
+ return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day;
137
+ }
138
+
139
+ // The site trims with .NET's whitespace set, which differs from JavaScript's (U+FEFF is not trimmed there, U+0085 is),
140
+ // so a name the site stored with an edge character must pass here exactly when the site would keep it.
141
+ const DOTNET_WHITESPACE = "\\t-\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
142
+ const DOTNET_EDGE_WHITESPACE = new RegExp(`^[${DOTNET_WHITESPACE}]|[${DOTNET_WHITESPACE}]$`, "u");
143
+
144
+ /** NUL and lone surrogates cannot be stored by the site. */
145
+ function hasUnstorableCharacters(text) {
146
+ return /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text);
147
+ }
148
+
149
+ function checkText(file, path, value, maximum, { allowEmpty = false } = {}) {
150
+ if (typeof value !== "string") throw invalid(file, "must be a string.", path);
151
+ if (DOTNET_EDGE_WHITESPACE.test(value)) throw invalid(file, "must have no space at either end.", path);
152
+ if (!allowEmpty && value.length === 0) throw invalid(file, "must not be empty.", path);
153
+ if (value.length > maximum) throw invalid(file, `must be at most ${maximum} characters.`, path);
154
+ // Only what the site cannot store is refused (NUL and lone surrogates): the site accepts any other text in a name
155
+ // or note, and a pull writes it back unchanged, so refusing more would fail an untouched pulled workspace.
156
+ if (hasUnstorableCharacters(value)) {
157
+ throw invalid(file, "must not contain NUL or an unpaired surrogate.", path);
158
+ }
159
+ }
160
+
161
+ const RECORD_KEYS = Object.freeze({
162
+ campaigns: ["key", "name", "note", "starts_on", "ends_on", "state", "counted_from"],
163
+ placements: ["key", "name", "state", "counted_from"],
164
+ });
165
+ const READ_ONLY_KEYS = new Set(["state", "counted_from"]);
166
+
167
+ /**
168
+ * Validates one parsed marketing file and returns its records as the author decides them: key, name,
169
+ * and for a campaign note, starts_on and ends_on (absent when unset). The read-only state and counted_from
170
+ * are checked for shape and kept on the record, so `link build` can read the state a pull wrote; a push ignores them.
171
+ * `archived` as a state is refused: archiving is a push that omits the
172
+ * key under --archive, never a word in the file.
173
+ */
174
+ export function validateMarketingDocument(kind, document_) {
175
+ const { file, listProperty } = MARKETING_KINDS[kind];
176
+ if (!isPlainObject(document_)) throw invalid(file, "must be a JSON object.", "$");
177
+ for (const property of Object.keys(document_)) {
178
+ if (property !== listProperty) {
179
+ throw invalid(file, `'${property}' is not a property of this file; it holds only '${listProperty}'.`, property);
180
+ }
181
+ }
182
+ const list = document_[listProperty];
183
+ if (!Array.isArray(list)) throw invalid(file, `'${listProperty}' must be an array.`, listProperty);
184
+ if (list.length > maximumTotal(kind)) {
185
+ throw invalid(file, `lists more than the ${maximumTotal(kind)} records a site can hold.`, listProperty);
186
+ }
187
+ const seen = new Set();
188
+ return list.map((record, index) => {
189
+ const at = `${listProperty}[${index}]`;
190
+ if (!isPlainObject(record)) throw invalid(file, "must be an object.", at);
191
+ for (const property of Object.keys(record)) {
192
+ if (!RECORD_KEYS[kind].includes(property)) {
193
+ throw invalid(file, `'${property}' is not a ${MARKETING_KINDS[kind].noun} property.`, `${at}.${property}`);
194
+ }
195
+ }
196
+ if (!isMarketingKey(record.key)) {
197
+ throw invalid(file, `key must be ${MARKETING_KEY_RULE}.`, `${at}.key`);
198
+ }
199
+ if (seen.has(record.key)) throw invalid(file, `names the key '${record.key}' more than once.`, `${at}.key`);
200
+ seen.add(record.key);
201
+ checkText(file, `${at}.name`, record.name, nameMaximum(kind));
202
+ const result = { key: record.key, name: record.name };
203
+ if (MARKETING_KINDS[kind].withLabels) {
204
+ if (record.note !== undefined) {
205
+ checkText(file, `${at}.note`, record.note, NOTE_MAXIMUM, { allowEmpty: true });
206
+ if (record.note !== "") result.note = record.note;
207
+ }
208
+ for (const field of ["starts_on", "ends_on"]) {
209
+ if (record[field] === undefined) continue;
210
+ if (!isCalendarDate(record[field])) throw invalid(file, "must be a day written YYYY-MM-DD.", `${at}.${field}`);
211
+ result[field] = record[field];
212
+ }
213
+ if (result.starts_on !== undefined && result.ends_on !== undefined && result.ends_on < result.starts_on) {
214
+ throw invalid(file, "ends_on cannot be before starts_on.", `${at}.ends_on`);
215
+ }
216
+ }
217
+ for (const field of READ_ONLY_KEYS) {
218
+ if (record[field] === undefined) continue;
219
+ // Kept on the validated record so `link build` can read the state a pull recorded; a push ignores it.
220
+ result[field] = record[field];
221
+ if (field === "state" && !MARKETING_STATES.includes(record.state)) {
222
+ throw invalid(file, `state is read-only and one of ${MARKETING_STATES.join(", ")}.`, `${at}.state`);
223
+ }
224
+ if (field === "state" && record.state === MARKETING_STATE_ARCHIVED) {
225
+ throw invalid(
226
+ file,
227
+ "a record cannot be archived by writing its state. Remove it from the file and push with --archive.",
228
+ `${at}.state`,
229
+ );
230
+ }
231
+ if (field === "counted_from" && !isCalendarDate(record.counted_from)) {
232
+ throw invalid(file, "counted_from is read-only and a day written YYYY-MM-DD.", `${at}.counted_from`);
233
+ }
234
+ }
235
+ return result;
236
+ });
237
+ }
238
+
239
+ /**
240
+ * The record as a file writes it, in one canonical key order, so the same record is always the same
241
+ * bytes: what the author decides, then what the site reports.
242
+ */
243
+ export function projectMarketingRecord(kind, record) {
244
+ const projected = { key: record.key, name: record.name };
245
+ if (MARKETING_KINDS[kind].withLabels) {
246
+ if (record.note !== undefined && record.note !== "") projected.note = record.note;
247
+ if (record.starts_on !== undefined) projected.starts_on = record.starts_on;
248
+ if (record.ends_on !== undefined) projected.ends_on = record.ends_on;
249
+ }
250
+ if (record.state !== undefined) projected.state = record.state;
251
+ if (record.counted_from !== undefined) projected.counted_from = record.counted_from;
252
+ return projected;
253
+ }
254
+
255
+ /** The file's whole document for a list of records, ordered by key. */
256
+ export function projectMarketingDocument(kind, records) {
257
+ return {
258
+ [MARKETING_KINDS[kind].listProperty]: [...records]
259
+ .sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0))
260
+ .map((record) => projectMarketingRecord(kind, record)),
261
+ };
262
+ }
263
+
264
+ /** The state word a creator reads for a wire state of this kind, or undefined for one this CLI does not know. */
265
+ export function marketingStateFromWire(kind, value) {
266
+ if (value === undefined || value === "") return undefined;
267
+ return MARKETING_STATE_WIRE[kind][value];
268
+ }
269
+
270
+ /** What a creator loses until a picked campaign or placement is live, in the link builder. */
271
+ export const BUILDER_NOT_LIVE_CONSEQUENCE = "Until it is live, page views count under the source only.";
272
+
273
+ /**
274
+ * One sentence for a campaign or placement that is not live yet, or empty when it is live. The wording
275
+ * follows the record's state, because "publishing" is only true while its release runs. The words are the
276
+ * Marketing link builder's; a test in the app keeps them equal.
277
+ */
278
+ export function notLiveNote(name, state) {
279
+ switch (state) {
280
+ case "publishing":
281
+ return `${name} is publishing. ${BUILDER_NOT_LIVE_CONSEQUENCE}`;
282
+ case "goes_live_with_first_publish":
283
+ return `${name} goes live with your first publish. ${BUILDER_NOT_LIVE_CONSEQUENCE}`;
284
+ case "not_in_current_release":
285
+ return `${name} is not in the current release; it goes live with your next publish. ${BUILDER_NOT_LIVE_CONSEQUENCE}`;
286
+ default:
287
+ return "";
288
+ }
289
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ MARKETING_KIND_NAMES,
3
+ MARKETING_KINDS,
4
+ MARKETING_LIMITS,
5
+ validateMarketingDocument,
6
+ } from "./marketing-contract.js";
7
+ import { readWorkspaceJson, workspaceFileExists } from "./workspace.js";
8
+
9
+ /**
10
+ * Reads the workspace's marketing files and keeps their baseline in the pull manifest (TR01301).
11
+ *
12
+ * A kind with no file is simply not read: a workspace that never held a placement is not telling the site to
13
+ * archive them all. Within a file, a record left out is a request to archive it, and only `marketing push
14
+ * --archive` honors that request.
15
+ */
16
+ export const MARKETING_MANIFEST_KEY = "marketing";
17
+
18
+ /** The records of every kind (or of the named kinds) whose file the workspace holds, validated. */
19
+ export async function readWorkspaceMarketing(workspaceDir, kinds = MARKETING_KIND_NAMES) {
20
+ const found = new Map();
21
+ for (const kind of kinds) {
22
+ const { file } = MARKETING_KINDS[kind];
23
+ if (!await workspaceFileExists(workspaceDir, file)) continue;
24
+ const document_ = await readWorkspaceJson(workspaceDir, file, MARKETING_LIMITS.fileBytes);
25
+ found.set(kind, validateMarketingDocument(kind, document_));
26
+ }
27
+ return found;
28
+ }
29
+
30
+ function isObject(value) {
31
+ return value !== null && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ /**
35
+ * What a baseline remembers of a record: its id, whether it was archived, the labels an author decides, and the state
36
+ * the site reported when it was written. The state is the one `link build` trusts for its note; a file's own `state`
37
+ * is whatever the author left there, which is wrong for a key that was renamed from a live one.
38
+ */
39
+ export function baselineEntry(kind, record) {
40
+ const entry = { id: record.id, archived: record.archived === true, name: record.name };
41
+ if (record.state !== undefined) entry.state = record.state;
42
+ if (MARKETING_KINDS[kind].withLabels) {
43
+ if (record.note !== undefined && record.note !== "") entry.note = record.note;
44
+ if (record.starts_on !== undefined) entry.starts_on = record.starts_on;
45
+ if (record.ends_on !== undefined) entry.ends_on = record.ends_on;
46
+ }
47
+ return entry;
48
+ }
49
+
50
+ /** The recorded baseline of one kind, by key. Empty when this workspace never pulled that kind. */
51
+ export function recordedMarketing(manifest, kind) {
52
+ const items = manifest[MARKETING_MANIFEST_KEY]?.[kind]?.items;
53
+ const recorded = new Map();
54
+ if (!isObject(items)) return recorded;
55
+ for (const [key, entry] of Object.entries(items)) {
56
+ if (isObject(entry) && typeof entry.id === "string" && typeof entry.archived === "boolean") {
57
+ recorded.set(key, entry);
58
+ }
59
+ }
60
+ return recorded;
61
+ }
62
+
63
+ /** Whether a remembered baseline and a record from the site agree on the labels an author decides. */
64
+ export function sameLabels(kind, left, right) {
65
+ if (left.name !== right.name) return false;
66
+ if (!MARKETING_KINDS[kind].withLabels) return true;
67
+ return (left.note ?? "") === (right.note ?? "")
68
+ && left.starts_on === right.starts_on
69
+ && left.ends_on === right.ends_on;
70
+ }
71
+
72
+ /** The manifest with one kind's baseline replaced by `entries` (key to baseline entry), in key order. */
73
+ export function withRecordedMarketing(manifest, kind, entries) {
74
+ const current = isObject(manifest[MARKETING_MANIFEST_KEY]) ? manifest[MARKETING_MANIFEST_KEY] : {};
75
+ const items = Object.fromEntries([...entries].sort(([left], [right]) => (left < right ? -1 : 1)));
76
+ return { ...manifest, [MARKETING_MANIFEST_KEY]: { ...current, [kind]: { items } } };
77
+ }
@@ -12,7 +12,6 @@ import {
12
12
  RULE_REDIRECT_SOURCE_INVALID,
13
13
  RULE_REDIRECT_SOURCE_NOT_STORABLE,
14
14
  RULE_REDIRECT_SOURCE_RUNTIME_MIRROR,
15
- RULE_REDIRECT_SOURCE_SHARE_LINK,
16
15
  } from "./site-checks.js";
17
16
 
18
17
  export { normalizeRedirectPath };
@@ -110,8 +109,6 @@ const SOURCE_RULE_REASONS = Object.freeze({
110
109
  + "a redirect source, and the page or home file there is generated.",
111
110
  [RULE_REDIRECT_SOURCE_INTEGRATION_DATA]: (first) =>
112
111
  `'/${first}' is reserved for integration data, so nothing under it can be a redirect source.`,
113
- [RULE_REDIRECT_SOURCE_SHARE_LINK]: () =>
114
- "'/s/' is reserved for share links, so nothing under it can be a redirect source.",
115
112
  [RULE_REDIRECT_SOURCE_RUNTIME_MIRROR]: () => RUNTIME_MIRROR_PATH_REASON,
116
113
  [RULE_REDIRECT_SOURCE_GENERATED_FILE]: (first) =>
117
114
  `'/${first}' is a file the site generates, so neither it nor anything under it can be a redirect source.`,
@@ -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 = 42;
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({
@@ -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.",
package/src/session.js CHANGED
@@ -86,7 +86,7 @@ function keyMissingError() {
86
86
  * The exchange's own answer is checked again afterwards, because the
87
87
  * recording can be stale.
88
88
  */
89
- function requireRecordedSurface(invocation, config) {
89
+ export function requireRecordedSurface(invocation, config) {
90
90
  if (invocation.surface === undefined || config.authoringSurface === undefined) return;
91
91
  if (!surfaceAllows(config.authoringSurface, invocation.surface)) {
92
92
  throw surfaceRefusal(invocation.verb ?? "This verb", config.authoringSurface, { source: "config" });
@@ -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()))