@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/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 = 43;
|
|
92
99
|
|
|
93
100
|
// The date-limit examples `help forms` prints; tests run them through the form validator.
|
|
94
101
|
export const FORM_DATE_WINDOW_FIELD = Object.freeze({
|
|
@@ -389,7 +396,7 @@ export function getDesignBlueprint(type) {
|
|
|
389
396
|
return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
|
|
390
397
|
}
|
|
391
398
|
export const REFERENCE_TOPICS = Object.freeze([
|
|
392
|
-
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and
|
|
399
|
+
Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and legal draft pages." }),
|
|
393
400
|
Object.freeze({
|
|
394
401
|
name: "page",
|
|
395
402
|
usage: `${CLI_BINARY_NAME} help page <page-type>`,
|
|
@@ -422,6 +429,16 @@ export const REFERENCE_TOPICS = Object.freeze([
|
|
|
422
429
|
usage: `${CLI_BINARY_NAME} help forms`,
|
|
423
430
|
summary: "Author a form as forms/<key>.json: file shape, field types, validate, pull, push, and placing it on a page.",
|
|
424
431
|
}),
|
|
432
|
+
Object.freeze({
|
|
433
|
+
name: "marketing",
|
|
434
|
+
usage: `${CLI_BINARY_NAME} help marketing`,
|
|
435
|
+
summary: "Keep a site's campaigns and placements as marketing/*.json: file shape, pull, push, archive, and what an agent may do.",
|
|
436
|
+
}),
|
|
437
|
+
Object.freeze({
|
|
438
|
+
name: "link",
|
|
439
|
+
usage: `${CLI_BINARY_NAME} help link`,
|
|
440
|
+
summary: "Build a tracked address: path, source, optional campaign and placement; what counts and when it goes live.",
|
|
441
|
+
}),
|
|
425
442
|
Object.freeze({
|
|
426
443
|
name: "media",
|
|
427
444
|
usage: `${CLI_BINARY_NAME} help media`,
|
|
@@ -903,6 +920,95 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
903
920
|
}),
|
|
904
921
|
}),
|
|
905
922
|
}),
|
|
923
|
+
marketing: Object.freeze({
|
|
924
|
+
title: "Campaigns and placements workspace contract",
|
|
925
|
+
summary: "A site's campaigns and placements are two JSON files under marketing/. 'marketing pull' reads the site's, "
|
|
926
|
+
+ "'marketing push' creates, updates, restores and (with --archive) archives to match them, and 'link build' prints "
|
|
927
|
+
+ "an address that uses them.",
|
|
928
|
+
usage: `${CLI_BINARY_NAME} marketing pull | ${CLI_BINARY_NAME} marketing push [--archive]`,
|
|
929
|
+
details: Object.freeze([
|
|
930
|
+
"Run 'pull' once first: both verbs need the pulled workspace's manifest, which records what each record looked "
|
|
931
|
+
+ "like when this workspace last read it. The site's licence must include analytics, and the site must be a Standard site.",
|
|
932
|
+
`A campaign groups links from one push; a placement names the spot a link appears, such as a newsletter footer. `
|
|
933
|
+
+ `Both are public keys the site's release admits, so page views carry them: a campaign as utm_campaign, a placement as `
|
|
934
|
+
+ `utm_content. ${MARKETING_KINDS.campaigns.file} is { campaigns: [ { key, name, note?, starts_on?, ends_on? } ] }; `
|
|
935
|
+
+ `${MARKETING_KINDS.placements.file} is { placements: [ { key, name } ] }. Records are listed by key.`,
|
|
936
|
+
`A key is ${MARKETING_KEY_RULE}. It is public URL text and never changes or returns to use, even after the record is `
|
|
937
|
+
+ "archived, because page views already counted under it keep the key. A name (at most "
|
|
938
|
+
+ `${MARKETING_KINDS.campaigns.rules.nameMaxLength} characters), a campaign's note (at most ${MARKETING_KINDS.campaigns.rules.noteMaxLength}) `
|
|
939
|
+
+ "and its starts_on and ends_on days (YYYY-MM-DD, labels only) are private and can change.",
|
|
940
|
+
`A pull also writes two read-only fields: state (${MARKETING_STATES.join(", ")}) and counted_from, the day the key first `
|
|
941
|
+
+ "went live and page views began to count under it. A push ignores both. A record is live once the silent release "
|
|
942
|
+
+ "that carries its key has run; until then page views count under the source only.",
|
|
943
|
+
"'marketing push' validates both files and compares every record with the site before the first write. "
|
|
944
|
+
+ "A key the file names that the site lacks is created. A record whose labels differ is updated. A key the file "
|
|
945
|
+
+ "names that the site has archived is restored (and updated if its labels differ). Nothing else is touched: a "
|
|
946
|
+
+ "record the file leaves out stays unless you pass --archive, and --archive archives only records this workspace "
|
|
947
|
+
+ "pulled. A changed key is therefore a new record plus, if you want the old one gone, an explicit --archive.",
|
|
948
|
+
"A record that changed on the site since the last pull is refused as marketing.concurrent_modification, a key "
|
|
949
|
+
+ "the site has that this workspace never pulled as marketing.pull_required; reconcile with 'marketing pull' and "
|
|
950
|
+
+ "retry. Taproot refuses a key outside the grammar, a key already used by an archived record, and a record over "
|
|
951
|
+
+ `the quota (${maximumActive("campaigns")} unarchived campaigns, ${maximumActive("placements")} unarchived placements, and a larger cap on archived ones `
|
|
952
|
+
+ "together); the refusal is shown as Taproot returns it. Archive one to make room, or restore an archived one.",
|
|
953
|
+
"'marketing pull' reports, per kind, file, total, items ([{ key, state }], at most 200), archived keys (at most 200, "
|
|
954
|
+
+ "with archivedTotal and archivedTruncated), canManage and maxActive. 'marketing push' reports, per kind, the keys it "
|
|
955
|
+
+ "created, updated, restored, archived, left unchanged, left on the site because the file omits them (unlisted) and found "
|
|
956
|
+
+ "on the site but never pulled (unpulled), each list capped at 50 with the true totals in counts and the capped lists "
|
|
957
|
+
+ "named in truncated; written is { items, count } across both kinds.",
|
|
958
|
+
"Creating or restoring a key queues a silent production release that starts admitting it; no 'deploy' is needed and "
|
|
959
|
+
+ "none is waited for. 'marketing pull' later shows the state. Archiving queues no release: the key stops being admitted from the next one.",
|
|
960
|
+
"The delegation.content capability carries the permissions this needs: site.analytics.view to read and also "
|
|
961
|
+
+ "site.pages.publish_any to change, because a new key publishes a release. Platform-wide, an administrator can pause external "
|
|
962
|
+
+ "authoring; a push is then refused with the rollout field. A refusal names a missing permission.",
|
|
963
|
+
]),
|
|
964
|
+
example: Object.freeze({
|
|
965
|
+
campaigns: Object.freeze({
|
|
966
|
+
campaigns: Object.freeze([
|
|
967
|
+
Object.freeze({
|
|
968
|
+
key: "spring-launch",
|
|
969
|
+
name: "Spring launch",
|
|
970
|
+
note: "Newsletter and social push",
|
|
971
|
+
starts_on: "2026-03-01",
|
|
972
|
+
ends_on: "2026-03-31",
|
|
973
|
+
}),
|
|
974
|
+
]),
|
|
975
|
+
}),
|
|
976
|
+
placements: Object.freeze({
|
|
977
|
+
placements: Object.freeze([Object.freeze({ key: "nl-footer", name: "Newsletter footer" })]),
|
|
978
|
+
}),
|
|
979
|
+
commands: Object.freeze([
|
|
980
|
+
`${CLI_BINARY_NAME} marketing pull`,
|
|
981
|
+
`${CLI_BINARY_NAME} marketing push`,
|
|
982
|
+
`${CLI_BINARY_NAME} link build --path /recipes/spring-salad --source newsletter --campaign spring-launch --placement nl-footer`,
|
|
983
|
+
]),
|
|
984
|
+
}),
|
|
985
|
+
}),
|
|
986
|
+
link: Object.freeze({
|
|
987
|
+
title: "Tracked link builder",
|
|
988
|
+
summary: "'link build' prints one address that counts under a source, and optionally a campaign and a placement. It saves nothing.",
|
|
989
|
+
usage: `${CLI_BINARY_NAME} link build --path <page> --source <key> [--campaign <key>] [--placement <key>] [--domain <host>]`,
|
|
990
|
+
details: Object.freeze([
|
|
991
|
+
"--path is the page on this site, such as /recipes/spring-salad; / or an empty value is the home page. A query or #fragment "
|
|
992
|
+
+ "is kept; a full address is accepted only if it is on this site's own host. --source is where the link will be posted: "
|
|
993
|
+
+ `${LINK_SOURCES.map((source) => source.key).join(", ")}. The medium (social or email) is set by the source and cannot be chosen. 'reader' is not offered: `
|
|
994
|
+
+ "a published page's own share action sets it.",
|
|
995
|
+
"The address is https://<public host><path>?utm_source=<source>&utm_medium=<medium>[&utm_campaign=<campaign>][&utm_content=<placement>]. "
|
|
996
|
+
+ "A utm_source, utm_medium, utm_campaign or utm_content already on the path is replaced, never duplicated. Page views carry these values into the site's "
|
|
997
|
+
+ "analytics only for campaigns and placements the site admits; anything else is dropped.",
|
|
998
|
+
"The host is the site's public address, read from the signed-in site; a site credential alone cannot read it, so "
|
|
999
|
+
+ "pass --domain <host> then. A site with no public address yet has nothing to build a link to.",
|
|
1000
|
+
"A campaign or placement is checked first. When the workspace has marketing/campaigns.json or marketing/placements.json "
|
|
1001
|
+
+ "(from 'marketing pull'), the file is the record of what exists; otherwise the live site is asked. A key it does not "
|
|
1002
|
+
+ "hold, or one the site has archived, is refused, because it would not be counted. A key that exists but is not live yet is "
|
|
1003
|
+
+ "allowed, and the result carries a note: 'publishing' while its release runs, 'goes live with your first publish' for a site "
|
|
1004
|
+
+ "that has not published, or 'not in the current release' after a rollback. Until it is live, page views count under the source only. A note read from a file says it is as of the last marketing pull or push, so run 'marketing pull' for the current state.",
|
|
1005
|
+
"The result is { link: { address, host, path, source, medium, campaign?, placement? }, notes? }.",
|
|
1006
|
+
]),
|
|
1007
|
+
example: Object.freeze({
|
|
1008
|
+
command: `${CLI_BINARY_NAME} link build --path /recipes/spring-salad --source newsletter --campaign spring-launch --placement nl-footer`,
|
|
1009
|
+
address: "https://example.com/recipes/spring-salad?utm_source=newsletter&utm_medium=email&utm_campaign=spring-launch&utm_content=nl-footer",
|
|
1010
|
+
}),
|
|
1011
|
+
}),
|
|
906
1012
|
media: Object.freeze({
|
|
907
1013
|
title: "Media upload contract",
|
|
908
1014
|
summary: "Upload workspace-root-relative raster files and videos; images are recorded in .taproot-site-media.json and videos report their ids.",
|
|
@@ -1728,7 +1834,7 @@ const FREE_FORM_REFERENCE = Object.freeze({
|
|
|
1728
1834
|
legalAndPrivacyPages: Object.freeze({
|
|
1729
1835
|
...LEGAL_PLATFORM_LINK_GUIDANCE,
|
|
1730
1836
|
choosing:
|
|
1731
|
-
`A policy that needs a version and an effective date is a legal page
|
|
1837
|
+
`A policy that needs a version and an effective date is a legal page; see '${CLI_BINARY_NAME} help page legal' for draft editing and app approval.`,
|
|
1732
1838
|
}),
|
|
1733
1839
|
componentNode: Object.freeze({
|
|
1734
1840
|
type: "componentBlock",
|
package/src/session.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ROLLOUT_REFUSAL_FIELD,
|
|
12
12
|
VERB_LOGIN,
|
|
13
13
|
VERB_USE,
|
|
14
|
+
SURFACE_STANDARD,
|
|
14
15
|
} from "./constants.js";
|
|
15
16
|
import { loadSiteConfig } from "./config.js";
|
|
16
17
|
import { cliOutdatedError, compareCliVersions, isBehindLatest } from "./cli-release.js";
|
|
@@ -85,7 +86,7 @@ function keyMissingError() {
|
|
|
85
86
|
* The exchange's own answer is checked again afterwards, because the
|
|
86
87
|
* recording can be stale.
|
|
87
88
|
*/
|
|
88
|
-
function requireRecordedSurface(invocation, config) {
|
|
89
|
+
export function requireRecordedSurface(invocation, config) {
|
|
89
90
|
if (invocation.surface === undefined || config.authoringSurface === undefined) return;
|
|
90
91
|
if (!surfaceAllows(config.authoringSurface, invocation.surface)) {
|
|
91
92
|
throw surfaceRefusal(invocation.verb ?? "This verb", config.authoringSurface, { source: "config" });
|
|
@@ -219,6 +220,7 @@ export async function resolveApiBaseUrl(environment) {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
export async function openSession(invocation = {}) {
|
|
223
|
+
if (invocation.resolvedSession !== undefined) return invocation.resolvedSession;
|
|
222
224
|
const environment = invocation.environment ?? process.env;
|
|
223
225
|
const onProgress = progressSink(invocation);
|
|
224
226
|
const now = invocation.now ?? Date.now;
|
|
@@ -231,6 +233,9 @@ export async function openSession(invocation = {}) {
|
|
|
231
233
|
// at all.
|
|
232
234
|
const environmentToken = environment?.[PUBLISH_KEY_ENVIRONMENT_VARIABLE];
|
|
233
235
|
if (environmentToken !== undefined) {
|
|
236
|
+
if (invocation.legalDrafts) {
|
|
237
|
+
throw new SiteAuthoringError("auth.legal_environment_override", `Unset ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} and run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' to use the selected site's legal draft grant.`);
|
|
238
|
+
}
|
|
234
239
|
const apiBaseUrl = await resolveApiBaseUrl(environment);
|
|
235
240
|
const config = await loadConfig(invocation);
|
|
236
241
|
if (!config.siteId) throw siteMissingError();
|
|
@@ -244,6 +249,7 @@ export async function openSession(invocation = {}) {
|
|
|
244
249
|
return {
|
|
245
250
|
config,
|
|
246
251
|
client,
|
|
252
|
+
legalDrafts: false,
|
|
247
253
|
siteId: config.siteId,
|
|
248
254
|
surface: config.authoringSurface,
|
|
249
255
|
platform: UNKNOWN_PLATFORM,
|
|
@@ -253,6 +259,32 @@ export async function openSession(invocation = {}) {
|
|
|
253
259
|
};
|
|
254
260
|
}
|
|
255
261
|
|
|
262
|
+
if (invocation.legalDrafts) {
|
|
263
|
+
const apiBaseUrl = await resolveApiBaseUrl(environment);
|
|
264
|
+
const config = await loadConfig(invocation);
|
|
265
|
+
if (!config.siteId) throw siteMissingError();
|
|
266
|
+
if (config.authoringSurface !== undefined && config.authoringSurface !== SURFACE_STANDARD) {
|
|
267
|
+
throw new SiteAuthoringError("auth.legal_site_required", "Legal draft access requires a standard site.");
|
|
268
|
+
}
|
|
269
|
+
requireRecordedSurface(invocation, config);
|
|
270
|
+
const store = await readCredentialStore(environment);
|
|
271
|
+
const credential = selectCredential(store.credentials, credentialApiOrigin(apiBaseUrl), { siteId: config.siteId });
|
|
272
|
+
if (credential === undefined) {
|
|
273
|
+
throw new SiteAuthoringError("auth.legal_grant_missing", `Legal draft access is missing. Run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.`);
|
|
274
|
+
}
|
|
275
|
+
if (Date.parse(credential.keyExpiresAt) <= now()) {
|
|
276
|
+
onProgress(
|
|
277
|
+
`The stored legal draft grant expired at ${credential.keyExpiresAt}. `
|
|
278
|
+
+ `If Taproot refuses it, run '${CLI_BINARY_NAME} ${VERB_LOGIN} --legal-drafts' again.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
config, client: invocation.client ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, credential.key)),
|
|
283
|
+
legalDrafts: true, siteId: config.siteId, surface: config.authoringSurface,
|
|
284
|
+
platform: UNKNOWN_PLATFORM, release: UNKNOWN_CLI_RELEASE, now, onProgress,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
256
288
|
let config;
|
|
257
289
|
let apiBaseUrl;
|
|
258
290
|
const signIn = await requireSignIn(environment, async () => {
|
|
@@ -280,6 +312,7 @@ export async function openSession(invocation = {}) {
|
|
|
280
312
|
return {
|
|
281
313
|
config,
|
|
282
314
|
client: invocation.client,
|
|
315
|
+
legalDrafts: false,
|
|
283
316
|
siteId: config.siteId,
|
|
284
317
|
surface: config.authoringSurface,
|
|
285
318
|
platform: UNKNOWN_PLATFORM,
|
|
@@ -403,6 +436,7 @@ export async function openSession(invocation = {}) {
|
|
|
403
436
|
config,
|
|
404
437
|
client,
|
|
405
438
|
siteId: config.siteId,
|
|
439
|
+
legalDrafts: false,
|
|
406
440
|
surface: exchanged.authoringSurface,
|
|
407
441
|
exchanged: {
|
|
408
442
|
keyId: exchanged.keyId,
|
|
@@ -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()))
|
|
@@ -323,12 +323,6 @@ const TYPE_DETAILS = Object.freeze({
|
|
|
323
323
|
},
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
-
/**
|
|
327
|
-
* The legal page type (TR01221). It is described here and nowhere else in the
|
|
328
|
-
* CLI: it has no template name in `typed-pages.js` because a site-authoring
|
|
329
|
-
* key cannot author it, so `pull` records it as a metadata-only page and
|
|
330
|
-
* `pages push` never sees a source for it.
|
|
331
|
-
*/
|
|
332
326
|
export const LEGAL_PAGE_TYPE = "legal";
|
|
333
327
|
|
|
334
328
|
export const TYPED_PAGE_TYPES = Object.freeze([...Object.keys(TYPE_DETAILS), LEGAL_PAGE_TYPE]);
|
|
@@ -355,15 +349,14 @@ function legalReference() {
|
|
|
355
349
|
displayName: "Legal page",
|
|
356
350
|
summary:
|
|
357
351
|
"A versioned legal document such as a privacy policy or terms: an automatic h1, a version and effective date, and "
|
|
358
|
-
+ "permanent links to each version. The CLI
|
|
359
|
-
readOnly:
|
|
352
|
+
+ "permanent links to each version. The CLI can edit drafts with an explicit site grant; approval and publication happen in the app.",
|
|
353
|
+
readOnly: false,
|
|
360
354
|
workspace: Object.freeze({
|
|
361
355
|
directory: `${PAGES_DIRECTORY}/`,
|
|
362
356
|
templateType: "TEMPLATE_TYPE_LEGAL",
|
|
363
357
|
sourceRule:
|
|
364
|
-
"
|
|
365
|
-
+ "
|
|
366
|
-
+ "nothing to send.",
|
|
358
|
+
"Pull writes a .pm.json source containing template legal and data with documentKey, documentRole, summaryOfChanges, material and body. "
|
|
359
|
+
+ "Edit that source to change the draft; Markdown is not supported. To create one, add a .pm.json source and a manifest pages entry with file, title and path, without a pageId. Version, effectiveDate and supersedesVersion are assigned by Taproot and must not be authored.",
|
|
367
360
|
}),
|
|
368
361
|
fields: Object.freeze([
|
|
369
362
|
Object.freeze({ name: "title", description: "The page title. It prints as the page's h1, so the body should not open with its own title." }),
|
|
@@ -374,13 +367,13 @@ function legalReference() {
|
|
|
374
367
|
Object.freeze({
|
|
375
368
|
name: "documentKey",
|
|
376
369
|
description:
|
|
377
|
-
"The document's stable identity,
|
|
370
|
+
"The document's stable identity, fixed after creation. The permanent version address exists only once the page has "
|
|
378
371
|
+ "a document key and a version.",
|
|
379
372
|
}),
|
|
380
373
|
Object.freeze({
|
|
381
374
|
name: "documentRole",
|
|
382
375
|
description:
|
|
383
|
-
"The typed purpose of the document, such as membership_terms or privacy_notice.
|
|
376
|
+
"The typed purpose of the document, such as membership_terms or privacy_notice. Fixed after creation.",
|
|
384
377
|
}),
|
|
385
378
|
Object.freeze({ name: "summaryOfChanges", description: "A short note on what this version changed, written by the owner." }),
|
|
386
379
|
Object.freeze({
|
|
@@ -402,14 +395,11 @@ function legalReference() {
|
|
|
402
395
|
]),
|
|
403
396
|
authoring: Object.freeze({
|
|
404
397
|
cli:
|
|
405
|
-
"
|
|
406
|
-
+ "
|
|
407
|
-
+ "included, and the request holding it is refused (up to 100 pages go in one request): name the pages ('approve <path>').",
|
|
398
|
+
"Pull reads legal pages with ordinary Content access. Run 'taproot-site login --legal-drafts', then "
|
|
399
|
+
+ "'taproot-site --legal-drafts pages push' to create or edit legal drafts. Approval and deletion are refused by the CLI; use the app. A bare 'approve' skips legal drafts; naming a legal page refuses the whole selection.",
|
|
408
400
|
key:
|
|
409
|
-
"
|
|
410
|
-
+ "
|
|
411
|
-
+ "outside the key's delegation.content, delegation.design and delegation.deployments capabilities. Until a "
|
|
412
|
-
+ "person promotes an app-approved legal page, every production promotion of a release staged after that approval is refused. This is how keys work today; whether a key should ever author a legal page is an open owner decision.",
|
|
401
|
+
"Only an explicit site-specific legal-drafts grant can save legal drafts. It does not grant site.legal_documents.manage, "
|
|
402
|
+
+ "and cannot approve, delete or deploy legal documents.",
|
|
413
403
|
app:
|
|
414
404
|
"A signed-in person who may manage the site's legal documents creates the page, writes its body, and publishes it "
|
|
415
405
|
+ "with a production deployment in the app. Taproot assigns the version and effective date at that point.",
|
|
@@ -421,18 +411,21 @@ function legalReference() {
|
|
|
421
411
|
freeForm:
|
|
422
412
|
"A free-form page is an ordinary page. It has no version history, no effective date, no permanent version "
|
|
423
413
|
+ "addresses and no automatic heading: open its Markdown with a '# ' line holding the title (see "
|
|
424
|
-
+ `'${CLI_BINARY_NAME} help page free-form').
|
|
425
|
-
+ "page, free-form unless another template fits: put the date it was last updated in its text, and tell the owner it is not versioned.",
|
|
414
|
+
+ `'${CLI_BINARY_NAME} help page free-form'). A policy written as free-form is an ordinary page; put its last-updated date in its text and tell the owner it is not versioned.`,
|
|
426
415
|
}),
|
|
427
416
|
platformLink: LEGAL_PLATFORM_LINK_GUIDANCE,
|
|
428
417
|
workflow: Object.freeze([
|
|
429
418
|
Object.freeze({
|
|
430
419
|
command: `${CLI_BINARY_NAME} pull`,
|
|
431
|
-
result: "
|
|
420
|
+
result: "Writes the legal draft as a .pm.json source and records its identity in the manifest.",
|
|
421
|
+
}),
|
|
422
|
+
Object.freeze({
|
|
423
|
+
command: `${CLI_BINARY_NAME} login --legal-drafts`,
|
|
424
|
+
result: "Request a site-specific grant; a person approves it in the app.",
|
|
432
425
|
}),
|
|
433
426
|
Object.freeze({
|
|
434
|
-
command:
|
|
435
|
-
result: "
|
|
427
|
+
command: `${CLI_BINARY_NAME} --legal-drafts pages push`,
|
|
428
|
+
result: "Saves changed legal drafts. A person approves and publishes them in the app.",
|
|
436
429
|
}),
|
|
437
430
|
]),
|
|
438
431
|
});
|
|
@@ -516,7 +509,7 @@ function formatField(field) {
|
|
|
516
509
|
}
|
|
517
510
|
|
|
518
511
|
function formatLegalReference(page) {
|
|
519
|
-
return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields
|
|
512
|
+
return `${page.displayName} (${page.type})\n${page.summary}\n\nWorkspace (${page.workspace.directory}):\n source rule ${page.workspace.sourceRule}\n\nFields:\n${
|
|
520
513
|
page.fields.map((field) => ` ${field.name}: ${field.description}`).join("\n")
|
|
521
514
|
}\n\nHow it renders:\n${page.rendering.map((line) => ` - ${line}`).join("\n")}\n\nWho can author it:\n CLI ${page.authoring.cli}\n site-authoring key ${page.authoring.key}\n app ${page.authoring.app}\n\nLegal page or free-form page:\n legal ${page.choosing.legal}\n free-form ${page.choosing.freeForm}\n\nLinking Taproot: ${page.platformLink.rule}\n example ${page.platformLink.example}\n\nWorkflow:\n${
|
|
522
515
|
page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
|
package/src/typed-pages.js
CHANGED
|
@@ -30,6 +30,7 @@ export const TEMPLATE_ARTICLE = "article";
|
|
|
30
30
|
export const TEMPLATE_RECIPE = "recipe";
|
|
31
31
|
export const TEMPLATE_ALBUM = "album";
|
|
32
32
|
export const TEMPLATE_PLACE_REVIEW = "place-review";
|
|
33
|
+
export const TEMPLATE_LEGAL = "legal";
|
|
33
34
|
/**
|
|
34
35
|
* A page Taproot generates (a tag, a month, a folder). It is update-only: the
|
|
35
36
|
* owner's title, breadcrumb, description and introduction are authored, and
|
|
@@ -44,6 +45,7 @@ export const PAGE_TEMPLATES = Object.freeze({
|
|
|
44
45
|
[TEMPLATE_RECIPE]: Object.freeze({ wireType: "TEMPLATE_TYPE_RECIPE", dataKey: "recipeData" }),
|
|
45
46
|
[TEMPLATE_ALBUM]: Object.freeze({ wireType: "TEMPLATE_TYPE_ALBUM", dataKey: "albumData" }),
|
|
46
47
|
[TEMPLATE_PLACE_REVIEW]: Object.freeze({ wireType: "TEMPLATE_TYPE_PLACE_REVIEW", dataKey: "placeReviewData" }),
|
|
48
|
+
[TEMPLATE_LEGAL]: Object.freeze({ wireType: "TEMPLATE_TYPE_LEGAL", dataKey: "legalDocumentData" }),
|
|
47
49
|
[TEMPLATE_GENERATED]: Object.freeze({ wireType: "TEMPLATE_TYPE_GENERATED", dataKey: "generatedPageData" }),
|
|
48
50
|
});
|
|
49
51
|
|
|
@@ -77,6 +79,7 @@ const TEMPLATE_FRONT_MATTER_KEYS = Object.freeze({
|
|
|
77
79
|
[TEMPLATE_PLACE_REVIEW]: Object.freeze([...TYPED_PAGE_FRONT_MATTER_KEYS, "placeId", "rating"]),
|
|
78
80
|
// Generated pages have no Markdown form.
|
|
79
81
|
[TEMPLATE_GENERATED]: Object.freeze([]),
|
|
82
|
+
[TEMPLATE_LEGAL]: Object.freeze([]),
|
|
80
83
|
});
|
|
81
84
|
|
|
82
85
|
export const FRONT_MATTER_KEYS = Object.freeze(
|
|
@@ -172,7 +175,17 @@ export function isAuthorableTemplateType(wireType) {
|
|
|
172
175
|
return templateNameForWireType(wireType) !== undefined;
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
/** Whether
|
|
178
|
+
/** Whether this wire template type is a legal document. */
|
|
179
|
+
export function isLegalTemplateType(wireType) {
|
|
180
|
+
return wireType === PAGE_TEMPLATES[TEMPLATE_LEGAL].wireType;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The legal identity fields that remain fixed after creation. */
|
|
184
|
+
export function legalIdentity(data) {
|
|
185
|
+
return { documentKey: data.documentKey, documentRole: data.documentRole };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether this wire template type is a generated page. */
|
|
176
189
|
export function isGeneratedTemplateType(wireType) {
|
|
177
190
|
return wireType === PAGE_TEMPLATES[TEMPLATE_GENERATED].wireType;
|
|
178
191
|
}
|
|
@@ -342,6 +355,13 @@ function canonicalIngredient(value) {
|
|
|
342
355
|
* server's JSON omits defaults and may gain fields).
|
|
343
356
|
*/
|
|
344
357
|
const DATA_BUILDERS = Object.freeze({
|
|
358
|
+
[TEMPLATE_LEGAL]: (raw) => ({
|
|
359
|
+
documentKey: raw.documentKey ?? "",
|
|
360
|
+
documentRole: raw.documentRole ?? "",
|
|
361
|
+
summaryOfChanges: raw.summaryOfChanges ?? "",
|
|
362
|
+
material: raw.material === true,
|
|
363
|
+
body: raw.body,
|
|
364
|
+
}),
|
|
345
365
|
[TEMPLATE_ARTICLE]: (raw) => definedEntries([["body", raw.body], ["place", raw.place]]),
|
|
346
366
|
[TEMPLATE_PLACE_REVIEW]: (raw) => ({ placeId: raw.placeId, rating: raw.rating, body: raw.body }),
|
|
347
367
|
// Defaults are written out, so a page with no custom title reads the same on every pull.
|
|
@@ -415,6 +435,16 @@ function optionalPlace(value, file, path) {
|
|
|
415
435
|
/** Strict checks on a source document's `data`, returning it in canonical form. */
|
|
416
436
|
function canonicalSourceData(template, data, file) {
|
|
417
437
|
const path = "data";
|
|
438
|
+
if (template === TEMPLATE_LEGAL) {
|
|
439
|
+
requireKeys(data, ["documentKey", "documentRole", "summaryOfChanges", "material", "body"], file, path);
|
|
440
|
+
for (const key of ["documentKey", "documentRole", "summaryOfChanges"]) {
|
|
441
|
+
if (data[key] !== undefined) requireString(data[key], file, `${path}.${key}`);
|
|
442
|
+
}
|
|
443
|
+
if (data.material !== undefined && typeof data.material !== "boolean") {
|
|
444
|
+
throw fail("pages.document_shape", `'${file}' ${path}.material must be true or false.`, file);
|
|
445
|
+
}
|
|
446
|
+
return DATA_BUILDERS[template]({ ...data, body: requireDocument(data.body, file, `${path}.body`, { nonEmpty: true }) });
|
|
447
|
+
}
|
|
418
448
|
if (template === TEMPLATE_ARTICLE) {
|
|
419
449
|
requireKeys(data, ["body", "place"], file, path);
|
|
420
450
|
return DATA_BUILDERS[template]({
|
|
@@ -564,10 +594,10 @@ export function typedDocumentFromJson(parsed, file) {
|
|
|
564
594
|
file,
|
|
565
595
|
);
|
|
566
596
|
}
|
|
567
|
-
if (template === TEMPLATE_GENERATED && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
|
|
597
|
+
if ((template === TEMPLATE_GENERATED || template === TEMPLATE_LEGAL) && (parsed.displayDate !== undefined || parsed.coverImageId !== undefined)) {
|
|
568
598
|
throw fail(
|
|
569
599
|
"pages.document_shape",
|
|
570
|
-
`'${file}' is a
|
|
600
|
+
`'${file}' is a ${template} page, which has no displayDate or coverImageId of its own: its document is `
|
|
571
601
|
+ "{ template, data }.",
|
|
572
602
|
file,
|
|
573
603
|
);
|
|
@@ -602,6 +632,16 @@ const pagePlaceId = (value) =>
|
|
|
602
632
|
|
|
603
633
|
/** Lenient projections of what the site returned for each template's data. */
|
|
604
634
|
const PAGE_PROJECTIONS = Object.freeze({
|
|
635
|
+
[TEMPLATE_LEGAL]: (raw) => {
|
|
636
|
+
const body = presentDocument(raw.body);
|
|
637
|
+
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_LEGAL]({
|
|
638
|
+
documentKey: typeof raw.documentKey === "string" ? raw.documentKey : "",
|
|
639
|
+
documentRole: typeof raw.documentRole === "string" ? raw.documentRole : "",
|
|
640
|
+
summaryOfChanges: typeof raw.summaryOfChanges === "string" ? raw.summaryOfChanges : "",
|
|
641
|
+
material: raw.material === true,
|
|
642
|
+
body,
|
|
643
|
+
});
|
|
644
|
+
},
|
|
605
645
|
[TEMPLATE_ARTICLE]: (raw) => {
|
|
606
646
|
const body = presentDocument(raw.body);
|
|
607
647
|
return body === undefined ? undefined : DATA_BUILDERS[TEMPLATE_ARTICLE]({ body, place: pagePlaceId(raw.place) });
|
|
@@ -666,7 +706,7 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
|
|
|
666
706
|
const data = PAGE_PROJECTIONS[name](raw);
|
|
667
707
|
if (data === undefined) return undefined;
|
|
668
708
|
// A generated page's date and cover are not part of its source: a push leaves them as the site holds them.
|
|
669
|
-
const generated = name === TEMPLATE_GENERATED;
|
|
709
|
+
const generated = name === TEMPLATE_GENERATED || name === TEMPLATE_LEGAL;
|
|
670
710
|
return typedDocument(name, {
|
|
671
711
|
displayDate: !generated && typeof page.displayDate === "string" && page.displayDate !== ""
|
|
672
712
|
? page.displayDate
|
|
@@ -705,7 +745,7 @@ export function contentDocuments(document_) {
|
|
|
705
745
|
/** Whether a push sends `displayDate` for pages of this wire template type (every template but free-form). */
|
|
706
746
|
export function sentDisplayDate(wireType) {
|
|
707
747
|
const name = templateNameForWireType(wireType);
|
|
708
|
-
return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED;
|
|
748
|
+
return name !== undefined && name !== TEMPLATE_FREE_FORM && name !== TEMPLATE_GENERATED && name !== TEMPLATE_LEGAL;
|
|
709
749
|
}
|
|
710
750
|
|
|
711
751
|
// The nodes the API accepts in an album or recipe introduction and in a recipe
|
|
@@ -903,7 +943,7 @@ export function wireTemplate(document_) {
|
|
|
903
943
|
* an update, and an empty string means "clear", which is what the API does.
|
|
904
944
|
*/
|
|
905
945
|
export function wirePageFields(document_) {
|
|
906
|
-
if (documentTemplate(document_)
|
|
946
|
+
if ([TEMPLATE_FREE_FORM, TEMPLATE_LEGAL].includes(documentTemplate(document_))) return {};
|
|
907
947
|
return definedEntries([
|
|
908
948
|
["displayDate", document_.displayDate],
|
|
909
949
|
["coverImageId", document_.coverImageId],
|
|
@@ -1111,6 +1151,10 @@ function parseFlag(fields, key, file) {
|
|
|
1111
1151
|
}
|
|
1112
1152
|
|
|
1113
1153
|
/** Generated pages are edited in their .pm.json source only. */
|
|
1154
|
+
export function legalMarkdownFault(file) {
|
|
1155
|
+
return fail("pages.legal_markdown", `'${file}' declares the legal template, which has no Markdown form. Use a .pm.json document.`, file);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1114
1158
|
export function generatedMarkdownFault(file) {
|
|
1115
1159
|
return fail(
|
|
1116
1160
|
"pages.generated_markdown",
|
|
@@ -1149,6 +1193,7 @@ export function templateFrontMatterFault(template, fieldNames, file) {
|
|
|
1149
1193
|
*/
|
|
1150
1194
|
export async function typedDocumentFromMarkdown({ template, fields, markdown, file, convert, resolveImage }) {
|
|
1151
1195
|
if (template === TEMPLATE_GENERATED) throw generatedMarkdownFault(file);
|
|
1196
|
+
if (template === TEMPLATE_LEGAL) throw legalMarkdownFault(file);
|
|
1152
1197
|
const displayDate = fields.has("displayDate") ? requireDisplayDate(fields.get("displayDate"), file) : undefined;
|
|
1153
1198
|
let coverImageId;
|
|
1154
1199
|
if (fields.has("coverImage")) {
|