@taprootio/site-authoring 0.15.0 → 0.16.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 +121 -8
- package/package.json +1 -1
- package/src/api.js +65 -0
- package/src/authors-contract.js +170 -0
- package/src/cli.js +211 -11
- package/src/constants.js +3 -0
- package/src/content/link-warnings.js +109 -0
- package/src/fixture-init.js +55 -3
- package/src/page-metadata.js +92 -0
- package/src/reference-help.js +58 -8
- package/src/staging-check.js +62 -0
- package/src/typed-page-reference.js +86 -18
- package/src/typed-pages.js +165 -4
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +9 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +419 -36
- package/src/verbs/plan.js +11 -1
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/validate.js +47 -2
- package/src/workspace.js +58 -6
package/src/cli.js
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
SURFACE_STANDARD,
|
|
18
18
|
VERB_APPLY,
|
|
19
19
|
VERB_APPROVE,
|
|
20
|
+
VERB_AUTHORS_ADD,
|
|
21
|
+
VERB_AUTHORS_LIST,
|
|
20
22
|
VERB_DELIVERY_CHECK,
|
|
21
23
|
VERB_DEPLOY,
|
|
22
24
|
VERB_STAGING_REVIEW,
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
VERB_LOGOUT,
|
|
31
33
|
VERB_MEDIA_UPLOAD,
|
|
32
34
|
VERB_NAV_PUSH,
|
|
35
|
+
VERB_PAGES_META_SET,
|
|
33
36
|
VERB_PAGES_PUSH,
|
|
34
37
|
VERB_PLACES_SEARCH,
|
|
35
38
|
VERB_PLACES_SELECT,
|
|
@@ -109,8 +112,10 @@ const VERBS = Object.freeze([
|
|
|
109
112
|
+ "is either an offline fixture (manifest.fixture.json) or a pulled workspace. Every page source, the theme, "
|
|
110
113
|
+ "appearance, header, brand, footer, navigation, redirects, and forms are checked with the validators the "
|
|
111
114
|
+ "pushes use, and every problem is reported at once in error.problems (each with its file, code, and field) "
|
|
112
|
-
+ "rather than one per run. A pulled workspace is checked against the pages
|
|
113
|
-
+ "
|
|
115
|
+
+ "rather than one per run. A pulled workspace is checked offline against the pages (generated ones included, "
|
|
116
|
+
+ "so navigation to a section or tag page resolves), images, and baselines its manifests recorded at the pull. "
|
|
117
|
+
+ "A link whose address is only http(s):// plus its own text (left by older editor autolinking) is reported in "
|
|
118
|
+
+ "linkWarnings without refusing; pages push reports the same for the pages it sends. This proves local structure and semantics only. It does not prove "
|
|
114
119
|
+ "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
|
|
115
120
|
+ "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
|
|
116
121
|
+ `See '${CLI_BINARY_NAME} help fixture' for the fixture manifest contract and for the path of the complete `
|
|
@@ -228,7 +233,64 @@ const VERBS = Object.freeze([
|
|
|
228
233
|
+ "site's version under '.taproot-site-state/', and leaves you to either push the local source or delete it "
|
|
229
234
|
+ "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason. "
|
|
230
235
|
+ "First revisions adopted without a completed body comparison are counted in pages.revisionsRecordedWithoutBodyComparison "
|
|
231
|
-
+ "and announced on the human channel. New downloads and compared bodies are excluded."
|
|
236
|
+
+ "and announced on the human channel. New downloads and compared bodies are excluded. "
|
|
237
|
+
+ "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
|
|
238
|
+
+ "(template 'generated'): you edit its custom title, breadcrumb title, custom description and introduction, "
|
|
239
|
+
+ "and the manifest keeps the title and description the site reports.",
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: VERB_PAGES_META_SET,
|
|
243
|
+
tokens: ["pages", "meta", "set"],
|
|
244
|
+
offline: true,
|
|
245
|
+
// Offline, but it reads taproot-site.json and edits the workspace manifest.
|
|
246
|
+
readsLocalState: true,
|
|
247
|
+
writesWorkspace: true,
|
|
248
|
+
metaOptions: true,
|
|
249
|
+
positionals: "metaPagePath",
|
|
250
|
+
summary: "Set a tracked page's title, description or author in the workspace; 'pages push' sends it.",
|
|
251
|
+
note: "Names one page by its path ('/' for the homepage) and sets --title, --description, --author, or any of "
|
|
252
|
+
+ "them, in the workspace's record of that page. A .pm.json page keeps its title, description and author "
|
|
253
|
+
+ "there, so this is the supported way to change them; a Markdown page keeps them in its front matter, which "
|
|
254
|
+
+ "is edited instead. --author names a site author by handle or a member by email address (see 'authors "
|
|
255
|
+
+ "list'); it only fills a page that has no author, and a page that already has a different one is refused "
|
|
256
|
+
+ "(pages.author_conflict). A name missing from authors.json is only warned about (authors.json can be older "
|
|
257
|
+
+ "than the site's list); 'pages push' checks it against the site. --author \"\" drops an author that has not "
|
|
258
|
+
+ "been sent; it never removes the site's. "
|
|
259
|
+
+ "A generated page keeps its own in its source (data.customTitle and data.customDescription), which this "
|
|
260
|
+
+ "edits; an empty --title or --description there clears the custom value so the page uses its generated one. "
|
|
261
|
+
+ "A description longer than 160 characters is warned about (search results cut it off) and one longer than "
|
|
262
|
+
+ "1000 is refused. Nothing is sent: run 'pages push', then 'approve'.",
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: VERB_AUTHORS_LIST,
|
|
266
|
+
surface: SURFACE_STANDARD,
|
|
267
|
+
// Naming an author is part of writing a page, and the site gates both the
|
|
268
|
+
// list and the creation on the permission that creates pages (TR01196).
|
|
269
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
270
|
+
tokens: ["authors", "list"],
|
|
271
|
+
summary: "List the site authors and the members a page can be credited to; refreshes authors.json.",
|
|
272
|
+
note: "A page's author is a site author without a Taproot account (named by handle, such as jane-doe) or a site "
|
|
273
|
+
+ "member who can create pages (named by email address). Pages are authorless unless a source names one, and "
|
|
274
|
+
+ "an author already on a page is never replaced. The result lists authors { handle, displayName, hasEmail } "
|
|
275
|
+
+ "and members { email, displayName }, and rewrites authors.json, which the offline checks ('validate', "
|
|
276
|
+
+ "'pages meta set') read to warn about a name it lacks. authors.json holds members' email addresses: keep it "
|
|
277
|
+
+ "out of a public repository. "
|
|
278
|
+
+ "A site author's own address is private and is never listed or written.",
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: VERB_AUTHORS_ADD,
|
|
282
|
+
surface: SURFACE_STANDARD,
|
|
283
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
284
|
+
tokens: ["authors", "add"],
|
|
285
|
+
positionals: "authorHandle",
|
|
286
|
+
authorOptions: true,
|
|
287
|
+
summary: "Create a site author, someone a page can be credited to who has no Taproot account.",
|
|
288
|
+
note: "Give the handle (lowercase letters, digits and single hyphens, at most 64 characters; it never changes) "
|
|
289
|
+
+ "and --name, the name readers see on the byline. --email is optional: it is kept private, never published, "
|
|
290
|
+
+ "and lets the person be linked to a Taproot account later. Then credit pages to the author with "
|
|
291
|
+
+ "'author: <handle>' in a page's front matter or 'pages meta set <path> --author <handle>'. A handle the site "
|
|
292
|
+
+ "already has under another name is refused (authors.handle_taken); under the same name it succeeds with "
|
|
293
|
+
+ "existing: true. authors.json is updated with the new author.",
|
|
232
294
|
},
|
|
233
295
|
{
|
|
234
296
|
name: VERB_PAGES_PUSH,
|
|
@@ -240,6 +302,10 @@ const VERBS = Object.freeze([
|
|
|
240
302
|
positionals: "pagePaths",
|
|
241
303
|
note:
|
|
242
304
|
"Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
|
|
305
|
+
+ "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
|
|
306
|
+
+ "(content.link_autolinked) without refusing. "
|
|
307
|
+
+ "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
|
|
308
|
+
+ "meta set --author'); the pages created that way are listed in pages.authorless with how to credit them. "
|
|
243
309
|
+ "The homepage is recorded with an empty path, so address it as '/'. "
|
|
244
310
|
+ "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
|
|
245
311
|
+ "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
|
|
@@ -253,6 +319,10 @@ const VERBS = Object.freeze([
|
|
|
253
319
|
+ "error.problems with its file, code, and field. --dry-run does all of that against the live site, reports "
|
|
254
320
|
+ "which pages would be created, updated, or left unchanged, and sends nothing; it exits 1 when there are "
|
|
255
321
|
+ "problems. "
|
|
322
|
+
+ "A generated page's source is update-only: a push refuses to create one (pages.generated_create), move it "
|
|
323
|
+
+ "(pages.generated_move), or change what it is (pages.generated_identity). A generated page you did not edit "
|
|
324
|
+
+ "never blocks a whole-workspace push, and one the site no longer has is reported as pages.staleGeneratedSources "
|
|
325
|
+
+ "and skipped (naming its path refuses with pages.generated_create). "
|
|
256
326
|
+ "See 'taproot-site help page free-form' for the stable manifest and error contract.",
|
|
257
327
|
},
|
|
258
328
|
{
|
|
@@ -330,9 +400,9 @@ const VERBS = Object.freeze([
|
|
|
330
400
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
331
401
|
tokens: ["redirects", "check"],
|
|
332
402
|
summary: "Check the current redirect map through the authenticated staging edge.",
|
|
333
|
-
note: "Reports path, HTTP status and Location
|
|
334
|
-
+ "that carries it, so this reads the staged release. "
|
|
335
|
-
+ "The result includes a fresh single-use staging handoff; keep it private.",
|
|
403
|
+
note: "Reports each entry's path, HTTP status and Location in redirects.items, and redirects.verified, without "
|
|
404
|
+
+ "following redirects. A redirect is live only with the release that carries it, so this reads the staged release. "
|
|
405
|
+
+ "The result includes a fresh single-use staging handoff in stagingPreview.url; keep it private.",
|
|
336
406
|
},
|
|
337
407
|
{
|
|
338
408
|
name: VERB_REDIRECTS_PULL,
|
|
@@ -457,7 +527,10 @@ const VERBS = Object.freeze([
|
|
|
457
527
|
positionals: "pagePaths",
|
|
458
528
|
note: "Positional arguments narrow the selection to those page paths;"
|
|
459
529
|
+ " with none, every draft the workspace manifest tracks is staged."
|
|
460
|
-
+ " The homepage is recorded with an empty path, so address it as '/'."
|
|
530
|
+
+ " The homepage is recorded with an empty path, so address it as '/'."
|
|
531
|
+
+ " A named page with no pending draft (already approved, or published with no new draft) is listed in"
|
|
532
|
+
+ " skipped { total, items: [{ pageId, path, status, reason }] } and the rest are approved; a path that names"
|
|
533
|
+
+ " no page this workspace tracks refuses the whole call.",
|
|
461
534
|
},
|
|
462
535
|
{
|
|
463
536
|
name: VERB_DEPLOY,
|
|
@@ -479,7 +552,8 @@ const VERBS = Object.freeze([
|
|
|
479
552
|
note: "deploy --staging returns a single-use review handoff in stagingPreview.url, on a managed Docs site too, "
|
|
480
553
|
+ "where it stages settings only: open the URL once, then switch the site's theme toggle to review both "
|
|
481
554
|
+ "schemes before deploy --production. If no handoff could be minted, stagingPreview.reason names why and "
|
|
482
|
-
+ "stagingPreview.recovery the next step; 'staging review' mints another."
|
|
555
|
+
+ "stagingPreview.recovery the next step; 'staging review' mints another. On a Standard site the result also "
|
|
556
|
+
+ "carries the automatic redirect check: routeCheck and redirects (see 'help redirects').",
|
|
483
557
|
},
|
|
484
558
|
{
|
|
485
559
|
name: VERB_PREVIEW_PAGE,
|
|
@@ -682,6 +756,10 @@ function verbHelp(verb) {
|
|
|
682
756
|
? " <query...>"
|
|
683
757
|
: verb.positionals === "placeSelection"
|
|
684
758
|
? " <google-place-id> <session-token>"
|
|
759
|
+
: verb.positionals === "metaPagePath"
|
|
760
|
+
? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
|
|
761
|
+
: verb.positionals === "authorHandle"
|
|
762
|
+
? " <handle> --name <display-name> [--email <address>]"
|
|
685
763
|
: verb.positionals
|
|
686
764
|
? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
|
|
687
765
|
: "";
|
|
@@ -695,6 +773,15 @@ function verbHelp(verb) {
|
|
|
695
773
|
--production Promote the completed staging deployment to production.
|
|
696
774
|
--allow-failed-preview Explicitly override the matching candidate's failed preview.`
|
|
697
775
|
: "";
|
|
776
|
+
const metaOptions = verb.metaOptions
|
|
777
|
+
? `\n --title <text> The page's title.
|
|
778
|
+
--description <text> The page's description for search results and link previews; '' clears it.
|
|
779
|
+
--author <ref> Credit a page that has no author: a site author's handle or a member's email address.`
|
|
780
|
+
: "";
|
|
781
|
+
const authorOptions = verb.authorOptions
|
|
782
|
+
? `\n --name <text> The name readers see on the byline (required).
|
|
783
|
+
--email <address> A private address, kept to link the person to an account later (optional).`
|
|
784
|
+
: "";
|
|
698
785
|
const jsonOption = verb.json
|
|
699
786
|
? "\n --json Emit the stable JSON contract (operational output is always JSON)."
|
|
700
787
|
: "";
|
|
@@ -733,6 +820,9 @@ function verbHelp(verb) {
|
|
|
733
820
|
: selfContained
|
|
734
821
|
? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
|
|
735
822
|
+ `write.${upgradeGate}`
|
|
823
|
+
: verb.writesWorkspace
|
|
824
|
+
? "This verb edits only the local workspace: it reads the configuration, uses no credential, and makes no "
|
|
825
|
+
+ "network request."
|
|
736
826
|
: verb.offline
|
|
737
827
|
? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
|
|
738
828
|
+ `network request and no write.${upgradeGate}`
|
|
@@ -773,7 +863,7 @@ function verbHelp(verb) {
|
|
|
773
863
|
${verb.summary}
|
|
774
864
|
${boundary}
|
|
775
865
|
|
|
776
|
-
${options}${targetOption}${deliveryTargetOption}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
|
|
866
|
+
${options}${targetOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
|
|
777
867
|
`;
|
|
778
868
|
}
|
|
779
869
|
|
|
@@ -1005,6 +1095,45 @@ function parsePlanOption(arguments_, index) {
|
|
|
1005
1095
|
return candidate;
|
|
1006
1096
|
}
|
|
1007
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* The text after --title or --description. Either may be empty, which clears a
|
|
1100
|
+
* generated page's custom value; a page with its own title refuses an empty one.
|
|
1101
|
+
*/
|
|
1102
|
+
function parseMetaOption(arguments_, index, option) {
|
|
1103
|
+
const candidate = arguments_[index + 1];
|
|
1104
|
+
if (
|
|
1105
|
+
typeof candidate !== "string"
|
|
1106
|
+
|| Buffer.byteLength(candidate, "utf8") > 8 * 1024
|
|
1107
|
+
|| hasAsciiControl(candidate)
|
|
1108
|
+
) {
|
|
1109
|
+
throw usageError(
|
|
1110
|
+
"cli.meta_option",
|
|
1111
|
+
option === "--title"
|
|
1112
|
+
? "--title requires one printable title (an empty one clears a generated page's custom title)."
|
|
1113
|
+
: "--description requires one printable description (an empty one clears it).",
|
|
1114
|
+
{ field: option.slice(2) },
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
return candidate;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** The text after --author, --name (authors add) or --email: one printable value, bounded. */
|
|
1121
|
+
function parseAuthorOption(arguments_, index, option, { allowEmpty = false } = {}) {
|
|
1122
|
+
const candidate = arguments_[index + 1];
|
|
1123
|
+
if (
|
|
1124
|
+
typeof candidate !== "string"
|
|
1125
|
+
|| (candidate.length === 0 && !allowEmpty)
|
|
1126
|
+
|| Buffer.byteLength(candidate, "utf8") > 2 * 1024
|
|
1127
|
+
|| hasAsciiControl(candidate)
|
|
1128
|
+
|| candidate.startsWith("--")
|
|
1129
|
+
) {
|
|
1130
|
+
throw usageError("cli.author_option", `${option} requires exactly one printable value.`, {
|
|
1131
|
+
field: option.slice(2),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
return candidate;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1008
1137
|
function parseNameOption(arguments_, index) {
|
|
1009
1138
|
const candidate = arguments_[index + 1];
|
|
1010
1139
|
if (
|
|
@@ -1080,6 +1209,11 @@ function parseArguments(arguments_) {
|
|
|
1080
1209
|
let json = false;
|
|
1081
1210
|
let keyName;
|
|
1082
1211
|
let planHash;
|
|
1212
|
+
let metaTitle;
|
|
1213
|
+
let metaDescription;
|
|
1214
|
+
let metaAuthor;
|
|
1215
|
+
let authorName;
|
|
1216
|
+
let authorEmail;
|
|
1083
1217
|
const positionals = [];
|
|
1084
1218
|
for (let index = 0; index < rest.length; index += 1) {
|
|
1085
1219
|
const argument = rest[index];
|
|
@@ -1152,6 +1286,39 @@ function parseArguments(arguments_) {
|
|
|
1152
1286
|
index += 1;
|
|
1153
1287
|
continue;
|
|
1154
1288
|
}
|
|
1289
|
+
if (verb.metaOptions && (argument === "--title" || argument === "--description")) {
|
|
1290
|
+
const value = parseMetaOption(rest, index, argument);
|
|
1291
|
+
if (argument === "--title") {
|
|
1292
|
+
if (metaTitle !== undefined) throw usageError("cli.duplicate_option", "--title may be supplied only once.");
|
|
1293
|
+
metaTitle = value;
|
|
1294
|
+
} else {
|
|
1295
|
+
if (metaDescription !== undefined) {
|
|
1296
|
+
throw usageError("cli.duplicate_option", "--description may be supplied only once.");
|
|
1297
|
+
}
|
|
1298
|
+
metaDescription = value;
|
|
1299
|
+
}
|
|
1300
|
+
index += 1;
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
if (verb.metaOptions && argument === "--author") {
|
|
1304
|
+
if (metaAuthor !== undefined) throw usageError("cli.duplicate_option", "--author may be supplied only once.");
|
|
1305
|
+
// An empty value clears a pending author that was never sent.
|
|
1306
|
+
metaAuthor = parseAuthorOption(rest, index, argument, { allowEmpty: true });
|
|
1307
|
+
index += 1;
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (verb.authorOptions && (argument === "--name" || argument === "--email")) {
|
|
1311
|
+
const value = parseAuthorOption(rest, index, argument);
|
|
1312
|
+
if (argument === "--name") {
|
|
1313
|
+
if (authorName !== undefined) throw usageError("cli.duplicate_option", "--name may be supplied only once.");
|
|
1314
|
+
authorName = value;
|
|
1315
|
+
} else {
|
|
1316
|
+
if (authorEmail !== undefined) throw usageError("cli.duplicate_option", "--email may be supplied only once.");
|
|
1317
|
+
authorEmail = value;
|
|
1318
|
+
}
|
|
1319
|
+
index += 1;
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1155
1322
|
if (verb.keyName && argument === "--name") {
|
|
1156
1323
|
if (keyName !== undefined) throw usageError("cli.duplicate_option", "--name may be supplied only once.");
|
|
1157
1324
|
keyName = parseNameOption(rest, index);
|
|
@@ -1234,6 +1401,28 @@ function parseArguments(arguments_) {
|
|
|
1234
1401
|
{ field: "environmentSelector" },
|
|
1235
1402
|
);
|
|
1236
1403
|
}
|
|
1404
|
+
if (verb.positionals === "metaPagePath" && positionals.length !== 1) {
|
|
1405
|
+
throw usageError("pages.meta_path_invalid", "pages meta set names exactly one page path ('/' for the homepage).", {
|
|
1406
|
+
field: "pagePath",
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
if (verb.metaOptions && metaTitle === undefined && metaDescription === undefined && metaAuthor === undefined) {
|
|
1410
|
+
throw usageError(
|
|
1411
|
+
"pages.meta_nothing_to_set",
|
|
1412
|
+
"pages meta set needs --title, --description, --author, or any of them.",
|
|
1413
|
+
{ field: "title" },
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
if (verb.positionals === "authorHandle" && positionals.length !== 1) {
|
|
1417
|
+
throw usageError("authors.handle_missing", "authors add needs exactly one handle, such as jane-doe.", {
|
|
1418
|
+
field: "handle",
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
if (verb.authorOptions && authorName === undefined) {
|
|
1422
|
+
throw usageError("authors.name_missing", "authors add needs --name, the name readers see on the byline.", {
|
|
1423
|
+
field: "name",
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1237
1426
|
if (verb.planHash && planHash === undefined) {
|
|
1238
1427
|
throw usageError("apply.plan_required", "apply requires --plan with the planHash that 'plan' reported.", {
|
|
1239
1428
|
field: "planHash",
|
|
@@ -1281,12 +1470,18 @@ function parseArguments(arguments_) {
|
|
|
1281
1470
|
dryRun,
|
|
1282
1471
|
keyName,
|
|
1283
1472
|
...(planHash === undefined ? {} : { planHash }),
|
|
1473
|
+
...(metaTitle === undefined ? {} : { metaTitle }),
|
|
1474
|
+
...(metaDescription === undefined ? {} : { metaDescription }),
|
|
1475
|
+
...(metaAuthor === undefined ? {} : { metaAuthor }),
|
|
1476
|
+
...(authorName === undefined ? {} : { authorName }),
|
|
1477
|
+
...(authorEmail === undefined ? {} : { authorEmail }),
|
|
1284
1478
|
positionals: verb.positionals
|
|
1285
1479
|
? {
|
|
1286
1480
|
key: verb.positionals,
|
|
1287
1481
|
values: positionals,
|
|
1288
|
-
scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath"
|
|
1289
|
-
|| verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
|
|
1482
|
+
scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath" || verb.positionals === "metaPagePath"
|
|
1483
|
+
|| verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
|
|
1484
|
+
|| verb.positionals === "authorHandle",
|
|
1290
1485
|
}
|
|
1291
1486
|
: undefined,
|
|
1292
1487
|
};
|
|
@@ -1369,6 +1564,11 @@ export async function runCli({
|
|
|
1369
1564
|
...(parsed.deliveryUrl === undefined ? {} : { deliveryUrl: parsed.deliveryUrl }),
|
|
1370
1565
|
...(parsed.propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds: parsed.propagationWaitSeconds }),
|
|
1371
1566
|
...(parsed.browser === undefined ? {} : { browser: parsed.browser }),
|
|
1567
|
+
...(parsed.metaTitle === undefined ? {} : { metaTitle: parsed.metaTitle }),
|
|
1568
|
+
...(parsed.metaDescription === undefined ? {} : { metaDescription: parsed.metaDescription }),
|
|
1569
|
+
...(parsed.metaAuthor === undefined ? {} : { metaAuthor: parsed.metaAuthor }),
|
|
1570
|
+
...(parsed.authorName === undefined ? {} : { authorName: parsed.authorName }),
|
|
1571
|
+
...(parsed.authorEmail === undefined ? {} : { authorEmail: parsed.authorEmail }),
|
|
1372
1572
|
capabilities: parsed.capabilities,
|
|
1373
1573
|
surface: parsed.surface,
|
|
1374
1574
|
surfaceCapabilities: parsed.surfaceCapabilities,
|
package/src/constants.js
CHANGED
|
@@ -83,6 +83,9 @@ export const VERB_VALIDATE = "validate";
|
|
|
83
83
|
export const VERB_LOGIN = "login";
|
|
84
84
|
export const VERB_LOGOUT = "logout";
|
|
85
85
|
export const VERB_PAGES_PUSH = "pages push";
|
|
86
|
+
export const VERB_PAGES_META_SET = "pages meta set";
|
|
87
|
+
export const VERB_AUTHORS_LIST = "authors list";
|
|
88
|
+
export const VERB_AUTHORS_ADD = "authors add";
|
|
86
89
|
export const VERB_NAV_PUSH = "nav push";
|
|
87
90
|
export const VERB_REDIRECTS_CHECK = "redirects check";
|
|
88
91
|
export const VERB_REDIRECTS_PULL = "redirects pull";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { sanitizeDiagnostic } from "../errors.js";
|
|
2
|
+
import { boundedByBytes } from "../session.js";
|
|
3
|
+
import { contentDocuments } from "../typed-pages.js";
|
|
4
|
+
import { displayPagePath } from "../workspace.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Links the editor made on its own before TR01191: text such as "ASP.NET"
|
|
8
|
+
* whose address is just "http://ASP.NET". The address names the text and
|
|
9
|
+
* nothing else (no path, query or fragment), so the author almost certainly
|
|
10
|
+
* never chose it. Reported as warnings so an author can remove them.
|
|
11
|
+
*/
|
|
12
|
+
export const LINK_AUTOLINKED = "content.link_autolinked";
|
|
13
|
+
|
|
14
|
+
// Progress names this many; the JSON list is bounded by bytes instead.
|
|
15
|
+
const MAXIMUM_PROGRESS_LINES = 20;
|
|
16
|
+
|
|
17
|
+
export function autolinkedLinks(document) {
|
|
18
|
+
const found = [];
|
|
19
|
+
(function walk(node, depth) {
|
|
20
|
+
if (depth > 64 || node === null || typeof node !== "object" || !Array.isArray(node.content)) return;
|
|
21
|
+
// A link split across runs (part of it bold, say) is one link, so adjacent
|
|
22
|
+
// text sharing one href is read as one label.
|
|
23
|
+
let run;
|
|
24
|
+
const close = () => {
|
|
25
|
+
if (run !== undefined && looksAutolinked(run.text, run.href)) found.push(run);
|
|
26
|
+
run = undefined;
|
|
27
|
+
};
|
|
28
|
+
for (const child of node.content) {
|
|
29
|
+
const href = child?.type === "text" && typeof child.text === "string" && Array.isArray(child.marks)
|
|
30
|
+
? child.marks.find((mark) => mark?.type === "link")?.attrs?.href
|
|
31
|
+
: undefined;
|
|
32
|
+
if (typeof href !== "string") {
|
|
33
|
+
close();
|
|
34
|
+
walk(child, depth + 1);
|
|
35
|
+
} else if (run !== undefined && run.href === href) {
|
|
36
|
+
run.text += child.text;
|
|
37
|
+
} else {
|
|
38
|
+
close();
|
|
39
|
+
run = { text: child.text, href };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
close();
|
|
43
|
+
})(document, 0);
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function looksAutolinked(text, href) {
|
|
48
|
+
const label = text.trim();
|
|
49
|
+
if (label === "" || /^[a-z][a-z0-9+.-]*:/iu.test(label)) return false;
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(href);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
57
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.port !== "") {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const written = href.replace(/^https?:\/\//iu, "").replace(/\/$/u, "");
|
|
61
|
+
return written.toLowerCase() === label.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Links that look made by the editor's old autolinking ("ASP.NET" linked to
|
|
66
|
+
* http://ASP.NET), named so an author can remove them; never a refusal.
|
|
67
|
+
*/
|
|
68
|
+
export function autolinkWarnings(planned, onProgress) {
|
|
69
|
+
const items = [];
|
|
70
|
+
for (const page of planned ?? []) {
|
|
71
|
+
for (const link of pageDocuments(page.document).flatMap((document) => autolinkedLinks(document))) {
|
|
72
|
+
const item = {
|
|
73
|
+
code: LINK_AUTOLINKED,
|
|
74
|
+
file: page.file,
|
|
75
|
+
path: displayPagePath(page.pagePath),
|
|
76
|
+
text: [...sanitizeDiagnostic(link.text, "")].slice(0, 200).join(""),
|
|
77
|
+
href: [...sanitizeDiagnostic(link.href, "")].slice(0, 300).join(""),
|
|
78
|
+
};
|
|
79
|
+
if (items.length < MAXIMUM_PROGRESS_LINES) {
|
|
80
|
+
onProgress(
|
|
81
|
+
`Warning: '${item.text}' in ${item.file} links to ${item.href}, which looks made by autolinking; `
|
|
82
|
+
+ "remove the link if nobody chose it.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
items.push(item);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (items.length > MAXIMUM_PROGRESS_LINES) {
|
|
89
|
+
onProgress(`Warning: ${items.length - MAXIMUM_PROGRESS_LINES} more autolink-shaped link(s); see linkWarnings.`);
|
|
90
|
+
}
|
|
91
|
+
const reported = boundedByBytes(items, 8 * 1024);
|
|
92
|
+
return items.length === 0
|
|
93
|
+
? {}
|
|
94
|
+
: { linkWarnings: { total: items.length, items: reported.items, ...(reported.truncated ? { truncated: true } : {}) } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every ProseMirror document a page carries: a free-form body, or a typed
|
|
99
|
+
* page's body, introduction and recipe steps. A page that failed validation
|
|
100
|
+
* may have none.
|
|
101
|
+
*/
|
|
102
|
+
function pageDocuments(document) {
|
|
103
|
+
if (document === null || typeof document !== "object") return [];
|
|
104
|
+
try {
|
|
105
|
+
return contentDocuments(document).map((entry) => entry.doc);
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/fixture-init.js
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
MANIFEST_FILE_NAME,
|
|
16
16
|
MANIFEST_VERSION,
|
|
17
17
|
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
18
|
+
normalizePagePath,
|
|
19
|
+
PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
18
20
|
readManifest,
|
|
19
21
|
readMediaManifest,
|
|
20
22
|
readWorkspaceJson,
|
|
@@ -143,10 +145,14 @@ export async function initializeFixture(invocation, validate) {
|
|
|
143
145
|
status: entry.status,
|
|
144
146
|
templateType: entry.templateType,
|
|
145
147
|
hasDraft: entry.hasDraft,
|
|
146
|
-
isGenerated:
|
|
148
|
+
isGenerated: entry.isGenerated === true,
|
|
147
149
|
file,
|
|
148
150
|
sourceFormat: "prosemirror",
|
|
149
151
|
workspaceMode: "editable",
|
|
152
|
+
// What a generated page is, which its identity check needs offline.
|
|
153
|
+
...(entry.generated !== null && typeof entry.generated === "object" && !Array.isArray(entry.generated)
|
|
154
|
+
? { generated: entry.generated }
|
|
155
|
+
: {}),
|
|
150
156
|
}));
|
|
151
157
|
add(file, sanitize(page.document));
|
|
152
158
|
}
|
|
@@ -194,6 +200,44 @@ export async function initializeFixture(invocation, validate) {
|
|
|
194
200
|
),
|
|
195
201
|
);
|
|
196
202
|
}
|
|
203
|
+
// Navigation and footer links may point at pages the fixture has no source
|
|
204
|
+
// for, such as generated section pages; they travel as metadata-only entries
|
|
205
|
+
// so the fixture validates as the site does (TR01198).
|
|
206
|
+
const editablePageCount = pages.length;
|
|
207
|
+
const linkedResourceIds = new Set();
|
|
208
|
+
(function collect(value) {
|
|
209
|
+
if (Array.isArray(value)) value.forEach(collect);
|
|
210
|
+
else if (value && typeof value === "object") {
|
|
211
|
+
for (const [key, child] of Object.entries(value)) {
|
|
212
|
+
if ((key === "resourceId" || key === "pageResourceId") && typeof child === "string") {
|
|
213
|
+
linkedResourceIds.add(child);
|
|
214
|
+
} else collect(child);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
})([...documents.values()]);
|
|
218
|
+
for (const entry of source.pages) {
|
|
219
|
+
if (entry.workspaceMode !== PAGE_WORKSPACE_MODE_METADATA_ONLY) continue;
|
|
220
|
+
if (!isCanonicalUuid(entry.pageId) || !isCanonicalUuid(entry.resourceId)) continue;
|
|
221
|
+
if (!linkedResourceIds.has(identity(entry.resourceId))) continue;
|
|
222
|
+
const linkedPath = normalizePagePath(entry.path);
|
|
223
|
+
if (linkedPath === undefined || linkedPath === "") {
|
|
224
|
+
refuse(
|
|
225
|
+
`A page that is linked to has no usable path of its own (${JSON.stringify(entry.path ?? null)}); pull again.`,
|
|
226
|
+
"pages",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
pages.push(sanitize({
|
|
230
|
+
pageId: entry.pageId,
|
|
231
|
+
resourceId: entry.resourceId,
|
|
232
|
+
path: linkedPath,
|
|
233
|
+
title: typeof entry.title === "string" ? entry.title : "",
|
|
234
|
+
status: entry.status,
|
|
235
|
+
templateType: entry.templateType,
|
|
236
|
+
hasDraft: entry.hasDraft === true,
|
|
237
|
+
isGenerated: entry.isGenerated === true,
|
|
238
|
+
workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
197
241
|
const redirects = documents.get("redirects.json");
|
|
198
242
|
// A fixture hash is a local placeholder, not authority to mutate the original site.
|
|
199
243
|
const revision = createHash("sha256").update(JSON.stringify(redirects.entries)).digest("hex");
|
|
@@ -242,10 +286,18 @@ export async function initializeFixture(invocation, validate) {
|
|
|
242
286
|
try {
|
|
243
287
|
for (const [file, value] of documents) await writeWorkspaceJson(destination, file, value);
|
|
244
288
|
const result = await validate({ ...invocation, init: false, cwd, fixturePath: destination });
|
|
245
|
-
invocation.onProgress?.(
|
|
289
|
+
invocation.onProgress?.(
|
|
290
|
+
`Initialized a sanitized fixture with ${editablePageCount} editable page(s)`
|
|
291
|
+
+ `${pages.length > editablePageCount ? ` and ${pages.length - editablePageCount} linked page(s)` : ""}.`,
|
|
292
|
+
);
|
|
246
293
|
return {
|
|
247
294
|
...result,
|
|
248
|
-
initialized: {
|
|
295
|
+
initialized: {
|
|
296
|
+
directory: destination,
|
|
297
|
+
pages: editablePageCount,
|
|
298
|
+
linkedPages: pages.length - editablePageCount,
|
|
299
|
+
skippedPages: source.pages.length - pages.length,
|
|
300
|
+
},
|
|
249
301
|
};
|
|
250
302
|
} catch (error) {
|
|
251
303
|
await rm(destination, { recursive: true, force: true });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
2
|
+
import { boundedByBytes } from "./session.js";
|
|
3
|
+
import { displayPagePath } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A page description is what search results and link previews show. Past about
|
|
7
|
+
* 160 characters search engines cut it off, so it is warned about; past 1000 it
|
|
8
|
+
* is refused, here and by the API (TR01194).
|
|
9
|
+
*/
|
|
10
|
+
export const DESCRIPTION_WARN_LENGTH = 160;
|
|
11
|
+
export const DESCRIPTION_MAXIMUM_LENGTH = 1000;
|
|
12
|
+
export const PAGE_DESCRIPTION_LONG = "pages.description_long";
|
|
13
|
+
|
|
14
|
+
const length = (value) => [...value].length;
|
|
15
|
+
|
|
16
|
+
/** Refuses a description the API would refuse. */
|
|
17
|
+
export function requireDescriptionLength(description, field = "description") {
|
|
18
|
+
if (typeof description === "string" && length(description) > DESCRIPTION_MAXIMUM_LENGTH) {
|
|
19
|
+
throw new SiteAuthoringError(
|
|
20
|
+
"pages.description_too_long",
|
|
21
|
+
`The description is ${length(description)} characters; it may be at most ${DESCRIPTION_MAXIMUM_LENGTH} `
|
|
22
|
+
+ `(search results show about ${DESCRIPTION_WARN_LENGTH}).`,
|
|
23
|
+
{ field },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Warnings for descriptions search results will cut off, named per page. */
|
|
29
|
+
export function descriptionWarnings(pages, onProgress) {
|
|
30
|
+
const items = [];
|
|
31
|
+
for (const page of pages ?? []) {
|
|
32
|
+
const description = page.declaredDescription ?? page.description;
|
|
33
|
+
if (typeof description !== "string" || length(description) <= DESCRIPTION_WARN_LENGTH) continue;
|
|
34
|
+
const item = {
|
|
35
|
+
code: PAGE_DESCRIPTION_LONG,
|
|
36
|
+
file: page.file,
|
|
37
|
+
path: displayPagePath(page.pagePath ?? page.path),
|
|
38
|
+
length: length(description),
|
|
39
|
+
};
|
|
40
|
+
onProgress(
|
|
41
|
+
`Warning: ${item.file}'s description is ${item.length} characters; search results show about `
|
|
42
|
+
+ `${DESCRIPTION_WARN_LENGTH}.`,
|
|
43
|
+
);
|
|
44
|
+
items.push(item);
|
|
45
|
+
}
|
|
46
|
+
const reported = boundedByBytes(items, 8 * 1024);
|
|
47
|
+
return items.length === 0
|
|
48
|
+
? {}
|
|
49
|
+
: {
|
|
50
|
+
descriptionWarnings: {
|
|
51
|
+
total: items.length,
|
|
52
|
+
items: reported.items,
|
|
53
|
+
...(reported.truncated ? { truncated: true } : {}),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const PAGE_AUTHOR_UNVERIFIED = "pages.author_unverified";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Warnings for authors an offline run could not confirm (TR01196). The only
|
|
62
|
+
* evidence offline is `authors.json`, which is as old as the last `pull` or
|
|
63
|
+
* `authors list`, so a name missing from it may simply be newer; it is flagged
|
|
64
|
+
* rather than refused, and an online run settles it against the site.
|
|
65
|
+
*/
|
|
66
|
+
export function authorWarnings(pages, onProgress) {
|
|
67
|
+
const items = [];
|
|
68
|
+
for (const page of pages ?? []) {
|
|
69
|
+
if (page.authorUnverified !== true) continue;
|
|
70
|
+
const item = {
|
|
71
|
+
code: PAGE_AUTHOR_UNVERIFIED,
|
|
72
|
+
file: page.file,
|
|
73
|
+
path: displayPagePath(page.pagePath ?? page.path),
|
|
74
|
+
author: page.author,
|
|
75
|
+
};
|
|
76
|
+
onProgress(
|
|
77
|
+
`Warning: ${item.file} names author '${item.author}', who is not in authors.json; it may have been added `
|
|
78
|
+
+ "since. 'taproot-site authors list' refreshes it, and 'pages push' checks the name against the site.",
|
|
79
|
+
);
|
|
80
|
+
items.push(item);
|
|
81
|
+
}
|
|
82
|
+
const reported = boundedByBytes(items, 8 * 1024);
|
|
83
|
+
return items.length === 0
|
|
84
|
+
? {}
|
|
85
|
+
: {
|
|
86
|
+
authorWarnings: {
|
|
87
|
+
total: items.length,
|
|
88
|
+
items: reported.items,
|
|
89
|
+
...(reported.truncated ? { truncated: true } : {}),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|