@taprootio/site-authoring 0.23.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -7
- package/package.json +2 -2
- package/src/analytics-campaign.json +31 -0
- package/src/analytics-link-label.json +22 -0
- package/src/analytics-source-vocabulary.json +17 -0
- package/src/api.js +27 -5
- package/src/capabilities.js +2 -1
- package/src/changelog.json +38 -0
- package/src/cli.js +155 -7
- package/src/constants.js +4 -1
- package/src/credentials.js +55 -14
- package/src/marketing-api.js +179 -0
- package/src/marketing-contract.js +289 -0
- package/src/marketing-workspace.js +77 -0
- package/src/redirects-contract.js +0 -3
- package/src/reference-help.js +109 -3
- package/src/session.js +35 -1
- package/src/site-check-findings.js +0 -1
- package/src/site-check-rules.json +0 -1
- package/src/site-checks.js +0 -6
- package/src/typed-page-reference.js +19 -26
- package/src/typed-pages.js +51 -6
- package/src/utm-link.js +214 -0
- package/src/verbs/approve.js +25 -9
- package/src/verbs/index.js +12 -0
- package/src/verbs/link-build.js +195 -0
- package/src/verbs/login.js +33 -17
- package/src/verbs/logout.js +15 -1
- package/src/verbs/marketing-pull.js +80 -0
- package/src/verbs/marketing-push.js +250 -0
- package/src/verbs/pages-delete.js +6 -3
- package/src/verbs/pages-push.js +41 -3
- package/src/verbs/plan.js +6 -1
- package/src/verbs/pull.js +63 -9
- package/src/verbs/whoami.js +42 -2
- package/src/workspace.js +3 -0
package/src/utm-link.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Generated from ux/client/src/common/utm-link.ts by scripts/sync-field-validation.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* UTM parameters on an address, as string work so every byte the creator typed is kept (TR01273). One module
|
|
4
|
+
* serves the Marketing link builder and the page editor's placement picker, so the two cannot disagree about
|
|
5
|
+
* what a query string means.
|
|
6
|
+
*
|
|
7
|
+
* Parameter names are compared the way the published-site Worker compares them: decoded once and lowercased.
|
|
8
|
+
*/
|
|
9
|
+
/** The parameters the link builder owns; a pasted copy of any is replaced, never duplicated. */
|
|
10
|
+
export const BUILDER_UTM_PARAMETERS = new Set([
|
|
11
|
+
"utm_source",
|
|
12
|
+
"utm_medium",
|
|
13
|
+
"utm_campaign",
|
|
14
|
+
"utm_content",
|
|
15
|
+
]);
|
|
16
|
+
/** A placement rides in `utm_content`. */
|
|
17
|
+
export const PLACEMENT_PARAMETER = "utm_content";
|
|
18
|
+
/**
|
|
19
|
+
* Outside-link destinations the engagement beacon records: lowercase ASCII host names with a letter-led final
|
|
20
|
+
* label. A copy of `ENGAGEMENT_HOST_PATTERN` in `generator/src/edge/analytics-engagement.ts`; a test keeps them equal.
|
|
21
|
+
*/
|
|
22
|
+
export const ENGAGEMENT_HOST_PATTERN = /^(?=.{3,100}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
23
|
+
export function splitAddress(address) {
|
|
24
|
+
const hash = address.indexOf("#");
|
|
25
|
+
const beforeFragment = hash < 0 ? address : address.slice(0, hash);
|
|
26
|
+
const fragment = hash < 0 ? "" : address.slice(hash + 1);
|
|
27
|
+
const mark = beforeFragment.indexOf("?");
|
|
28
|
+
return {
|
|
29
|
+
base: mark < 0 ? beforeFragment : beforeFragment.slice(0, mark),
|
|
30
|
+
query: mark < 0 ? "" : beforeFragment.slice(mark + 1),
|
|
31
|
+
fragment,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function normalizedName(raw) {
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(raw).toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return raw.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A pair's value as the beacon reads it: `+` as a space, decoded once, then trimmed and lowercased. Empty when it
|
|
44
|
+
* cannot decode or holds a control character, which the beacon rejects before it trims (so `%0Abio-link` is not
|
|
45
|
+
* the placement `bio-link`).
|
|
46
|
+
*/
|
|
47
|
+
function normalizedValue(raw) {
|
|
48
|
+
try {
|
|
49
|
+
const decoded = decodeURIComponent(raw.replace(/\+/gu, " "));
|
|
50
|
+
return /[\u0000-\u001f\u007f-\u009f]/u.test(decoded) ? "" : decoded.trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function pairsOf(query) {
|
|
57
|
+
return query.split("&").filter(pair => pair !== "").map(raw => {
|
|
58
|
+
const equals = raw.indexOf("=");
|
|
59
|
+
return {
|
|
60
|
+
raw,
|
|
61
|
+
name: normalizedName(equals < 0 ? raw : raw.slice(0, equals)),
|
|
62
|
+
value: equals < 0 ? "" : normalizedValue(raw.slice(equals + 1)),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Applies a change to an address's query string and returns the address. Pairs the change does not touch are kept
|
|
68
|
+
* byte for byte and in order; empty pairs are dropped, so a bare `?` or `&&` never leaves a stray separator; no
|
|
69
|
+
* remaining pair means no `?`; the fragment goes back last.
|
|
70
|
+
*/
|
|
71
|
+
export function rewriteQuery(address, change) {
|
|
72
|
+
const { base, query, fragment } = splitAddress(address);
|
|
73
|
+
const kept = pairsOf(query)
|
|
74
|
+
.filter(pair => !change.remove?.has(pair.name) && !change.removeWhen?.(pair.name, pair.value))
|
|
75
|
+
.map(pair => pair.raw);
|
|
76
|
+
const added = (change.append ?? []).map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
|
|
77
|
+
const pairs = [...kept, ...added];
|
|
78
|
+
return `${base}${pairs.length ? `?${pairs.join("&")}` : ""}${fragment ? `#${fragment}` : ""}`;
|
|
79
|
+
}
|
|
80
|
+
/** Every `utm_content` value in an address, normalized the way the beacon reads one. */
|
|
81
|
+
export function placementValues(address) {
|
|
82
|
+
return pairsOf(splitAddress(address).query).filter(pair => pair.name === PLACEMENT_PARAMETER).map(pair => pair.value);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Writes a placement into an address. A key replaces every `utm_content` (the beacon drops a click's label unless
|
|
86
|
+
* exactly one exists). No key removes a `utm_content` only when it is one of the site's own placement keys, so a
|
|
87
|
+
* value another tool wrote stays.
|
|
88
|
+
*/
|
|
89
|
+
export function withPlacement(address, key, siteKeys) {
|
|
90
|
+
return key
|
|
91
|
+
? rewriteQuery(address, { remove: new Set([PLACEMENT_PARAMETER]), append: [[PLACEMENT_PARAMETER, key]] })
|
|
92
|
+
: rewriteQuery(address, {
|
|
93
|
+
removeWhen: (name, value) => name === PLACEMENT_PARAMETER && siteKeys.has(value),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the beacon would record a click on this address as an outbound click from the site: an absolute
|
|
98
|
+
* `http(s)` address whose host differs from the site's (exact comparison, as the beacon does, so `www.` counts as
|
|
99
|
+
* different) and fits the host grammar the beacon accepts (dotted, letter-led final label, so IP addresses and
|
|
100
|
+
* single-label hosts never count). A site with no public address yet has no host to differ from.
|
|
101
|
+
*/
|
|
102
|
+
export function isOutsideLink(address, siteHost) {
|
|
103
|
+
let url;
|
|
104
|
+
try {
|
|
105
|
+
url = new URL(address.trim());
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
111
|
+
return false;
|
|
112
|
+
const host = url.hostname.toLowerCase();
|
|
113
|
+
return host !== siteHost.trim().toLowerCase() && ENGAGEMENT_HOST_PATTERN.test(host);
|
|
114
|
+
}
|
|
115
|
+
const SITE_AUTHORITY = /^(?:https?:)?\/\/([^/?#]*)([\s\S]*)$/iu;
|
|
116
|
+
/** Lowercased, a leading `www.` dropped. It does not trim: a space inside a pasted address is not part of a host. */
|
|
117
|
+
function bareHost(host) {
|
|
118
|
+
return host.toLowerCase().replace(/^www\./u, "");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Reads an address that starts with `http(s)://` or `//` and names the site's own host: no userinfo, no port,
|
|
122
|
+
* a leading `www.` ignored on either side. Returns what follows the authority, or undefined for anything else.
|
|
123
|
+
* One rule for the builder's path box and for a link in an email (TR01302), judged on the raw authority so an
|
|
124
|
+
* explicit default port is refused the same way in both.
|
|
125
|
+
*/
|
|
126
|
+
export function ownSiteAuthority(address, siteHost) {
|
|
127
|
+
const match = SITE_AUTHORITY.exec(address.trim());
|
|
128
|
+
if (!match)
|
|
129
|
+
return undefined;
|
|
130
|
+
const host = match[1].toLowerCase();
|
|
131
|
+
if (host === "" || /[@:]/u.test(host) || bareHost(host) !== bareHost(siteHost.trim()))
|
|
132
|
+
return undefined;
|
|
133
|
+
return { rest: match[2] };
|
|
134
|
+
}
|
|
135
|
+
/** Whether an address is an absolute `http(s)` link to the site's own host (an email link the toggle applies to). */
|
|
136
|
+
export function isOwnSiteLink(address, siteHost) {
|
|
137
|
+
return /^https?:\/\//iu.test(address.trim()) && ownSiteAuthority(address, siteHost) !== undefined;
|
|
138
|
+
}
|
|
139
|
+
export const OTHER_SITE_ERROR = "Enter a path on this site.";
|
|
140
|
+
/**
|
|
141
|
+
* Turns what a creator typed into a path on their own site: starting with `/`, with an existing query and
|
|
142
|
+
* fragment kept. A pasted address must be on the site's own host (a leading `www.` on either side is ignored,
|
|
143
|
+
* with no userinfo or port); its host is removed so a pasted address can never point the result elsewhere.
|
|
144
|
+
* Spaces, control characters and non-ASCII characters are percent-encoded; every other byte is kept.
|
|
145
|
+
*/
|
|
146
|
+
export function normalizeSitePath(input, siteHost) {
|
|
147
|
+
let rest = input.trim();
|
|
148
|
+
if (SITE_AUTHORITY.test(rest)) {
|
|
149
|
+
const own = ownSiteAuthority(rest, siteHost);
|
|
150
|
+
if (!own)
|
|
151
|
+
return { ok: false, error: OTHER_SITE_ERROR };
|
|
152
|
+
rest = own.rest;
|
|
153
|
+
}
|
|
154
|
+
else if (/^[a-z][a-z0-9+.-]*:/iu.test(rest)) {
|
|
155
|
+
return { ok: false, error: OTHER_SITE_ERROR };
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// An address pasted without its scheme ("example.com/blog") is the site's own host followed by a path.
|
|
159
|
+
const lead = /^([^/?#]+)([\s\S]*)$/u.exec(rest);
|
|
160
|
+
if (lead && bareHost(lead[1]) === bareHost(siteHost.trim()))
|
|
161
|
+
rest = lead[2];
|
|
162
|
+
}
|
|
163
|
+
const { base, query, fragment } = splitAddress(rest);
|
|
164
|
+
const path = base === "" ? "/" : base.startsWith("/") ? base : `/${base}`;
|
|
165
|
+
// A lone surrogate (a truncated emoji) cannot be percent-encoded and would throw; it becomes U+FFFD first.
|
|
166
|
+
const wellFormed = (value) => value.replace(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/gu, "\ufffd");
|
|
167
|
+
const encode = (value) => wellFormed(value).replace(/[^\x21-\x7e]/gu, character => encodeURIComponent(character));
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
path: encode(`${path}${query ? `?${query}` : ""}${fragment ? `#${fragment}` : ""}`),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** The medium and source the builder appends, then the optional campaign and placement. */
|
|
174
|
+
export function trackedAddress(siteHost, path, parts) {
|
|
175
|
+
const append = [["utm_source", parts.source], ["utm_medium", parts.medium]];
|
|
176
|
+
if (parts.campaign)
|
|
177
|
+
append.push(["utm_campaign", parts.campaign]);
|
|
178
|
+
if (parts.placement)
|
|
179
|
+
append.push([PLACEMENT_PARAMETER, parts.placement]);
|
|
180
|
+
return `https://${siteHost.trim().toLowerCase()}${rewriteQuery(path, { remove: BUILDER_UTM_PARAMETERS, append })}`;
|
|
181
|
+
}
|
|
182
|
+
/** What an address carries of a tracking, read the way the Worker reads it. `on` needs exactly one source and one medium that match. */
|
|
183
|
+
export function readEmailTracking(address, tracking) {
|
|
184
|
+
const pairs = pairsOf(splitAddress(address).query);
|
|
185
|
+
const values = (name) => pairs.filter(pair => pair.name === name).map(pair => pair.value);
|
|
186
|
+
const sources = values("utm_source"), mediums = values("utm_medium");
|
|
187
|
+
return {
|
|
188
|
+
on: sources.length === 1 && sources[0] === tracking.source && mediums.length === 1
|
|
189
|
+
&& mediums[0] === tracking.medium,
|
|
190
|
+
campaigns: values("utm_campaign"),
|
|
191
|
+
placements: values(PLACEMENT_PARAMETER),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Writes an email tracking into an address, replacing any source, medium, campaign or placement already on it. */
|
|
195
|
+
export function withEmailTracking(address, tracking) {
|
|
196
|
+
const append = [["utm_source", tracking.source], ["utm_medium", tracking.medium]];
|
|
197
|
+
if (tracking.campaign)
|
|
198
|
+
append.push(["utm_campaign", tracking.campaign]);
|
|
199
|
+
if (tracking.placement)
|
|
200
|
+
append.push([PLACEMENT_PARAMETER, tracking.placement]);
|
|
201
|
+
return rewriteQuery(address, { remove: BUILDER_UTM_PARAMETERS, append });
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Takes an email tracking back out: only what the switch writes. The source and medium go when they are the
|
|
205
|
+
* Newsletter pair, a campaign or placement goes when it is one of the site's own keys, and any other value stays.
|
|
206
|
+
*/
|
|
207
|
+
export function withoutEmailTracking(address, tracking, siteKeys) {
|
|
208
|
+
return rewriteQuery(address, {
|
|
209
|
+
removeWhen: (name, value) => (name === "utm_source" && value === tracking.source)
|
|
210
|
+
|| (name === "utm_medium" && value === tracking.medium)
|
|
211
|
+
|| (name === "utm_campaign" && siteKeys.campaigns.has(value))
|
|
212
|
+
|| (name === PLACEMENT_PARAMETER && siteKeys.placements.has(value)),
|
|
213
|
+
});
|
|
214
|
+
}
|
package/src/verbs/approve.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { VERB_APPROVE } from "../constants.js";
|
|
9
9
|
import { SiteAuthoringError } from "../errors.js";
|
|
10
10
|
import { boundedByBytes, boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
11
|
+
import { isLegalTemplateType } from "../typed-pages.js";
|
|
11
12
|
import { displayPagePath, normalizePagePath, readManifest, writeManifest } from "../workspace.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -45,7 +46,7 @@ export async function approve(invocation) {
|
|
|
45
46
|
|
|
46
47
|
// Positional arguments narrow the selection to those page paths; `cli.js`
|
|
47
48
|
// hands them over as `pagePaths`, and a programmatic caller can supply the
|
|
48
|
-
// same key directly. With none,
|
|
49
|
+
// same key directly. With none, legal drafts are skipped and the other tracked drafts are staged.
|
|
49
50
|
//
|
|
50
51
|
// A path this CLI cannot use is refused by name rather than dropped. Dropping
|
|
51
52
|
// it silently is the worst available outcome: the remaining selection would
|
|
@@ -80,16 +81,29 @@ export async function approve(invocation) {
|
|
|
80
81
|
);
|
|
81
82
|
}
|
|
82
83
|
const pathOf = (summary) => normalizePagePath(summary.path) ?? summary.path;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
const tracked = pages.filter((summary) => !isGonePage(summary) && manifestByPageId.has(summary.pageId));
|
|
85
|
+
const isLegal = (page) => isLegalTemplateType(page.templateType)
|
|
86
|
+
|| isLegalTemplateType(manifestByPageId.get(page.pageId)?.templateType);
|
|
87
|
+
if (requestedPaths !== undefined && tracked.some((page) => requestedPaths.has(pathOf(page)) && isLegal(page))) {
|
|
88
|
+
throw new SiteAuthoringError("approve.legal_not_allowed", "Legal drafts must be approved in the app. Nothing was approved.", { field: "pages" });
|
|
89
|
+
}
|
|
90
|
+
const selected = tracked.filter((summary) =>
|
|
91
|
+
summary.hasDraft && (requestedPaths === undefined || requestedPaths.has(pathOf(summary))));
|
|
92
|
+
const legalDrafts = requestedPaths === undefined ? selected.filter(isLegal) : [];
|
|
93
|
+
const candidates = selected.filter((page) => !isLegal(page));
|
|
88
94
|
|
|
89
95
|
// A requested page with nothing to approve is reported, not refused, so
|
|
90
96
|
// one already-approved path does not stop the rest of the batch. A path
|
|
91
97
|
// that names no page this workspace tracks is still refused by name.
|
|
92
|
-
const skipped =
|
|
98
|
+
const skipped = legalDrafts.map((page) => ({
|
|
99
|
+
pageId: page.pageId,
|
|
100
|
+
path: displayPagePath(pathOf(page)),
|
|
101
|
+
status: page.status,
|
|
102
|
+
reason: "legal_requires_app",
|
|
103
|
+
}));
|
|
104
|
+
if (legalDrafts.length > 0) {
|
|
105
|
+
onProgress(`Skipping ${legalDrafts.length} legal draft${legalDrafts.length === 1 ? "" : "s"}; approve ${legalDrafts.length === 1 ? "it" : "them"} in the app.`);
|
|
106
|
+
}
|
|
93
107
|
if (requestedPaths !== undefined) {
|
|
94
108
|
const matched = new Set(candidates.map(pathOf));
|
|
95
109
|
const unknown = [];
|
|
@@ -137,7 +151,9 @@ export async function approve(invocation) {
|
|
|
137
151
|
|
|
138
152
|
if (candidates.length === 0) {
|
|
139
153
|
onProgress(
|
|
140
|
-
|
|
154
|
+
legalDrafts.length > 0
|
|
155
|
+
? "No ordinary drafts to approve; approve the skipped legal drafts in the app."
|
|
156
|
+
: skipped.length > 0
|
|
141
157
|
? "Nothing to approve: the requested page(s) have no pending draft."
|
|
142
158
|
: "No page in this workspace is carrying a draft; there is nothing to approve.",
|
|
143
159
|
);
|
|
@@ -145,7 +161,7 @@ export async function approve(invocation) {
|
|
|
145
161
|
approved: { total: 0, items: [] },
|
|
146
162
|
...skippedResult,
|
|
147
163
|
stagedNotDeployed: true,
|
|
148
|
-
nextStep: "deploy --staging",
|
|
164
|
+
nextStep: legalDrafts.length > 0 ? "approve legal drafts in the app" : "deploy --staging",
|
|
149
165
|
});
|
|
150
166
|
}
|
|
151
167
|
|
package/src/verbs/index.js
CHANGED
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
VERB_FORMS_PULL,
|
|
12
12
|
VERB_FORMS_PUSH,
|
|
13
13
|
VERB_FORMS_VALIDATE,
|
|
14
|
+
VERB_LINK_BUILD,
|
|
14
15
|
VERB_LOGIN,
|
|
15
16
|
VERB_LOGOUT,
|
|
17
|
+
VERB_MARKETING_PULL,
|
|
18
|
+
VERB_MARKETING_PUSH,
|
|
16
19
|
VERB_MEDIA_UPLOAD,
|
|
17
20
|
VERB_NAV_PUSH,
|
|
18
21
|
VERB_PAGES_DELETE,
|
|
@@ -49,8 +52,11 @@ import { footerPush } from "./footer-push.js";
|
|
|
49
52
|
import { formsPull } from "./forms-pull.js";
|
|
50
53
|
import { formsPush } from "./forms-push.js";
|
|
51
54
|
import { formsValidate } from "./forms-validate.js";
|
|
55
|
+
import { linkBuild } from "./link-build.js";
|
|
52
56
|
import { login } from "./login.js";
|
|
53
57
|
import { logout } from "./logout.js";
|
|
58
|
+
import { marketingPull } from "./marketing-pull.js";
|
|
59
|
+
import { marketingPush } from "./marketing-push.js";
|
|
54
60
|
import { mediaUpload } from "./media-upload.js";
|
|
55
61
|
import { navPush } from "./nav-push.js";
|
|
56
62
|
import { pagesDelete } from "./pages-delete.js";
|
|
@@ -91,6 +97,9 @@ import { whoami } from "./whoami.js";
|
|
|
91
97
|
* quiet, // --quiet; suppresses progress, never the JSON result
|
|
92
98
|
* keyName, // --name; `login` only, undefined everywhere else
|
|
93
99
|
* planHash, // --plan; `apply` only: the plan it may run
|
|
100
|
+
* archive, // --archive; `marketing push` only: archive a record the file leaves out
|
|
101
|
+
* linkPath, linkSource, linkCampaign, linkPlacement, linkDomain,
|
|
102
|
+
* // --path, --source, --campaign, --placement, --domain; `link build` only
|
|
94
103
|
* onProgress, // human progress sink (stderr, or a no-op when quiet)
|
|
95
104
|
* fetch, // injectable fetch, for tests
|
|
96
105
|
* signal, // optional AbortSignal
|
|
@@ -174,6 +183,9 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
174
183
|
[VERB_FORMS_PULL]: formsPull,
|
|
175
184
|
[VERB_FORMS_PUSH]: formsPush,
|
|
176
185
|
[VERB_FORMS_VALIDATE]: formsValidate,
|
|
186
|
+
[VERB_MARKETING_PULL]: marketingPull,
|
|
187
|
+
[VERB_MARKETING_PUSH]: marketingPush,
|
|
188
|
+
[VERB_LINK_BUILD]: linkBuild,
|
|
177
189
|
[VERB_MEDIA_UPLOAD]: mediaUpload,
|
|
178
190
|
[VERB_APPROVE]: approve,
|
|
179
191
|
[VERB_DEPLOY]: deploy,
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { listAuthorableSites, withRefusalGuidance } from "../api.js";
|
|
2
|
+
import { CLI_BINARY_NAME, VERB_LINK_BUILD, VERB_LOGIN } from "../constants.js";
|
|
3
|
+
import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
|
|
4
|
+
import { listMarketingRecords } from "../marketing-api.js";
|
|
5
|
+
import {
|
|
6
|
+
isMarketingKey,
|
|
7
|
+
LINK_SOURCES,
|
|
8
|
+
MARKETING_KINDS,
|
|
9
|
+
MARKETING_STATES,
|
|
10
|
+
MARKETING_STATE_ARCHIVED,
|
|
11
|
+
MARKETING_KEY_RULE,
|
|
12
|
+
notLiveNote,
|
|
13
|
+
} from "../marketing-contract.js";
|
|
14
|
+
import { readWorkspaceMarketing, recordedMarketing } from "../marketing-workspace.js";
|
|
15
|
+
import {
|
|
16
|
+
openAccountSession,
|
|
17
|
+
openAnonymousSession,
|
|
18
|
+
openSession,
|
|
19
|
+
requireRecordedSurface,
|
|
20
|
+
successResult,
|
|
21
|
+
} from "../session.js";
|
|
22
|
+
import { readManifest } from "../workspace.js";
|
|
23
|
+
import { ENGAGEMENT_HOST_PATTERN, normalizeSitePath, trackedAddress } from "../utm-link.js";
|
|
24
|
+
|
|
25
|
+
function invalidOption(code, message, field) {
|
|
26
|
+
return new SiteAuthoringError(code, message, { field, exitCode: 2 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The site's public host: the one given, or the signed-in account's record of the configured site. */
|
|
30
|
+
async function resolveHost(invocation, config) {
|
|
31
|
+
if (invocation.linkDomain !== undefined) {
|
|
32
|
+
const host = invocation.linkDomain.trim().toLowerCase();
|
|
33
|
+
if (!ENGAGEMENT_HOST_PATTERN.test(host)) {
|
|
34
|
+
throw invalidOption("link.domain_invalid", "--domain is the site's public host name, such as example.com, without a scheme or path.", "domain");
|
|
35
|
+
}
|
|
36
|
+
return host;
|
|
37
|
+
}
|
|
38
|
+
let sites;
|
|
39
|
+
try {
|
|
40
|
+
const account = await openAccountSession(invocation);
|
|
41
|
+
sites = await withRefusalGuidance(account.onProgress, "site list", async () => await listAuthorableSites(account.client));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof SiteAuthoringError && error.code === "auth.key_missing") {
|
|
44
|
+
throw new SiteAuthoringError(
|
|
45
|
+
"link.domain_unknown",
|
|
46
|
+
`A link needs the site's public host, which comes from the signed-in site. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}', `
|
|
47
|
+
+ "or pass --domain <host>: a site credential cannot read hosting configuration.",
|
|
48
|
+
{ field: "domain" },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
const host = sites.find((site) => site.siteId === config.siteId)?.primaryDomain ?? "";
|
|
54
|
+
if (host === "") {
|
|
55
|
+
throw new SiteAuthoringError(
|
|
56
|
+
"link.no_public_address",
|
|
57
|
+
"This site does not have a public address yet, so there is nothing to build a link to. "
|
|
58
|
+
+ "Set it in Settings, Licence & domain, or pass --domain <host>.",
|
|
59
|
+
{ field: "domain" },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return host.toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The campaign or placement a link names. The state comes from the baseline the last pull or push recorded in the
|
|
67
|
+
* manifest when the workspace holds this kind's file and a known, unarchived state was recorded for the key; in every
|
|
68
|
+
* other case the live site is asked. (A file's own `state` is only what the author left there, so it is never used.)
|
|
69
|
+
* A key the site does not hold is not counted, so it is refused rather than printed; an archived key is no longer
|
|
70
|
+
* admitted, so it is refused the same way.
|
|
71
|
+
*/
|
|
72
|
+
async function findRecord(kind, key, files, invocation, config) {
|
|
73
|
+
const { noun, file } = MARKETING_KINDS[kind];
|
|
74
|
+
const fromFile = files.get(kind);
|
|
75
|
+
if (fromFile !== undefined) {
|
|
76
|
+
const record = fromFile.find((candidate) => candidate.key === key);
|
|
77
|
+
if (record === undefined) {
|
|
78
|
+
throw new SiteAuthoringError(
|
|
79
|
+
`link.${noun}_unknown`,
|
|
80
|
+
`${file} has no ${noun} '${key}'. Run 'marketing pull' if it was made since, or add it to the file and `
|
|
81
|
+
+ "run 'marketing push': a key the site does not hold is not counted.",
|
|
82
|
+
{ field: noun },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
// The state a pull or push recorded for this key is the one to report. A key with no baseline entry (made or
|
|
86
|
+
// restored in the app and then added to the file), an archived entry, or an entry with no known state is not
|
|
87
|
+
// known to be live, so the site is asked, and it refuses a key it does not hold.
|
|
88
|
+
const manifest = await readManifest(config.workspaceDir, config.siteId, { required: false });
|
|
89
|
+
const recorded = manifest === undefined ? undefined : recordedMarketing(manifest, kind).get(key);
|
|
90
|
+
if (
|
|
91
|
+
recorded !== undefined && !recorded.archived && MARKETING_STATES.includes(recorded.state)
|
|
92
|
+
&& recorded.state !== MARKETING_STATE_ARCHIVED
|
|
93
|
+
) {
|
|
94
|
+
return { name: record.name, state: recorded.state, fromFile: file };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const session = await openSession(invocation);
|
|
98
|
+
const listing = await withRefusalGuidance(
|
|
99
|
+
session.onProgress,
|
|
100
|
+
"link build",
|
|
101
|
+
async () => await listMarketingRecords(session.client, session.siteId, kind),
|
|
102
|
+
);
|
|
103
|
+
const record = listing.records.find((candidate) => candidate.key === key);
|
|
104
|
+
if (record === undefined || record.state === MARKETING_STATE_ARCHIVED) {
|
|
105
|
+
throw new SiteAuthoringError(
|
|
106
|
+
`link.${noun}_unknown`,
|
|
107
|
+
`The site has no ${record === undefined ? "" : "unarchived "}${noun} '${key}'. A key the site does not hold is `
|
|
108
|
+
+ "not counted. Create it with 'marketing push' (or restore it by naming it in the file).",
|
|
109
|
+
{ field: noun },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return { name: record.name, state: record.state };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `link build` — print one tracked address and save nothing.
|
|
117
|
+
*
|
|
118
|
+
* It runs the Marketing link builder's own rules (compiled into `utm-link.js`) and its source vocabulary, so an
|
|
119
|
+
* address built here counts exactly as one built in the app. A campaign or placement that is not live yet is
|
|
120
|
+
* allowed, with the builder's note that page views count under the source only until it is.
|
|
121
|
+
*/
|
|
122
|
+
export async function linkBuild(invocation) {
|
|
123
|
+
const { linkPath, linkSource, linkCampaign, linkPlacement } = invocation;
|
|
124
|
+
if (linkPath === undefined) {
|
|
125
|
+
throw invalidOption("link.path_missing", "link build needs --path, the page on this site; use / for the home page.", "path");
|
|
126
|
+
}
|
|
127
|
+
if (linkSource === undefined) {
|
|
128
|
+
throw invalidOption("link.source_missing", `link build needs --source, one of: ${LINK_SOURCES.map((source) => source.key).join(", ")}.`, "source");
|
|
129
|
+
}
|
|
130
|
+
const source = LINK_SOURCES.find((candidate) => candidate.key === linkSource);
|
|
131
|
+
if (source === undefined) {
|
|
132
|
+
throw new SiteAuthoringError(
|
|
133
|
+
"link.source_unknown",
|
|
134
|
+
linkSource === "reader"
|
|
135
|
+
? "The reader source is set by a published page's own share action and cannot be built here."
|
|
136
|
+
: `'${linkSource}' is not a source. Use one of: ${LINK_SOURCES.map((candidate) => candidate.key).join(", ")}.`,
|
|
137
|
+
{ field: "source", exitCode: 2, alternatives: LINK_SOURCES.map((candidate) => candidate.key) },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
for (const [field, value] of [["campaign", linkCampaign], ["placement", linkPlacement]]) {
|
|
141
|
+
if (value !== undefined && !isMarketingKey(value)) {
|
|
142
|
+
throw invalidOption("link.key_invalid", `--${field} is a key of ${MARKETING_KEY_RULE}.`, field);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { config, onProgress } = await openAnonymousSession({ ...invocation, client: null });
|
|
147
|
+
if (config?.siteId === undefined) {
|
|
148
|
+
throw new SiteAuthoringError(
|
|
149
|
+
"config.site_missing",
|
|
150
|
+
`No site is selected. Run '${CLI_BINARY_NAME} use <site>' to choose one, or set siteId in taproot-site.json.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// Offline, like every other verb: a site that cannot take it is told so before any request.
|
|
154
|
+
requireRecordedSurface(invocation, config);
|
|
155
|
+
const host = await resolveHost(invocation, config);
|
|
156
|
+
const normalized = normalizeSitePath(linkPath, host);
|
|
157
|
+
if (!normalized.ok) throw invalidOption("link.path_invalid", normalized.error, "path");
|
|
158
|
+
|
|
159
|
+
// Only the files of the kinds named are read, so a half-edited file never stops a link that does not use it.
|
|
160
|
+
const files = await readWorkspaceMarketing(
|
|
161
|
+
config.workspaceDir,
|
|
162
|
+
[["campaigns", linkCampaign], ["placements", linkPlacement]].filter(([, key]) => key !== undefined).map(([kind]) => kind),
|
|
163
|
+
);
|
|
164
|
+
const notes = [];
|
|
165
|
+
const named = {};
|
|
166
|
+
for (const [kind, key] of [["campaigns", linkCampaign], ["placements", linkPlacement]]) {
|
|
167
|
+
if (key === undefined) continue;
|
|
168
|
+
const record = await findRecord(kind, key, files, invocation, config);
|
|
169
|
+
named[kind] = key;
|
|
170
|
+
const note = notLiveNote(sanitizeDiagnostic(record.name, key), record.state);
|
|
171
|
+
// A file's state is the one written at the last pull or push, so say so rather than present it as current.
|
|
172
|
+
if (note !== "") notes.push(record.fromFile === undefined ? note : `${note} (As of the last marketing pull or push; ${record.fromFile}.)`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const address = trackedAddress(host, normalized.path, {
|
|
176
|
+
source: source.key,
|
|
177
|
+
medium: source.medium,
|
|
178
|
+
campaign: named.campaigns,
|
|
179
|
+
placement: named.placements,
|
|
180
|
+
});
|
|
181
|
+
onProgress(address);
|
|
182
|
+
for (const note of notes) onProgress(note);
|
|
183
|
+
return successResult(VERB_LINK_BUILD, config.siteId, {
|
|
184
|
+
link: {
|
|
185
|
+
address,
|
|
186
|
+
host,
|
|
187
|
+
path: normalized.path,
|
|
188
|
+
source: source.key,
|
|
189
|
+
medium: source.medium,
|
|
190
|
+
...(named.campaigns === undefined ? {} : { campaign: named.campaigns }),
|
|
191
|
+
...(named.placements === undefined ? {} : { placement: named.placements }),
|
|
192
|
+
},
|
|
193
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
194
|
+
});
|
|
195
|
+
}
|