@taprootio/site-authoring 0.22.4 → 0.23.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/src/verbs/plan.js CHANGED
@@ -7,9 +7,9 @@ import {
7
7
  getNavigation,
8
8
  IMAGE_PROCESSING_STATE_COMPLETE,
9
9
  listSiteImages,
10
+ isGonePage,
10
11
  listSitePages,
11
12
  listVideos,
12
- PAGE_STATUS_DELETED,
13
13
  withRefusalGuidance,
14
14
  } from "../api.js";
15
15
  import {
@@ -35,6 +35,7 @@ import { checkWorkspace } from "../workspace-check.js";
35
35
  import { enforceWorkspaceReads, ledgerDigest, recordWorkspaceReads } from "../workspace-ledger.js";
36
36
  import {
37
37
  PAGE_SOURCE_EXTENSIONS,
38
+ NAVIGATION_FILE_NAME,
38
39
  PAGES_DIRECTORY,
39
40
  readManifest,
40
41
  readMediaManifest,
@@ -42,6 +43,7 @@ import {
42
43
  } from "../workspace.js";
43
44
  import { footerPush } from "./footer-push.js";
44
45
  import { mediaUpload, pendingMediaFiles } from "./media-upload.js";
46
+ import { navigationMovedRefusal, requireNavigationBaseline } from "../navigation-workspace.js";
45
47
  import { navPush, validateNavigationWorkspaceDocument } from "./nav-push.js";
46
48
  import {
47
49
  liveAuthorsRefresher,
@@ -194,7 +196,7 @@ async function preflight(session, invocation) {
194
196
  { field: "pages" },
195
197
  );
196
198
  }
197
- const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
199
+ const live = livePages.filter((summary) => !isGonePage(summary));
198
200
  const liveResourceIds = new Set(live.map((summary) => summary.resourceId).filter(Boolean));
199
201
 
200
202
  // The shared checks for the presentation, footer, and navigation; pages
@@ -357,8 +359,10 @@ async function preflight(session, invocation) {
357
359
  onProgress("Reading the site's navigation.");
358
360
  let liveNavigation;
359
361
  let navigationChanged = false;
362
+ let settlesLostPush = false;
360
363
  if (offline.navigation !== undefined) {
361
- liveNavigation = await getNavigation(client, siteId);
364
+ const live = await getNavigation(client, siteId);
365
+ liveNavigation = live.navItems;
362
366
  let liveItems;
363
367
  try {
364
368
  liveItems = validateNavigationWorkspaceDocument({ siteId, navItems: liveNavigation }, siteId).navItems;
@@ -368,6 +372,24 @@ async function preflight(session, invocation) {
368
372
  liveItems = liveNavigation;
369
373
  }
370
374
  navigationChanged = stableJson(liveItems) !== stableJson(offline.navigation.navItems);
375
+ // A tree equal to the file under a baseline the site has moved past is a push whose answer was lost after
376
+ // it committed. The step runs so nav push settles the workspace, as theme push replays a lost save.
377
+ if (!navigationChanged) {
378
+ try {
379
+ settlesLostPush = requireNavigationBaseline(manifest) !== live.revision;
380
+ } catch (error) {
381
+ if (!(error instanceof SiteAuthoringError)) throw error;
382
+ }
383
+ }
384
+ // Only a navigation step that would run can be refused, as for the theme.
385
+ if (navigationChanged) {
386
+ try {
387
+ if (requireNavigationBaseline(manifest) !== live.revision) throw navigationMovedRefusal();
388
+ } catch (error) {
389
+ if (!(error instanceof SiteAuthoringError)) throw error;
390
+ problems.push(...problemsOf(error, { area: CHECK_AREA.navigation, file: NAVIGATION_FILE_NAME }));
391
+ }
392
+ }
371
393
  }
372
394
 
373
395
  const pagesItems = reportedWithin(pages.planned.map(plannedPageItem), PAGE_ITEMS_BYTES);
@@ -395,7 +417,10 @@ async function preflight(session, invocation) {
395
417
  ...(theme.replaying ? { replaysLostSave: true } : {}),
396
418
  }),
397
419
  }),
398
- step(PLAN_STEP.navigation, stepStatus(problems, PLAN_STEP.navigation, navigationChanged)),
420
+ step(PLAN_STEP.navigation, stepStatus(problems, PLAN_STEP.navigation, navigationChanged || settlesLostPush), {
421
+ // The site already holds the file's tree, so the step only settles the workspace's recorded revision.
422
+ ...(settlesLostPush && !navigationChanged ? { settlesLostPush: true } : {}),
423
+ }),
399
424
  ];
