@taprootio/site-authoring 0.14.1 → 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 +181 -16
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- 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 +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +105 -10
package/src/workspace.js
CHANGED
|
@@ -4,7 +4,19 @@ import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { atomicWriteFile } from "./atomic-file.js";
|
|
7
|
+
import { parseAuthorReference } from "./authors-contract.js";
|
|
7
8
|
import { hasAsciiControl, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
9
|
+
import {
|
|
10
|
+
noteEntry,
|
|
11
|
+
noteExists,
|
|
12
|
+
noteFileDeleted,
|
|
13
|
+
noteFileMissing,
|
|
14
|
+
noteFileRead,
|
|
15
|
+
noteFileWritten,
|
|
16
|
+
noteStreamMissing,
|
|
17
|
+
noteStreamOpened,
|
|
18
|
+
noteWalk,
|
|
19
|
+
} from "./workspace-ledger.js";
|
|
8
20
|
|
|
9
21
|
/**
|
|
10
22
|
* The local workspace: what `pull` writes, what the push verbs read back, and
|
|
@@ -279,6 +291,9 @@ export const WORKSPACE_LIMITS = Object.freeze({
|
|
|
279
291
|
// untouched. Generous for any real authoring workspace, and an explicit
|
|
280
292
|
// refusal rather than an unbounded buffer for one that is not.
|
|
281
293
|
pulledBodyBytes: 64 * 1024 * 1024,
|
|
294
|
+
// The site's authors and the members a page can name: a few hundred people
|
|
295
|
+
// at most, but written by the site and read back, so bounded by bytes.
|
|
296
|
+
authorsBytes: 1024 * 1024,
|
|
282
297
|
files: 5_000,
|
|
283
298
|
directoryDepth: 12,
|
|
284
299
|
relativePathBytes: 1_024,
|
|
@@ -330,17 +345,31 @@ export function canonicalDocumentHash(document_) {
|
|
|
330
345
|
|
|
331
346
|
/**
|
|
332
347
|
* What a push would send for a page, as one hash: the source's bytes plus the
|
|
333
|
-
* title, path and
|
|
334
|
-
* come from the manifest, not the file). `pages push` skips a page whose key
|
|
348
|
+
* title, path, description and author that travel with it (for a `.pm.json`
|
|
349
|
+
* source they come from the manifest, not the file). `pages push` skips a page whose key
|
|
335
350
|
* equals the one recorded when the workspace last agreed with the site, so an
|
|
336
351
|
* unedited page is never re-sent (an update would turn an approved page back
|
|
337
352
|
* into a draft). A Markdown source also folds in the hash of the document it
|
|
338
353
|
* converts to, because its media references resolve through the media manifest:
|
|
339
354
|
* re-uploading an image changes what is sent without changing the file.
|
|
340
355
|
*/
|
|
341
|
-
export function pageContentKey(sourceHash, { title, path, description }, documentHash = "") {
|
|
356
|
+
export function pageContentKey(sourceHash, { title, path, description, author }, documentHash = "") {
|
|
357
|
+
// The author joins the key only when there is one (TR01196). Every page
|
|
358
|
+
// without an author keeps the key it has always had, so an existing workspace
|
|
359
|
+
// does not see each of its pages as changed and resend them all, which would
|
|
360
|
+
// turn approved pages back into drafts.
|
|
342
361
|
return workspaceContentHash(
|
|
343
|
-
Buffer.from(
|
|
362
|
+
Buffer.from(
|
|
363
|
+
JSON.stringify([
|
|
364
|
+
sourceHash,
|
|
365
|
+
title ?? "",
|
|
366
|
+
path ?? "",
|
|
367
|
+
description ?? "",
|
|
368
|
+
documentHash,
|
|
369
|
+
...(typeof author === "string" && author !== "" ? [author] : []),
|
|
370
|
+
]),
|
|
371
|
+
"utf8",
|
|
372
|
+
),
|
|
344
373
|
);
|
|
345
374
|
}
|
|
346
375
|
|
|
@@ -348,6 +377,12 @@ function normalizeBaselineHash(value) {
|
|
|
348
377
|
return typeof value === "string" && WORKSPACE_CONTENT_HASH.test(value) ? value : undefined;
|
|
349
378
|
}
|
|
350
379
|
|
|
380
|
+
// A folded author reference or "" (no author); anything else is a damaged member.
|
|
381
|
+
function normalizeAuthorValue(value) {
|
|
382
|
+
if (value === undefined || value === "") return value;
|
|
383
|
+
return parseAuthorReference(value)?.value;
|
|
384
|
+
}
|
|
385
|
+
|
|
351
386
|
// A calendar date or "" (no date). Anything else is a damaged baseline member.
|
|
352
387
|
function normalizeBaselineDisplayDate(value) {
|
|
353
388
|
return typeof value === "string" && (value === "" || /^\d{4}-\d{2}-\d{2}$/u.test(value)) ? value : undefined;
|
|
@@ -364,7 +399,7 @@ function normalizeBaselineDisplayDate(value) {
|
|
|
364
399
|
* dropped here instead of becoming a path refusal inside the verb that exists
|
|
365
400
|
* to repair the manifest.
|
|
366
401
|
*/
|
|
367
|
-
function isDiscoverablePageSource(file) {
|
|
402
|
+
export function isDiscoverablePageSource(file) {
|
|
368
403
|
if (typeof file !== "string" || !file.startsWith(`${PAGES_DIRECTORY}/`)) return false;
|
|
369
404
|
const segments = file.split("/");
|
|
370
405
|
return segments.length > 1
|
|
@@ -423,9 +458,10 @@ export function pageSourceRegistry(manifest) {
|
|
|
423
458
|
const contentKey = normalizeBaselineHash(declared?.contentKey);
|
|
424
459
|
const revision = normalizePageBodyRevision(declared?.revision);
|
|
425
460
|
const displayDate = normalizeBaselineDisplayDate(declared?.displayDate);
|
|
461
|
+
const author = normalizeAuthorValue(declared?.author);
|
|
426
462
|
const baseline =
|
|
427
463
|
remoteHash === undefined && sourceHash === undefined && revision === undefined && displayDate === undefined
|
|
428
|
-
&& contentKey === undefined
|
|
464
|
+
&& contentKey === undefined && author === undefined
|
|
429
465
|
? undefined
|
|
430
466
|
: {
|
|
431
467
|
...(remoteHash === undefined ? {} : { remoteHash }),
|
|
@@ -433,12 +469,31 @@ export function pageSourceRegistry(manifest) {
|
|
|
433
469
|
...(contentKey === undefined ? {} : { contentKey }),
|
|
434
470
|
...(revision === undefined ? {} : { revision }),
|
|
435
471
|
...(displayDate === undefined ? {} : { displayDate }),
|
|
472
|
+
// The author the site held for the page when this workspace last
|
|
473
|
+
// reconciled with it: what a page's own author reference is
|
|
474
|
+
// compared with before anything is sent (TR01196).
|
|
475
|
+
...(author === undefined || author === "" ? {} : { author }),
|
|
436
476
|
};
|
|
437
477
|
const record = {
|
|
438
478
|
pageId: entry.pageId,
|
|
439
479
|
file: entry.file,
|
|
440
480
|
sourceFormat,
|
|
441
481
|
baseline,
|
|
482
|
+
// What a generated page was when this workspace last pulled it, kept so a
|
|
483
|
+
// pull that cannot read its body does not forget it (TR01195).
|
|
484
|
+
...(entry.generated !== null && typeof entry.generated === "object" && !Array.isArray(entry.generated)
|
|
485
|
+
? { generated: entry.generated }
|
|
486
|
+
: {}),
|
|
487
|
+
// What this workspace says the page's metadata is; a .pm.json page's only
|
|
488
|
+
// record of it (TR01194).
|
|
489
|
+
metadata: {
|
|
490
|
+
title: typeof entry.title === "string" ? entry.title : "",
|
|
491
|
+
path: typeof entry.path === "string" ? entry.path : "",
|
|
492
|
+
description: typeof entry.description === "string" ? entry.description : "",
|
|
493
|
+
// The author reference the site holds for the page, or the one a
|
|
494
|
+
// `pages meta set --author` is waiting to send (TR01196).
|
|
495
|
+
author: normalizeAuthorValue(entry.author) ?? "",
|
|
496
|
+
},
|
|
442
497
|
baselineDiscarded: declared !== undefined && baseline === undefined,
|
|
443
498
|
formatMismatch: entry.sourceFormat !== undefined && entry.sourceFormat !== sourceFormat,
|
|
444
499
|
duplicateSource: false,
|
|
@@ -713,6 +768,7 @@ export async function ensureWorkspaceDirectory(workspaceDir, relativeDirectory =
|
|
|
713
768
|
export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes) {
|
|
714
769
|
const filePath = resolveWorkspacePath(workspaceDir, relativePath);
|
|
715
770
|
if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
|
|
771
|
+
noteFileMissing(relativePath);
|
|
716
772
|
throw new SiteAuthoringError(
|
|
717
773
|
"workspace.file_missing",
|
|
718
774
|
`Workspace file '${relativePath}' does not exist.`,
|
|
@@ -744,10 +800,13 @@ export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes
|
|
|
744
800
|
if (bytesRead === 0) break;
|
|
745
801
|
total += bytesRead;
|
|
746
802
|
}
|
|
747
|
-
|
|
803
|
+
const contents = buffer.subarray(0, total);
|
|
804
|
+
noteFileRead(relativePath, contents);
|
|
805
|
+
return contents;
|
|
748
806
|
} catch (error) {
|
|
749
807
|
if (error instanceof SiteAuthoringError) throw error;
|
|
750
808
|
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
809
|
+
noteFileMissing(relativePath);
|
|
751
810
|
throw new SiteAuthoringError(
|
|
752
811
|
"workspace.file_missing",
|
|
753
812
|
`Workspace file '${relativePath}' does not exist.`,
|
|
@@ -778,6 +837,7 @@ const STREAM_HEADER_BYTES = 4096;
|
|
|
778
837
|
export async function openWorkspaceFileStream(workspaceDir, relativePath, maximumBytes) {
|
|
779
838
|
const filePath = resolveWorkspacePath(workspaceDir, relativePath);
|
|
780
839
|
if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
|
|
840
|
+
noteStreamMissing(relativePath);
|
|
781
841
|
throw new SiteAuthoringError(
|
|
782
842
|
"workspace.file_missing",
|
|
783
843
|
`Workspace file '${relativePath}' does not exist.`,
|
|
@@ -812,6 +872,7 @@ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximu
|
|
|
812
872
|
total += bytesRead;
|
|
813
873
|
}
|
|
814
874
|
const opened = handle;
|
|
875
|
+
noteStreamOpened(relativePath, byteLength, modifiedMilliseconds, header.subarray(0, total));
|
|
815
876
|
return {
|
|
816
877
|
byteLength,
|
|
817
878
|
modifiedMilliseconds,
|
|
@@ -850,6 +911,7 @@ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximu
|
|
|
850
911
|
await handle?.close().catch(() => {});
|
|
851
912
|
if (error instanceof SiteAuthoringError) throw error;
|
|
852
913
|
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
914
|
+
noteStreamMissing(relativePath);
|
|
853
915
|
throw new SiteAuthoringError(
|
|
854
916
|
"workspace.file_missing",
|
|
855
917
|
`Workspace file '${relativePath}' does not exist.`,
|
|
@@ -885,6 +947,12 @@ export async function readWorkspaceJson(workspaceDir, relativePath, maximumBytes
|
|
|
885
947
|
* read outside the workspace.
|
|
886
948
|
*/
|
|
887
949
|
export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
|
|
950
|
+
const kind = await classifyWorkspaceEntry(workspaceDir, relativePath);
|
|
951
|
+
noteEntry(relativePath, kind);
|
|
952
|
+
return kind;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function classifyWorkspaceEntry(workspaceDir, relativePath) {
|
|
888
956
|
const target = resolveWorkspacePath(workspaceDir, relativePath);
|
|
889
957
|
// The leaf `lstat` below refuses a linked final component; this refuses a
|
|
890
958
|
// linked one anywhere above it, which is how `media/link/sub` would otherwise
|
|
@@ -929,6 +997,12 @@ export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
|
|
|
929
997
|
* missing.
|
|
930
998
|
*/
|
|
931
999
|
export async function workspaceFileExists(workspaceDir, relativePath) {
|
|
1000
|
+
const exists = await regularFileExists(workspaceDir, relativePath);
|
|
1001
|
+
noteExists(relativePath, exists);
|
|
1002
|
+
return exists;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function regularFileExists(workspaceDir, relativePath) {
|
|
932
1006
|
const filePath = resolveWorkspacePath(workspaceDir, relativePath);
|
|
933
1007
|
if (!await requireRealDirectoryChain(workspaceDir, relativePath)) return false;
|
|
934
1008
|
let stats;
|
|
@@ -982,6 +1056,7 @@ export async function writeWorkspaceFile(workspaceDir, relativePath, contents, {
|
|
|
982
1056
|
),
|
|
983
1057
|
},
|
|
984
1058
|
});
|
|
1059
|
+
noteFileWritten(relativePath, contents);
|
|
985
1060
|
return relativePath;
|
|
986
1061
|
}
|
|
987
1062
|
|
|
@@ -998,7 +1073,7 @@ export async function writeWorkspaceJson(workspaceDir, relativePath, value) {
|
|
|
998
1073
|
* exists to prevent.
|
|
999
1074
|
*/
|
|
1000
1075
|
export async function deleteWorkspaceFile(workspaceDir, relativePath) {
|
|
1001
|
-
if (!await
|
|
1076
|
+
if (!await regularFileExists(workspaceDir, relativePath)) return false;
|
|
1002
1077
|
try {
|
|
1003
1078
|
await unlink(resolveWorkspacePath(workspaceDir, relativePath));
|
|
1004
1079
|
} catch (error) {
|
|
@@ -1009,6 +1084,7 @@ export async function deleteWorkspaceFile(workspaceDir, relativePath) {
|
|
|
1009
1084
|
{ field: relativePath },
|
|
1010
1085
|
);
|
|
1011
1086
|
}
|
|
1087
|
+
noteFileDeleted(relativePath);
|
|
1012
1088
|
return true;
|
|
1013
1089
|
}
|
|
1014
1090
|
|
|
@@ -1040,8 +1116,12 @@ export async function walkWorkspaceFiles(
|
|
|
1040
1116
|
extensions,
|
|
1041
1117
|
{ segmentPattern = SAFE_SEGMENT, segmentDescription = "letters, digits, '.', '_', and '-'" } = {},
|
|
1042
1118
|
) {
|
|
1119
|
+
const filter = `${extensions.join(",")}|${segmentPattern}`;
|
|
1043
1120
|
const rootPath = resolveWorkspacePath(workspaceDir, relativeRoot);
|
|
1044
|
-
if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true }))
|
|
1121
|
+
if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true })) {
|
|
1122
|
+
noteWalk(relativeRoot, filter, []);
|
|
1123
|
+
return [];
|
|
1124
|
+
}
|
|
1045
1125
|
const collected = [];
|
|
1046
1126
|
const queue = [{ directory: rootPath, relative: relativeRoot, depth: 0 }];
|
|
1047
1127
|
while (queue.length > 0) {
|
|
@@ -1088,7 +1168,9 @@ export async function walkWorkspaceFiles(
|
|
|
1088
1168
|
}
|
|
1089
1169
|
}
|
|
1090
1170
|
}
|
|
1091
|
-
|
|
1171
|
+
collected.sort();
|
|
1172
|
+
noteWalk(relativeRoot, filter, collected);
|
|
1173
|
+
return collected;
|
|
1092
1174
|
}
|
|
1093
1175
|
|
|
1094
1176
|
/**
|
|
@@ -1112,6 +1194,14 @@ export function normalizePagePath(value) {
|
|
|
1112
1194
|
return trimmed;
|
|
1113
1195
|
}
|
|
1114
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* A page path as results show it: the home page is '/', everything else as
|
|
1199
|
+
* stored. The manifest and the wire keep the empty root path.
|
|
1200
|
+
*/
|
|
1201
|
+
export function displayPagePath(value) {
|
|
1202
|
+
return (normalizePagePath(value) ?? value) || "/";
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1115
1205
|
/**
|
|
1116
1206
|
* Chooses the workspace file name for a pulled page. The server's path is
|
|
1117
1207
|
* preferred because it is what an author recognizes, but it is server data
|
|
@@ -1324,6 +1414,11 @@ export async function readMediaManifest(workspaceDir, expectedSiteId) {
|
|
|
1324
1414
|
};
|
|
1325
1415
|
}
|
|
1326
1416
|
|
|
1417
|
+
/** The alt text a media record keeps when its file is uploaded again: the author's own, or none. */
|
|
1418
|
+
export function recordedAlt(entry) {
|
|
1419
|
+
return typeof entry?.alt === "string" ? entry.alt : "";
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1327
1422
|
export async function writeMediaManifest(workspaceDir, mediaManifest) {
|
|
1328
1423
|
return await writeWorkspaceJson(workspaceDir, MEDIA_MANIFEST_FILE_NAME, mediaManifest);
|
|
1329
1424
|
}
|