@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 CHANGED
@@ -124,8 +124,12 @@ with no credential, no network, and no write: every page source (metadata,
124
124
  Markdown conversion, media references, content vocabulary, section contexts),
125
125
  page paths and templates against the pages the pull recorded, navigation,
126
126
  redirects, forms, and the complete theme, appearance, header, brand, and
127
- footer. It reports every problem at once in `error.problems`, each with its
128
- file, code, and field, instead of one per run.
127
+ footer. Navigation to a generated page (a section, tag or place page) resolves
128
+ against the pages the pull recorded. It reports every problem at once in
129
+ `error.problems`, each with its file, code, and field, instead of one per run.
130
+ Warnings never refuse: `linkWarnings` names a link whose address is only
131
+ `http(s)://` plus its own text (`content.link_autolinked`, left by older editor
132
+ autolinking), and `pages push` reports the same for the pages it sends.
129
133
 
130
134
  `taproot-site validate <fixture-directory>` checks a complete offline fixture
131
135
  the same way: a directory laid out like a
@@ -360,6 +364,92 @@ it is created: a source for another template is refused with
360
364
  `pages.template_immutable`. `taproot-site help page <type>` gives each
361
365
  template's fields and one worked example.
362
366
 
367
+ A `.pm.json` page's title, path, description and author live in the workspace
368
+ manifest rather than the file. `pages meta set <path> --title <text>
369
+ --description <text>` changes the title or description there (a Markdown page
370
+ keeps them in its front matter; `--author` is described under *Page authors*); `pull` keeps that edit until `pages push`
371
+ sends it, and a site edit to the same page is a `pages.pull_conflict`. A
372
+ description over 160 characters is a warning (`descriptionWarnings`), because
373
+ search results cut it off; over 1000 is refused with
374
+ `pages.description_too_long`, by the CLI and by Taproot.
375
+
376
+ ### Page authors
377
+
378
+ Pages are authorless by default: a page you create with the CLI publishes with
379
+ no byline unless you name an author, and `pages push` lists the pages it created
380
+ that way in `pages.authorless`, with how to credit them. An author is one of two
381
+ kinds of person, named by one string:
382
+
383
+ - a **site author without a Taproot account** — one of an imported blog's
384
+ authors, say — named by handle (`jane-doe`: lowercase letters, digits and
385
+ single hyphens, at most 64 characters, fixed when the author is created). A
386
+ site author's name appears on the byline and in the page's structured data;
387
+ there is no profile link or follow button, because there is no account;
388
+ - a **site member who can create pages**, named by email address.
389
+
390
+ `authors list` prints both and rewrites `authors.json` (`pull` writes it too).
391
+ `authors add jane-doe --name "Jane Doe" [--email jane@example.com]` creates a
392
+ site author; `--email` is kept private (never published, never listed) so the
393
+ person can be linked to a Taproot account later. Adding a handle the site
394
+ already has under the same name succeeds and reports `existing: true`, so a
395
+ script can repeat itself; a different name for a taken handle is refused
396
+ (`authors.handle_taken`), naming the one the site has. `authors.json` holds the
397
+ members' email addresses, so keep it out of a public repository.
398
+
399
+ A page names its author with `author: jane-doe` in its front matter (any
400
+ template), or, for a `.pm.json` page, `pages meta set <path> --author jane-doe`.
401
+ Only the CLI assigns authors, and only to a page that has none; people editing
402
+ in the app are never asked to name one. `authors.json` is as old as the last
403
+ `pull` or `authors list`, so a name missing from it is not proof the person does
404
+ not exist. `validate` and `pages meta set` run offline: they refuse text that
405
+ is neither a handle nor an address (`pages.author_invalid`) and otherwise only
406
+ warn (`pages.author_unverified`, in `authorWarnings`) about a name `authors.json`
407
+ lacks. `pages push` and `plan` settle it online: on a miss they read the site's
408
+ current list once (`pages push` rewrites `authors.json` with it) and refuse
409
+ (`pages.author_unknown`) only a name the site really lacks. A member who cannot
410
+ create pages is `pages.author_unknown` as well, with the same wording.
411
+
412
+ The CLI only fills a page that has no author: a page that already has a
413
+ different one is refused (`pages.author_conflict`), by the CLI from what the
414
+ last pull recorded and by Taproot, and naming the author a page already has
415
+ changes nothing. The refusal says how to settle it: for a `.pm.json` page,
416
+ `pages meta set <path> --author <the site's author>` matches the site, and
417
+ `pages meta set <path> --author ""` drops an author that was never sent (it
418
+ never asks the site to remove one, and is refused once the site holds an
419
+ author); for a Markdown page, fix the `author:` line. `pull` keeps a pending
420
+ author the site now disagrees with and says so. The author is recorded on the
421
+ page's manifest entry (`author`, and `baseline.author` for what the site
422
+ held), so a re-push never resends it. The site names a page's author in
423
+ listings only while that person can still be named: a former member's page, or
424
+ a member who can no longer create pages, reports none, and a push that names
425
+ someone else on it is refused as a conflict.
426
+
427
+ Pages Taproot generates from the site's content — a tag, the tags index, a
428
+ published year or month, a place archive, a folder — are update-only sources.
429
+ `pull` writes each as `pages/<name>.pm.json` with `"template": "generated"`:
430
+ `data` holds what the page is (`kind`, `tagId`, `year`, `month`, `countryCode`,
431
+ `regionCode`, `citySlug`, `categorySlug`, which you leave as pulled) and the four
432
+ things that are yours to author: `customTitle`, `breadcrumbTitle`,
433
+ `customDescription` and `introductionBody`. An empty string means "use the
434
+ generated default"; the description then falls back to the introduction's first
435
+ paragraph, else the system default. The manifest keeps the title and description
436
+ the site reports, for reference. `pages meta set <path> --title/--description`
437
+ edits `customTitle` and `customDescription` in the source (an empty value
438
+ clears it), and `pages push` sends the page back as an unapproved draft. The
439
+ title and description the site stores are derived from `customTitle`,
440
+ `customDescription` and `introductionBody`, so what a push sends for them is
441
+ ignored. A push refuses to create a generated page (`pages.generated_create`),
442
+ move one (`pages.generated_move`) or change its kind or identity
443
+ (`pages.generated_identity`, also when the manifest has no record of what the
444
+ file was pulled as: run `pull`); there is no Markdown form
445
+ (`pages.generated_markdown`). A generated page you did not edit never blocks a
446
+ push of the whole workspace, even when Taproot moved it since the pull (a tag was
447
+ renamed). If the site no longer has the page (its tag, month or folder is gone),
448
+ a whole-workspace push reports the file under `pages.staleGeneratedSources` and
449
+ sends nothing for it, and `pull` says so; delete the file. Naming that path in
450
+ `pages push <page-path>` is refused with `pages.generated_create`. A description over 160 characters is warned about,
451
+ since search results cut it off, and one over 1000 is refused.
452
+
363
453
  `pages push [page-path...]` validates and sends the selected pages, or every
