@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.
@@ -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
+ }
@@ -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.`,