@taprootio/site-authoring 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -2
- 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/capabilities.js +1 -1
- package/src/changelog.json +24 -0
- package/src/cli.js +115 -1
- package/src/constants.js +3 -0
- 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 +107 -1
- package/src/session.js +1 -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/utm-link.js +214 -0
- package/src/verbs/index.js +12 -0
- package/src/verbs/link-build.js +195 -0
- package/src/verbs/marketing-pull.js +80 -0
- package/src/verbs/marketing-push.js +250 -0
- package/src/verbs/pull.js +8 -3
- package/src/verbs/whoami.js +32 -1
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/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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { withRefusalGuidance } from "../api.js";
|
|
2
|
+
import { VERB_MARKETING_PULL } from "../constants.js";
|
|
3
|
+
import { listMarketingRecords } from "../marketing-api.js";
|
|
4
|
+
import {
|
|
5
|
+
MARKETING_KIND_NAMES,
|
|
6
|
+
MARKETING_KINDS,
|
|
7
|
+
projectMarketingDocument,
|
|
8
|
+
} from "../marketing-contract.js";
|
|
9
|
+
import { baselineEntry, withRecordedMarketing } from "../marketing-workspace.js";
|
|
10
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
11
|
+
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
12
|
+
|
|
13
|
+
const MAXIMUM_REPORTED = 200;
|
|
14
|
+
|
|
15
|
+
/** The archived keys, bounded like the live ones so a site with thousands of them still fits the result. */
|
|
16
|
+
function archivedSummary(keys) {
|
|
17
|
+
const reported = boundedList(keys, MAXIMUM_REPORTED);
|
|
18
|
+
return {
|
|
19
|
+
archived: reported.items,
|
|
20
|
+
archivedTotal: keys.length,
|
|
21
|
+
...(reported.truncated ? { archivedTruncated: true } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `marketing pull` — write the site's live campaigns and placements and record what was read.
|
|
27
|
+
*
|
|
28
|
+
* It replaces each file with the site's state, so a local edit that was never pushed is replaced; that is what
|
|
29
|
+
* pull means everywhere else in this CLI. Archived records are listed and not written: a record the site has
|
|
30
|
+
* archived keeps its key for ever, and a file that names the key asks for it to be restored. The baseline it
|
|
31
|
+
* records is what `marketing push` fences against.
|
|
32
|
+
*/
|
|
33
|
+
export async function marketingPull(invocation) {
|
|
34
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
35
|
+
// The manifest is the pulled workspace this baseline belongs to, the same precondition every push has.
|
|
36
|
+
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
37
|
+
|
|
38
|
+
// Both kinds are read before either file is written, so a refusal on the second never leaves a first file
|
|
39
|
+
// without its baseline.
|
|
40
|
+
const listings = new Map();
|
|
41
|
+
for (const kind of MARKETING_KIND_NAMES) {
|
|
42
|
+
listings.set(
|
|
43
|
+
kind,
|
|
44
|
+
await withRefusalGuidance(
|
|
45
|
+
onProgress,
|
|
46
|
+
"marketing pull",
|
|
47
|
+
async () => await listMarketingRecords(client, siteId, kind),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const summary = {};
|
|
53
|
+
for (const [kind, listing] of listings) {
|
|
54
|
+
const { file, noun } = MARKETING_KINDS[kind];
|
|
55
|
+
const live = listing.records.filter((record) => !record.archived);
|
|
56
|
+
const archived = listing.records.filter((record) => record.archived).map((record) => record.key).sort();
|
|
57
|
+
await writeWorkspaceJson(config.workspaceDir, file, projectMarketingDocument(kind, live));
|
|
58
|
+
manifest = withRecordedMarketing(
|
|
59
|
+
manifest,
|
|
60
|
+
kind,
|
|
61
|
+
listing.records.map((record) => [record.key, baselineEntry(kind, record)]),
|
|
62
|
+
);
|
|
63
|
+
const reported = boundedList(
|
|
64
|
+
live.map((record) => ({ key: record.key, state: record.state })).sort((left, right) => left.key < right.key ? -1 : 1),
|
|
65
|
+
MAXIMUM_REPORTED,
|
|
66
|
+
);
|
|
67
|
+
summary[kind] = {
|
|
68
|
+
file,
|
|
69
|
+
total: live.length,
|
|
70
|
+
items: reported.items,
|
|
71
|
+
...(reported.truncated ? { itemsTruncated: true } : {}),
|
|
72
|
+
...(archived.length > 0 ? archivedSummary(archived) : {}),
|
|
73
|
+
canManage: listing.canManage,
|
|
74
|
+
...(listing.maxActive === undefined ? {} : { maxActive: listing.maxActive }),
|
|
75
|
+
};
|
|
76
|
+
onProgress(`Wrote ${file} with ${live.length} ${noun}${live.length === 1 ? "" : "s"}.`);
|
|
77
|
+
}
|
|
78
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
79
|
+
return successResult(VERB_MARKETING_PULL, siteId, { marketing: summary });
|
|
80
|
+
}
|