364
454
  workspace page when none is named; the homepage is addressed as `/`. Every
365
455
  selected page is checked before anything is sent, and a refusal lists every
@@ -557,6 +647,25 @@ targets are refused (`content.link_rel`, `content.link_target`). The editor's
557
647
  old default, `noopener noreferrer nofollow`, reads as no choice. See
558
648
  `taproot-site help page free-form`.
559
649
 
650
+ ### Authoring feedback in 0.16.0
651
+
652
+ - `pages meta set <path> --title/--description/--author` edits a `.pm.json`
653
+ page's title, description or author; `pull` keeps the edit until `pages push`
654
+ sends it. Descriptions are warned about over 160 characters and refused over
655
+ 1000, here and by Taproot.
656
+ - Generated pages (tags, archives, places, folders) pull as update-only sources
657
+ whose custom title, breadcrumb, description and introduction you can edit.
658
+ - Pages are authorless by default. `authors list`, `authors add` and
659
+ `authors.json` name a member who can create pages or a site author without a
660
+ Taproot account; `author:` front matter or `pages meta set --author` credits
661
+ one on a page that has none, and an author is never replaced from the CLI.
662
+ - `redirects check` and `deploy --staging` report `routeCheck`, `redirects` and
663
+ `stagingPreview` at the top level; result paths spell the home page `/`;
664
+ `approve` skips a page with nothing to approve instead of failing the batch.
665
+ - `validate` checks a real pulled workspace offline (`validate --init` keeps the
666
+ generated pages navigation links to), and `validate` and `pages push` warn on
667
+ links that look made by autolinking (`content.link_autolinked`).
668
+
560
669
  ### Staging verification and deployment evidence