400
425
  const planHash = `sha256:${createHash("sha256").update(stableJson({
401
426
  version: 1,
@@ -412,7 +437,8 @@ async function preflight(session, invocation) {
412
437
  problems,
413
438
  pendingMedia,
414
439
  planHash,
415
- // The live state each step must still find, where the server has no fence.
440
+ // The live state each step must still find: page revisions and the navigation tree, which the steps check
441
+ // themselves (the theme and footer saves are fenced by the server, and the navigation save is as well).
416
442
  plannedPages: new Map(sending.map((page) => [page.file, stableJson([page.pageId ?? "", page.revision ?? "", page.action])])),
417
443
  plannedLiveNavigation: stableJson(liveNavigation ?? null),
418
444
  };
package/src/verbs/pull.js CHANGED
@@ -5,8 +5,8 @@ import {
5
5
  getSitePresentation,
6
6
  getSiteRedirectMap,
7
7
  listSiteAuthors,
8
+ isGonePage,
8
9
  listSitePages,
9
- PAGE_STATUS_DELETED,
10
10
  PAGE_STATUS_DRAFT,
11
11
  PAGE_STATUS_UNKNOWN,
12
12
  withRefusalGuidance,
@@ -22,6 +22,7 @@ import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
22
22
  import { appearanceManifestEntry, footerManifestEntry, presentationManifestEntry } from "../footer-workspace.js";
23
23
  import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
24
24
  import { projectRedirectMapForWorkspace, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
25
+ import { navigationManifestEntry } from "../navigation-workspace.js";
25
26
  import { redirectsManifestEntry } from "../redirects-workspace.js";
26
27
  import { projectNavigationForWorkspace } from "./nav-push.js";
27
28
  import { boundedList, openSession, successResult } from "../session.js";
@@ -678,7 +679,7 @@ export async function pull(invocation) {
678
679
  onProgress("Listing the site's pages.");
679
680
  ({ pages, truncated } = await listSitePages(client, siteId, { onProgress }));
680
681
  }
681
- const live = pages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
682
+ const live = pages.filter((summary) => !isGonePage(summary));
682
683
  const authorable = live.filter((summary) => isAuthorableTemplateType(summary.templateType));
683
684
  // Generated-page sources recorded apart as retired. One whose page the site
684
685
  // lists again (a bounded listing left it out last time) takes its file back
@@ -1312,9 +1313,11 @@ export async function pull(invocation) {
1312
1313
  }
1313
1314
 
1314
1315
  let navItems;
1316
+ let navigation;
1315
1317
  if (!presentationOnly) {
1316
1318
  onProgress("Reading the navigation tree.");
1317
- navItems = await getNavigation(client, siteId);
1319
+ navigation = await getNavigation(client, siteId);
1320
+ navItems = navigation.navItems;
1318
1321
  await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, {
1319
1322
  siteId,
1320
1323
  navItems: projectNavigationForWorkspace(navItems),
@@ -1356,7 +1359,7 @@ export async function pull(invocation) {
1356
1359
  // not a partial pull.
1357
1360
  ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
1358
1361
  pagesTruncated: truncated,
1359
- ...(navItems === undefined ? {} : { navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length } }),
1362
+ ...(navigation === undefined ? {} : { navigation: navigationManifestEntry(navigation) }),
1360
1363
  ...(redirectMap === undefined ? {} : { redirects: redirectsManifestEntry(redirectMap) }),
1361
1364
  settings: pulledSettings,
1362
1365
  settingsSkipped: skippedSettings,
@@ -1409,7 +1412,7 @@ export async function pull(invocation) {
1409
1412
  items: reported.items,
1410
1413
  ...(reported.truncated ? { itemsTruncated: true } : {}),
1411
1414
  },
1412
- ...(navItems === undefined ? {} : { navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length } }),
1415
+ ...(navigation === undefined ? {} : { navigation: navigationManifestEntry(navigation) }),
1413
1416
  // Absent, not zeroed, when the site served no map: an agent reading
1414
1417
  // `entries: 0` would take the site for one with no redirects.
1415
1418
  ...(redirectMap === undefined ? {} : {
@@ -1,9 +1,10 @@
1
1
  import { mintStagingPreviewHandoff, withRefusalGuidance } from "../api.js";
2
- import { VERB_STAGING_REVIEW } from "../constants.js";
2
+ import { STAGING_REVIEW_SESSION_NOTE, VERB_STAGING_REVIEW } from "../constants.js";
3
3
  import { openSession, successResult } from "../session.js";
4
4
 
5
5
  export const STAGING_REVIEW_GUIDANCE = "Open the URL once in a browser, then switch the site's theme toggle between "
6
- + "light and dark to review both schemes; no page draft is needed to review a presentation change.";
6
+ + "light and dark to review both schemes; no page draft is needed to review a presentation change. "
7
+ + STAGING_REVIEW_SESSION_NOTE;
7
8
 
8
9
  /**
9
10
  * `staging review` — mint a fresh single-use review handoff for the site's
@@ -62,7 +62,7 @@ function fail(code, message, field) {
62
62
  throw new SiteAuthoringError(code, message, { field });
63
63
  }
64
64
 
65
- async function readSettingsDocument(workspaceDir, siteId, settingsType, expectedEntityId = siteId) {
65
+ export async function readSettingsDocument(workspaceDir, siteId, settingsType, expectedEntityId = siteId) {
66
66
  const file = `${SETTINGS_DIRECTORY}/${SETTINGS_FILES[settingsType]}`;
67
67
  if (!await workspaceFileExists(workspaceDir, file)) {
68
68
  fail("theme.settings_missing", `Workspace file '${file}' is missing. Run 'taproot-site pull' first.`, file);
@@ -83,6 +83,34 @@ async function readSettingsDocument(workspaceDir, siteId, settingsType, expected
83
83
  return document.settings;
84
84
  }
85
85
 
86
+ /** The theme paths the styles document lacks, both schemes, in the order theme push reports them. */
87
+ export function missingThemeSettings(style) {
88
+ const missing = [];
89
+ for (const scheme of ["light", "dark"]) {
90
+ const name = `${scheme}Theme`;
91
+ if (!Object.hasOwn(style, name)) missing.push(name);
92
+ else missing.push(...missingThemeFields(style[name], scheme));
93
+ }
94
+ return missing;
95
+ }
96
+
97
+ /** The one refusal for absent required settings keys, listing every path; `resolve` shares it with push and validate. */
98
+ export function requireNoMissingSettings(missing) {
99
+ if (missing.length === 0) return;
100
+ throw new SiteAuthoringError(
101
+ "theme.settings_missing",
102
+ "Required settings keys are absent from this workspace. Restore them locally, or run 'taproot-site pull' if the workspace is stale; a fresh pull that still omits them means the server projection is incomplete for the listed paths.",
103
+ {
104
+ field: missing[0],
105
+ details: missing.map((field) => ({
106
+ code: "theme.setting_missing",
107
+ field,
108
+ message: MISSING_SETTING_MESSAGE,
109
+ })),
110
+ },
111
+ );
112
+ }
113
+
86
114
  /** The complete local validation phase shared by live push and offline fixtures. */
87
115
  export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds, expectedEntityIds) {
88
116
  const [style, brand, header, publishing] = await Promise.all([
@@ -119,12 +147,7 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
119
147
  // Preflight the complete required-key inventory before any value validator
120
148
  // can stop at a malformed value in an earlier group. Optional footer color
121
149
  // leaves retain their existing empty-color fallback.
122
- const missing = [];
123
- for (const scheme of ["light", "dark"]) {
124
- const name = `${scheme}Theme`;
125
- if (!Object.hasOwn(style, name)) missing.push(name);
126
- else missing.push(...missingThemeFields(style[name], scheme));
127
- }
150
+ const missing = missingThemeSettings(style);
128
151
  for (const definition of APPEARANCE_SCALAR_FIELDS) {
129
152
  if (!Object.hasOwn(documents[definition.settingsType], definition.name)) {
130
153
  missing.push(definition.path);
@@ -137,20 +160,7 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
137
160
  if (!Object.hasOwn(publishing.footerSettings, scheme)) missing.push(`footerSettings.${scheme}`);
138
161
  }
139
162
  }
140
- if (missing.length > 0) {
141
- throw new SiteAuthoringError(
142
- "theme.settings_missing",
143
- "Required settings keys are absent from this workspace. Restore them locally, or run 'taproot-site pull' if the workspace is stale; a fresh pull that still omits them means the server projection is incomplete for the listed paths.",
144
- {
145
- field: missing[0],
146
- details: missing.map((field) => ({
147
- code: "theme.setting_missing",
148
- field,
149
- message: MISSING_SETTING_MESSAGE,
150
- })),
151
- },
152
- );
153
- }
163
+ requireNoMissingSettings(missing);
154
164
  const themes = await validateAndLintThemePair(style.lightTheme, style.darkTheme);
155
165
  const scalarOperations = buildAppearanceScalarOperations(
156
166
  documents,
@@ -0,0 +1,98 @@
1
+ import { getSitePresentation, withRefusalGuidance } from "../api.js";
2
+ import { LIMITS, VERB_THEME_RESOLVE } from "../constants.js";
3
+ import { SiteAuthoringError } from "../errors.js";
4
+ import { CHECK_AREA, collectProblems, refuseProblems, withinByteBudget } from "../problems.js";
5
+ import { boundedByBytes, openAnonymousSession, openSession, successResult } from "../session.js";
6
+ import { projectSettingsGroup, SETTINGS_GROUPS, SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
7
+ import { AA_LARGE_RATIO, AA_TEXT_RATIO, resolveThemePair, THEME_STYLES_FILE } from "../theme-resolve.js";
8
+ import { missingThemeSettings, readSettingsDocument, requireNoMissingSettings } from "./theme-push.js";
9
+
10
+ // A result must fit LIMITS.githubOutputBytes (64 KiB), and a theme may declare 128 contexts a scheme. The lists that
11
+ // grow with the theme are bounded first, each by its own share; surfaces then take what is left, and the ones past
12
+ // it are named in omittedSurfaces to be fetched with --scheme and --context.
13
+ const RESULT_ENVELOPE_BYTES = 4 * 1024;
14
+ const FLAG_BYTE_BUDGET = 10 * 1024;
15
+ const NAME_LIST_BYTE_BUDGET = 6 * 1024;
16
+
17
+ const STYLES_GROUP = SETTINGS_GROUPS.find((group) => group.settingsType === SETTINGS_TYPE_TAPROOT_STYLES);
18
+
19
+ /** The workspace's pair, read the way theme push reads it; or the site's, projected the way pull writes it. */
20
+ async function readThemes(invocation) {
21
+ if (invocation.fromSite === true) {
22
+ const session = await openSession(invocation);
23
+ const presentation = await withRefusalGuidance(session.onProgress, "theme resolve", async () =>
24
+ await getSitePresentation(session.client, session.siteId));
25
+ const projected = projectSettingsGroup(STYLES_GROUP, presentation);
26
+ return { siteId: session.siteId, source: "site", themes: [projected.lightTheme, projected.darkTheme] };
27
+ }
28
+ const { config } = await openAnonymousSession({ ...invocation, client: null });
29
+ if (config === undefined) {
30
+ throw new SiteAuthoringError(
31
+ "config.not_found",
32
+ "No taproot-site.json was found. theme resolve reads the pulled workspace it names; use --site to read the site instead.",
33
+ );
34
+ }
35
+ if (!config.siteId) {
36
+ throw new SiteAuthoringError(
37
+ "config.site_missing",
38
+ "No site is selected. Run 'taproot-site use <site>' to choose one, or set siteId in taproot-site.json.",
39
+ );
40
+ }
41
+ const style = await readSettingsDocument(config.workspaceDir, config.siteId, SETTINGS_TYPE_TAPROOT_STYLES);
42
+ // The same preflight push and validate run, so a stale workspace gets the same code and the full path list.
43
+ requireNoMissingSettings(missingThemeSettings(style));
44
+ return { siteId: config.siteId, source: "workspace", themes: [style.lightTheme, style.darkTheme] };
45
+ }
46
+
47
+ /**
48
+ * `theme resolve` — what Espalier compiles from the stored light and dark
49
+ * themes: every semantic color per scheme and context, and the contrast of
50
+ * each ink-on-surface pairing. Offline from the pulled workspace by default;
51
+ * `--site` reads the site's own theme instead (and needs a credential).
52
+ */
53
+ export async function themeResolve(invocation) {
54
+ const { siteId, source, themes: [lightTheme, darkTheme] } = await readThemes(invocation);
55
+ const problems = [];
56
+ const resolution = await collectProblems(
57
+ problems,
58
+ { area: CHECK_AREA.presentation, file: THEME_STYLES_FILE },
59
+ async () => await resolveThemePair(lightTheme, darkTheme, {
60
+ scheme: invocation.themeScheme,
61
+ context: invocation.themeContext,
62
+ }),
63
+ );
64
+ refuseProblems(problems, "theme resolve");
65
+
66
+ const flags = { ...boundedByBytes(resolution.flags, FLAG_BYTE_BUDGET), count: resolution.flags.length };
67
+ const contexts = { ...boundedByBytes(resolution.contexts, NAME_LIST_BYTE_BUDGET), count: resolution.contexts.length };
68
+ const surfaceBudget = LIMITS.githubOutputBytes - RESULT_ENVELOPE_BYTES - NAME_LIST_BYTE_BUDGET
69
+ - Buffer.byteLength(JSON.stringify({ flags, contexts }), "utf8");
70
+ const entries = resolution.schemes.flatMap(({ scheme, surfaces }) => surfaces.map((surface) => ({ scheme, surface })));
71
+ const shown = withinByteBudget(entries, surfaceBudget);
72
+ const omitted = entries.slice(shown.length);
73
+ const schemes = resolution.schemes
74
+ .map(({ scheme }) => ({
75
+ scheme,
76
+ surfaces: shown.filter((entry) => entry.scheme === scheme).map((entry) => entry.surface),
77
+ }))
78
+ .filter((entry) => entry.surfaces.length > 0);
79
+ return successResult(VERB_THEME_RESOLVE, siteId, {
80
+ source,
81
+ thresholds: { textRatio: AA_TEXT_RATIO, largeRatio: AA_LARGE_RATIO },
82
+ contexts,
83
+ schemes,
84
+ contrast: resolution.contrast,
85
+ flags,
86
+ ...(omitted.length === 0
87
+ ? {}
88
+ : {
89
+ omittedSurfaces: {
90
+ ...boundedByBytes(
91
+ omitted.map((entry) => ({ scheme: entry.scheme, context: entry.surface.context })),
92
+ NAME_LIST_BYTE_BUDGET,
93
+ ),
94
+ count: omitted.length,
95
+ },
96
+ }),
97
+ });
98
+ }
package/src/workspace.js CHANGED
@@ -1068,9 +1068,9 @@ export async function writeWorkspaceJson(workspaceDir, relativePath, value) {
1068
1068
  * Removes one internal workspace file, refusing a link anywhere on the way to
1069
1069
  * it exactly as a read would. Returns whether anything was there.
1070
1070
  *
1071
- * Only internal state is ever removed this way. Authored sources are the
1072
- * author's, and a verb that deleted one would be doing the thing this package
1073
- * exists to prevent.
1071
+ * Internal state is removed this way as a matter of course. An authored source
1072
+ * is the author's, so the one caller that removes it, `pages delete`, does so
1073
+ * only for the page the author just asked to delete.
1074
1074
  */
1075
1075
  export async function deleteWorkspaceFile(workspaceDir, relativePath) {
1076
1076
  if (!await regularFileExists(workspaceDir, relativePath)) return false;