@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.
- 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 +38 -0
- package/src/cli.js +115 -1
- package/src/constants.js +3 -0
- package/src/field-schema.json +11 -0
- package/src/field-validation.js +17 -1
- package/src/forms-contract.js +15 -1
- 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 +112 -1
- package/src/session.js +1 -1
- package/src/setting-reference.js +18 -0
- package/src/settings-catalog.js +4 -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/reference-help.js
CHANGED
|
@@ -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 =
|
|
98
|
+
export const REFERENCE_VERSION = 44;
|
|
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`,
|
|
@@ -866,6 +883,11 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
866
883
|
+ "the visitor saw it, so a response shows what each box agreed to.",
|
|
867
884
|
"Editing a definition appends a version on the site; versions are never changed. A page keeps the form "
|
|
868
885
|
+ "version it was published with until the next deploy, and a rollback restores the form it shipped with.",
|
|
886
|
+
"definition.presentation optionally overrides the site's form appearance: background, border and shadow are "
|
|
887
|
+
+ "optional booleans. Omit a property to inherit the corresponding site preference (formBoxBackground, "
|
|
888
|
+
+ "formBoxBorder, formBoxShadow); false turns that choice off. Omit presentation when all three inherit: an empty "
|
|
889
|
+
+ "object is refused. Site defaults are background on, border off and shadow on. Background can be off while border "
|
|
890
|
+
+ "and shadow remain on. The release freezes both the form choices and the site defaults.",
|
|
869
891
|
"Both verbs write the file in one canonical key order, so a pulled file pushed back unchanged reports "
|
|
870
892
|
+ "'unchanged' and leaves the bytes as they were. Write your own files in that order and a push rewrites "
|
|
871
893
|
+ "nothing you did not change.",
|
|
@@ -903,6 +925,95 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
903
925
|
}),
|
|
904
926
|
}),
|
|
905
927
|
}),
|
|
928
|
+
marketing: Object.freeze({
|
|
929
|
+
title: "Campaigns and placements workspace contract",
|
|
930
|
+
summary: "A site's campaigns and placements are two JSON files under marketing/. 'marketing pull' reads the site's, "
|
|
931
|
+
+ "'marketing push' creates, updates, restores and (with --archive) archives to match them, and 'link build' prints "
|
|
932
|
+
+ "an address that uses them.",
|
|
933
|
+
usage: `${CLI_BINARY_NAME} marketing pull | ${CLI_BINARY_NAME} marketing push [--archive]`,
|
|
934
|
+
details: Object.freeze([
|
|
935
|
+
"Run 'pull' once first: both verbs need the pulled workspace's manifest, which records what each record looked "
|
|
936
|
+
+ "like when this workspace last read it. The site's licence must include analytics, and the site must be a Standard site.",
|
|
937
|
+
`A campaign groups links from one push; a placement names the spot a link appears, such as a newsletter footer. `
|
|
938
|
+
+ `Both are public keys the site's release admits, so page views carry them: a campaign as utm_campaign, a placement as `
|
|
939
|
+
+ `utm_content. ${MARKETING_KINDS.campaigns.file} is { campaigns: [ { key, name, note?, starts_on?, ends_on? } ] }; `
|
|
940
|
+
+ `${MARKETING_KINDS.placements.file} is { placements: [ { key, name } ] }. Records are listed by key.`,
|
|
941
|
+
`A key is ${MARKETING_KEY_RULE}. It is public URL text and never changes or returns to use, even after the record is `
|
|
942
|
+
+ "archived, because page views already counted under it keep the key. A name (at most "
|
|
943
|
+
+ `${MARKETING_KINDS.campaigns.rules.nameMaxLength} characters), a campaign's note (at most ${MARKETING_KINDS.campaigns.rules.noteMaxLength}) `
|
|
944
|
+
+ "and its starts_on and ends_on days (YYYY-MM-DD, labels only) are private and can change.",
|
|
945
|
+
`A pull also writes two read-only fields: state (${MARKETING_STATES.join(", ")}) and counted_from, the day the key first `
|
|
946
|
+
+ "went live and page views began to count under it. A push ignores both. A record is live once the silent release "
|
|
947
|
+
+ "that carries its key has run; until then page views count under the source only.",
|
|
948
|
+
"'marketing push' validates both files and compares every record with the site before the first write. "
|
|
949
|
+
+ "A key the file names that the site lacks is created. A record whose labels differ is updated. A key the file "
|
|
950
|
+
+ "names that the site has archived is restored (and updated if its labels differ). Nothing else is touched: a "
|
|
951
|
+
+ "record the file leaves out stays unless you pass --archive, and --archive archives only records this workspace "
|
|
952
|
+
+ "pulled. A changed key is therefore a new record plus, if you want the old one gone, an explicit --archive.",
|
|
953
|
+
"A record that changed on the site since the last pull is refused as marketing.concurrent_modification, a key "
|
|
954
|
+
+ "the site has that this workspace never pulled as marketing.pull_required; reconcile with 'marketing pull' and "
|
|
955
|
+
+ "retry. Taproot refuses a key outside the grammar, a key already used by an archived record, and a record over "
|
|
956
|
+
+ `the quota (${maximumActive("campaigns")} unarchived campaigns, ${maximumActive("placements")} unarchived placements, and a larger cap on archived ones `
|
|
957
|
+
+ "together); the refusal is shown as Taproot returns it. Archive one to make room, or restore an archived one.",
|
|
958
|
+
"'marketing pull' reports, per kind, file, total, items ([{ key, state }], at most 200), archived keys (at most 200, "
|
|
959
|
+
+ "with archivedTotal and archivedTruncated), canManage and maxActive. 'marketing push' reports, per kind, the keys it "
|
|
960
|
+
+ "created, updated, restored, archived, left unchanged, left on the site because the file omits them (unlisted) and found "
|
|
961
|
+
+ "on the site but never pulled (unpulled), each list capped at 50 with the true totals in counts and the capped lists "
|
|
962
|
+
+ "named in truncated; written is { items, count } across both kinds.",
|
|
963
|
+
"Creating or restoring a key queues a silent production release that starts admitting it; no 'deploy' is needed and "
|
|
964
|
+
+ "none is waited for. 'marketing pull' later shows the state. Archiving queues no release: the key stops being admitted from the next one.",
|
|
965
|
+
"The delegation.content capability carries the permissions this needs: site.analytics.view to read and also "
|
|
966
|
+
+ "site.pages.publish_any to change, because a new key publishes a release. Platform-wide, an administrator can pause external "
|
|
967
|
+
+ "authoring; a push is then refused with the rollout field. A refusal names a missing permission.",
|
|
968
|
+
]),
|
|
969
|
+
example: Object.freeze({
|
|
970
|
+
campaigns: Object.freeze({
|
|
971
|
+
campaigns: Object.freeze([
|
|
972
|
+
Object.freeze({
|
|
973
|
+
key: "spring-launch",
|
|
974
|
+
name: "Spring launch",
|
|
975
|
+
note: "Newsletter and social push",
|
|
976
|
+
starts_on: "2026-03-01",
|
|
977
|
+
ends_on: "2026-03-31",
|
|
978
|
+
}),
|
|
979
|
+
]),
|
|
980
|
+
}),
|
|
981
|
+
placements: Object.freeze({
|
|
982
|
+
placements: Object.freeze([Object.freeze({ key: "nl-footer", name: "Newsletter footer" })]),
|
|
983
|
+
}),
|
|
984
|
+
commands: Object.freeze([
|
|
985
|
+
`${CLI_BINARY_NAME} marketing pull`,
|
|
986
|
+
`${CLI_BINARY_NAME} marketing push`,
|
|
987
|
+
`${CLI_BINARY_NAME} link build --path /recipes/spring-salad --source newsletter --campaign spring-launch --placement nl-footer`,
|
|
988
|
+
]),
|
|
989
|
+
}),
|
|
990
|
+
}),
|
|
991
|
+
link: Object.freeze({
|
|
992
|
+
title: "Tracked link builder",
|
|
993
|
+
summary: "'link build' prints one address that counts under a source, and optionally a campaign and a placement. It saves nothing.",
|
|
994
|
+
usage: `${CLI_BINARY_NAME} link build --path <page> --source <key> [--campaign <key>] [--placement <key>] [--domain <host>]`,
|
|
995
|
+
details: Object.freeze([
|
|
996
|
+
"--path is the page on this site, such as /recipes/spring-salad; / or an empty value is the home page. A query or #fragment "
|
|
997
|
+
+ "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: "
|
|
998
|
+
+ `${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: `
|
|
999
|
+
+ "a published page's own share action sets it.",
|
|
1000
|
+
"The address is https://<public host><path>?utm_source=<source>&utm_medium=<medium>[&utm_campaign=<campaign>][&utm_content=<placement>]. "
|
|
1001
|
+
+ "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 "
|
|
1002
|
+
+ "analytics only for campaigns and placements the site admits; anything else is dropped.",
|
|
1003
|
+
"The host is the site's public address, read from the signed-in site; a site credential alone cannot read it, so "
|
|
1004
|
+
+ "pass --domain <host> then. A site with no public address yet has nothing to build a link to.",
|
|
1005
|
+
"A campaign or placement is checked first. When the workspace has marketing/campaigns.json or marketing/placements.json "
|
|
1006
|
+
+ "(from 'marketing pull'), the file is the record of what exists; otherwise the live site is asked. A key it does not "
|
|
1007
|
+
+ "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 "
|
|
1008
|
+
+ "allowed, and the result carries a note: 'publishing' while its release runs, 'goes live with your first publish' for a site "
|
|
1009
|
+
+ "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.",
|
|
1010
|
+
"The result is { link: { address, host, path, source, medium, campaign?, placement? }, notes? }.",
|
|
1011
|
+
]),
|
|
1012
|
+
example: Object.freeze({
|
|
1013
|
+
command: `${CLI_BINARY_NAME} link build --path /recipes/spring-salad --source newsletter --campaign spring-launch --placement nl-footer`,
|
|
1014
|
+
address: "https://example.com/recipes/spring-salad?utm_source=newsletter&utm_medium=email&utm_campaign=spring-launch&utm_content=nl-footer",
|
|
1015
|
+
}),
|
|
1016
|
+
}),
|
|
906
1017
|
media: Object.freeze({
|
|
907
1018
|
title: "Media upload contract",
|
|
908
1019
|
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" });
|
package/src/setting-reference.js
CHANGED
|
@@ -272,6 +272,24 @@ const PREFERENCES_ENTRIES = {
|
|
|
272
272
|
+ "a deployment.",
|
|
273
273
|
false,
|
|
274
274
|
),
|
|
275
|
+
formBoxBackground: entry(
|
|
276
|
+
`${FEATURES} > Form appearance > Background`,
|
|
277
|
+
"Whether embedded forms have a box background by default. A form's presentation.background overrides this choice.",
|
|
278
|
+
"Embedded forms, including validation and confirmation. A change applies after deployment.",
|
|
279
|
+
true,
|
|
280
|
+
),
|
|
281
|
+
formBoxBorder: entry(
|
|
282
|
+
`${FEATURES} > Form appearance > Border`,
|
|
283
|
+
"Whether embedded forms have a border by default. A form's presentation.border overrides this choice.",
|
|
284
|
+
"Embedded forms. Independent of background and shadow; applies after deployment.",
|
|
285
|
+
false,
|
|
286
|
+
),
|
|
287
|
+
formBoxShadow: entry(
|
|
288
|
+
`${FEATURES} > Form appearance > Shadow`,
|
|
289
|
+
"Whether embedded forms have a shadow by default. A form's presentation.shadow overrides this choice.",
|
|
290
|
+
"Embedded forms. Independent of background and border; applies after deployment.",
|
|
291
|
+
true,
|
|
292
|
+
),
|
|
275
293
|
tiptapImageMaxHeightVh: entry(
|
|
276
294
|
`${FEATURES} > Image height`,
|
|
277
295
|
"The tallest an image in a page body may be, as a percent of the screen height (45, 60, 75 or 90).",
|
package/src/settings-catalog.js
CHANGED
|
@@ -130,6 +130,9 @@ export const SETTINGS_GROUPS = Object.freeze([
|
|
|
130
130
|
// Asks readers before the visit cookie is set (TR01263). Off is what an omitted value means, so a snapshot of
|
|
131
131
|
// an untouched site reads the same as the site behaves.
|
|
132
132
|
{ name: "analyticsConsentPrompt", wireType: "boolean" },
|
|
133
|
+
{ name: "formBoxBackground", wireType: "boolean", zero: true },
|
|
134
|
+
{ name: "formBoxBorder", wireType: "boolean" },
|
|
135
|
+
{ name: "formBoxShadow", wireType: "boolean", zero: true },
|
|
133
136
|
{ name: "tiptapImageMaxHeightVh", wireType: "int" },
|
|
134
137
|
{ name: "tiptapImageMaxImagesPerRow", wireType: "int" },
|
|
135
138
|
{ name: "tiptapImageBorderWidth", wireType: "int" },
|
|
@@ -146,7 +149,7 @@ export const CANDIDATE_SELECTABLE_SETTINGS_TYPES = Object.freeze(
|
|
|
146
149
|
);
|
|
147
150
|
|
|
148
151
|
function zeroValue(field) {
|
|
149
|
-
if (field.wireType === "boolean") return false;
|
|
152
|
+
if (field.wireType === "boolean") return field.zero ?? false;
|
|
150
153
|
if (field.wireType === "int" || field.wireType === "double") return 0;
|
|
151
154
|
if (field.wireType === "theme" || field.wireType === "document") return field.zero ?? {};
|
|
152
155
|
return field.zero ?? "";
|
|
@@ -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" },
|
package/src/site-checks.js
CHANGED
|
@@ -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()))
|
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,
|