561
670
 
562
671
  `deploy --staging` selects the same changed pages, settings groups and navigation
@@ -579,13 +688,15 @@ only error class and known page/node/attribute labels, never renderer messages.
579
688
  Staging deploys check the current redirect map through the real staging edge.
580
689
  Run `taproot-site redirects check` to repeat once the deployment has completed; a redirect is a file inside the release, so it is live exactly when its release is served.
581
690
  The Node CLI consumes a separate handoff, retains its short staging cookie in
582
- memory, and requests each map path without following redirects. Results report
583
- path, HTTP status and Location; `verified` requires all entries to match and the
691
+ memory, and requests each map path without following redirects. Both
692
+ `redirects check` and `deploy --staging` report `routeCheck` and `redirects` at
693
+ the top level of their result: `redirects.items` lists each path, HTTP status
694
+ and Location, and `redirects.verified` requires all entries to match and the
584
695
  map revision to remain unchanged. A mismatch is evidence to investigate, not a
585
696
  failed deployment. Checks describe the current map, not a historical release.
586
697
  Requests have a 90-second deadline with eight concurrent requests; large JSON
587
- lists have explicit truncation, prioritizing failures. Human output reports each
588
- entry. A gate/transport failure never counts as verified.
698
+ lists have explicit truncation, prioritizing failures. Progress output reports
699
+ each entry. A gate/transport failure never counts as verified.
589
700
 
590
701
  `stagingPreview.url` is a fresh single-use two-minute handoff minted **after**
591
702
  checks. Opening it consumes it and establishes a five-minute staging cookie.
@@ -640,8 +751,10 @@ The destination must be new, outside the source workspace, and its parent must e
640
751
  to select another source configuration. Initialization needs no credential or
641
752
  network. It exports editable pages of every template as workspace documents with navigation,
642
753
  redirects, settings, and version-7 appearance/footer metadata, validates the
643
- result, and leaves the source unchanged. Excluded metadata-only pages are
644
- counted; unresolved references to them fail validation.
754
+ result, and leaves the source unchanged. A metadata-only page that navigation,
755
+ the footer or a page links to, such as a generated section page, is kept as a
756
+ metadata-only entry (`initialized.linkedPages`); other metadata-only pages are
757
+ left out (`initialized.skippedPages`).
645
758
 
646
759
  Exported UUIDs and HTTP(S) origins are deterministic fixture values under
647
760
  `example.test`; URL credentials, query strings and fragments are removed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/api.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  // would turn a successful mint into an orphaned key.
24
24
  import { KEY_PREFIX } from "./credentials.js";
25
25
  import { isCanonicalUuid, normalizePreviewDiagnostic, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
26
+ import { normalizeAuthorListing, parseAuthorReference } from "./authors-contract.js";
26
27
  import { docsPublicationModeFromWire, siteKindFromWire, surfaceFromWire } from "./surface.js";
27
28
  import {
28
29
  DEFAULT_REDIRECT_STATUS,
@@ -499,6 +500,14 @@ export function normalizePageSummary(value) {
499
500
  // The page's calendar date, empty when it has none. It is not part of
500
501
  // `bodyRevision`, so a typed page's push guard compares it separately.
501
502
  displayDate: typeof summary.displayDate === "string" ? summary.displayDate : "",
503
+ // The name the page's author goes by in a write: a site author's handle or
504
+ // a member's email address, folded. Empty for a page with no author, for a
505
+ // former member's page, and against a Taproot that predates authors
506
+ // (TR01196). Never the author's display name, which is not a reference.
507
+ authorRef: typeof summary.authorRef === "string" ? parseAuthorReference(summary.authorRef)?.value ?? "" : "",
508
+ // Whether the page credits anyone at all, which an empty authorRef cannot
509
+ // say: it is also empty for an author this credential may not name.
510
+ hasAuthor: typeof summary.authorDisplayName === "string" && summary.authorDisplayName.trim() !== "",
502
511
  };
503
512
  }
504
513
 
@@ -545,6 +554,62 @@ export async function updatePage(client, pageId, body) {
545
554
  );
546
555
  }
547
556
 
557
+ /**
558
+ * Gives the site's two author refusals the stable codes the CLI documents
559
+ * (TR01196). The site answers an unknown name and one that cannot be named with
560
+ * the same field and wording on purpose; this keeps them one code too.
561
+ */
562
+ export function translateAuthorRefusal(error, file) {
563
+ if (!(error instanceof ApiError)) return error;
564
+ for (const [field, code, fallback] of [
565
+ ["AuthorUnknown", "pages.author_unknown", "No site author or member who can create pages has that name."],
566
+ ["AuthorConflict", "pages.author_conflict", "The page already has a different author, which is never replaced."],
567
+ ]) {
568
+ if (error.hasField(field)) {
569
+ const detail = sanitizeDiagnostic(error.descriptionFor(field) ?? fallback, fallback);
570
+ // The workspace only learns who the site credits from a pull, and a
571
+ // conflict means someone gave the page an author after the last one.
572
+ const hint = field === "AuthorConflict"
573
+ ? " Run 'taproot-site pull' to learn who the site credits, then match it with 'pages meta set <path> --author "
574
+ + "<author>' (a .pm.json page) or the author: front matter line (Markdown)."
575
+ : "";
576
+ return new SiteAuthoringError(code, `'${file}': ${detail}${hint}`, { field: file });
577
+ }
578
+ }
579
+ return error;
580
+ }
581
+
582
+ /**
583
+ * Reads who a page can be credited to: the site's authors without an account
584
+ * and the members who can create pages (TR01196).
585
+ */
586
+ export async function listSiteAuthors(client, siteId) {
587
+ return normalizeAuthorListing(
588
+ requireObject(await client.request(sitePath(siteId, "authors")), "api.authors_contract", "site authors"),
589
+ );
590
+ }
591
+
592
+ /** Creates a site author. The site folds the handle and the address. */
593
+ export async function createSiteAuthor(client, siteId, { handle, displayName, email }) {
594
+ const created = requireObject(
595
+ await client.request(sitePath(siteId, "authors"), {
596
+ method: "POST",
597
+ body: { handle, displayName, ...(email === undefined ? {} : { email }) },
598
+ }),
599
+ "api.authors_contract",
600
+ "site author",
601
+ );
602
+ const listing = normalizeAuthorListing({ authors: [created], members: [] });
603
+ if (listing.authors.length !== 1) {
604
+ throw new SiteAuthoringError(
605
+ "api.authors_contract",
606
+ "Taproot returned a site author this CLI could not read.",
607
+ { field: "handle" },
608
+ );
609
+ }
610
+ return listing.authors[0];
611
+ }
612
+
548
613
  export async function publishDrafts(client, pageIds) {
549
614
  const response = requireObject(
550
615
  await client.request("v1/pages/publish_drafts", { method: "POST", body: { pageIds } }),
@@ -0,0 +1,170 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+
3
+ /**
4
+ * Who a page can be credited to (TR01196).
5
+ *
6
+ * A page's author is one of two kinds of person, named by one string:
7
+ *
8
+ * - a site member who can create pages, by email address; or
9
+ * - a site author without a Taproot account, by handle. Handles are a
10
+ * lowercase slug the site fixes when the author is created, so a page keeps
11
+ * pointing at the same person if their display name changes.
12
+ *
13
+ * The two spellings cannot collide: a handle never contains '@'. The site
14
+ * folds case on both (an address is stored lowercase, a handle is lowercase),
15
+ * so this module does too, and compares only folded values.
16
+ */
17
+
18
+ export const AUTHORS_FILE_NAME = "authors.json";
19
+
20
+ export const AUTHOR_HANDLE_MAXIMUM_LENGTH = 64;
21
+ export const AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH = 256;
22
+ export const AUTHOR_EMAIL_MAXIMUM_LENGTH = 320;
23
+
24
+ const HANDLE = /^[a-z0-9]+(-[a-z0-9]+)*$/u;
25
+ // Deliberately loose: the site canonicalizes and decides. This only tells an
26
+ // address from a handle and refuses text that cannot be one.
27
+ const EMAIL = /^[^\s@<>()]+@[^\s@<>()]+\.[^\s@<>()]+$/u;
28
+
29
+ export const AUTHOR_KIND_HANDLE = "handle";
30
+ export const AUTHOR_KIND_EMAIL = "email";
31
+
32
+ /** The folded handle, or undefined when the text is not a handle. */
33
+ export function normalizeAuthorHandle(value) {
34
+ if (typeof value !== "string") return undefined;
35
+ const handle = value.trim().toLowerCase();
36
+ return handle.length > 0 && handle.length <= AUTHOR_HANDLE_MAXIMUM_LENGTH && HANDLE.test(handle)
37
+ ? handle
38
+ : undefined;
39
+ }
40
+
41
+ /** The folded address, or undefined when the text is not an email address. */
42
+ export function normalizeAuthorEmail(value) {
43
+ if (typeof value !== "string") return undefined;
44
+ const email = value.trim().toLowerCase();
45
+ return email.length <= AUTHOR_EMAIL_MAXIMUM_LENGTH && EMAIL.test(email) ? email : undefined;
46
+ }
47
+
48
+ /**
49
+ * What an `author` reference names: `{ kind, value }` with the folded value, or
50
+ * undefined for text that is neither a handle nor an email address.
51
+ */
52
+ export function parseAuthorReference(value) {
53
+ if (typeof value !== "string") return undefined;
54
+ if (value.includes("@")) {
55
+ const email = normalizeAuthorEmail(value);
56
+ return email === undefined ? undefined : { kind: AUTHOR_KIND_EMAIL, value: email };
57
+ }
58
+ const handle = normalizeAuthorHandle(value);
59
+ return handle === undefined ? undefined : { kind: AUTHOR_KIND_HANDLE, value: handle };
60
+ }
61
+
62
+ /** The folded reference, or a refusal that names where it was written. */
63
+ export function requireAuthorReference(value, field, code = "pages.author_invalid") {
64
+ const reference = parseAuthorReference(value);
65
+ if (reference === undefined) {
66
+ throw new SiteAuthoringError(
67
+ code,
68
+ `'${typeof value === "string" ? value.slice(0, 80) : ""}' is not an author reference. Name a site author by `
69
+ + "handle (lowercase letters, digits and hyphens, such as jane-doe) or a site member by email address.",
70
+ { field },
71
+ );
72
+ }
73
+ return reference.value;
74
+ }
75
+
76
+ function boundedText(value, maximum) {
77
+ return typeof value === "string" && value.length <= maximum ? value : undefined;
78
+ }
79
+
80
+ /**
81
+ * Reads the site's author listing, refusing a response that does not have the
82
+ * shape this CLI relies on. A member's address is only present for a caller
83
+ * allowed to see it; the CLI's credential is, so an absent one is dropped.
84
+ */
85
+ export function normalizeAuthorListing(value) {
86
+ const listing = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
87
+ const authors = [];
88
+ for (const raw of Array.isArray(listing.authors) ? listing.authors : []) {
89
+ const handle = normalizeAuthorHandle(raw?.handle);
90
+ if (handle === undefined) continue;
91
+ authors.push({
92
+ handle,
93
+ displayName: boundedText(raw.displayName, AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH) ?? "",
94
+ hasEmail: raw.hasEmail === true || (typeof raw.email === "string" && raw.email !== ""),
95
+ });
96
+ }
97
+ const members = [];
98
+ for (const raw of Array.isArray(listing.members) ? listing.members : []) {
99
+ const email = normalizeAuthorEmail(raw?.email);
100
+ if (email === undefined) continue;
101
+ members.push({
102
+ email,
103
+ displayName: boundedText(raw.displayName, AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH) ?? "",
104
+ });
105
+ }
106
+ return { authors, members };
107
+ }
108
+
109
+ /**
110
+ * The workspace file `pull` and `authors list` write. Site authors carry no
111
+ * address: it is private to the site and nothing local needs it. Members carry
112
+ * theirs, because that is how a page names them.
113
+ */
114
+ export function projectAuthorsForWorkspace(siteId, listing) {
115
+ return {
116
+ siteId,
117
+ authors: listing.authors.map(({ handle, displayName }) => ({ handle, displayName })),
118
+ members: listing.members.map(({ email, displayName }) => ({ email, displayName })),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Reads `authors.json` back. Anything unreadable or bound to another site is
124
+ * treated as absent: it can only ever make a reference unprovable offline,
125
+ * never turn a valid one into a refusal.
126
+ */
127
+ export function readAuthorsDocument(value, siteId) {
128
+ if (value === null || typeof value !== "object" || Array.isArray(value) || value.siteId !== siteId) {
129
+ return undefined;
130
+ }
131
+ const handles = new Set();
132
+ for (const author of Array.isArray(value.authors) ? value.authors : []) {
133
+ const handle = normalizeAuthorHandle(author?.handle);
134
+ if (handle !== undefined) handles.add(handle);
135
+ }
136
+ const emails = new Set();
137
+ for (const member of Array.isArray(value.members) ? value.members : []) {
138
+ const email = normalizeAuthorEmail(member?.email);
139
+ if (email !== undefined) emails.add(email);
140
+ }
141
+ return { handles, emails };
142
+ }
143
+
144
+ /**
145
+ * Whether a folded reference names someone the recorded listing knows, or
146
+ * `undefined` when there is no listing to ask.
147
+ */
148
+ export function authorKnown(authors, reference) {
149
+ if (authors === undefined) return undefined;
150
+ const parsed = parseAuthorReference(reference);
151
+ if (parsed === undefined) return false;
152
+ return parsed.kind === AUTHOR_KIND_EMAIL ? authors.emails.has(parsed.value) : authors.handles.has(parsed.value);
153
+ }
154
+
155
+ /**
156
+ * The refusal text for a source that names a different author than the site
157
+ * holds. The way out depends on where the author is written: a `.pm.json`
158
+ * page keeps it in the workspace manifest, edited with `pages meta set`; a
159
+ * Markdown page keeps it in its front matter (TR01196).
160
+ */
161
+ export function authorConflictMessage({ shown, file, held, wanted, markdown }) {
162
+ const lead = `Page '${shown}' already has the author '${held}'`
163
+ + (wanted === undefined ? "" : `, and '${file}' names '${wanted}'`)
164
+ + ". An author is never replaced from the CLI";
165
+ return markdown
166
+ ? `${lead}: set 'author: ${held}' in the front matter, or remove the line, to match the site. To credit someone `
167
+ + "else, change it in the app."
168
+ : `${lead}: run 'taproot-site pages meta set ${shown} --author ${held}' to match the site. To credit someone `
169
+ + "else, change it in the app.";
170
+ }