canopycms 0.0.68-int.107 → 0.0.68-int.108

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.
Files changed (64) hide show
  1. package/dist/api/branch-status.js +26 -2
  2. package/dist/api/content.js +49 -6
  3. package/dist/api/github-sync.d.ts +11 -3
  4. package/dist/api/github-sync.js +16 -8
  5. package/dist/branch-metadata.d.ts +6 -0
  6. package/dist/branch-metadata.js +15 -1
  7. package/dist/cli/cli.js +336 -114
  8. package/dist/cli/generate-ai-content.js +260 -74
  9. package/dist/cli/init.js +37 -12
  10. package/dist/config/index.d.ts +1 -1
  11. package/dist/config/schemas/field.js +7 -2
  12. package/dist/config/types.d.ts +17 -6
  13. package/dist/config/types.js +3 -9
  14. package/dist/content-store.js +6 -4
  15. package/dist/editor/BranchManager.js +2 -3
  16. package/dist/editor/Editor.js +11 -2
  17. package/dist/editor/PreviewFrame.d.ts +3 -1
  18. package/dist/editor/PreviewFrame.js +10 -2
  19. package/dist/editor/admin/SystemHealthPanel.js +5 -1
  20. package/dist/editor/components/EditorSidebar.d.ts +3 -1
  21. package/dist/editor/components/EditorSidebar.js +4 -4
  22. package/dist/editor/components/ReferencedByList.js +10 -4
  23. package/dist/editor/fields/BlockField.js +3 -3
  24. package/dist/editor/fields/MarkdownField.js +3 -0
  25. package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
  26. package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
  27. package/dist/editor/hooks/useCommentSystem.js +31 -16
  28. package/dist/editor/preview-bridge.d.ts +9 -0
  29. package/dist/editor/preview-bridge.js +38 -0
  30. package/dist/git-manager.d.ts +9 -0
  31. package/dist/git-manager.js +13 -0
  32. package/dist/github-service.d.ts +6 -0
  33. package/dist/github-service.js +20 -0
  34. package/dist/services.d.ts +10 -1
  35. package/dist/services.js +33 -17
  36. package/dist/static/index.d.ts +17 -3
  37. package/dist/static/index.js +54 -0
  38. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  39. package/dist/task-queue/cms-task-queue.js +1 -1
  40. package/dist/task-queue/index.d.ts +1 -1
  41. package/dist/task-queue/index.js +1 -1
  42. package/dist/task-queue/task-queue.d.ts +6 -0
  43. package/dist/task-queue/task-queue.js +29 -0
  44. package/dist/task-queue/worker-status.d.ts +9 -9
  45. package/dist/task-queue/worker-status.js +29 -17
  46. package/dist/types.d.ts +37 -6
  47. package/dist/utils/content-serialize.d.ts +4 -1
  48. package/dist/utils/content-serialize.js +110 -9
  49. package/dist/utils/json-source-splice.d.ts +14 -0
  50. package/dist/utils/json-source-splice.js +120 -0
  51. package/dist/validation/markdown-safety.d.ts +68 -0
  52. package/dist/validation/markdown-safety.js +604 -0
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/dist/worker/cms-worker.d.ts +51 -1
  56. package/dist/worker/cms-worker.js +156 -34
  57. package/dist/worker/git-sync.d.ts +1 -1
  58. package/dist/worker/git-sync.js +32 -5
  59. package/dist/worker/rebase.d.ts +1 -1
  60. package/dist/worker/rebase.js +6 -0
  61. package/dist/worker/task-runner.d.ts +2 -2
  62. package/dist/worker/task-runner.js +141 -47
  63. package/dist/worker/worker-context.d.ts +16 -3
  64. package/package.json +2 -1
@@ -11,6 +11,7 @@ import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../uti
11
11
  import { isNonFastForwardRejection } from '../utils/git.js';
12
12
  import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
13
13
  import { submissionEditorFromUser } from '../submission-attribution.js';
14
+ import { NothingToSubmitError } from '../services.js';
14
15
  const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
15
16
  const { branchContext } = gc;
16
17
  return { ok: true, status: 200, data: { branch: branchContext.branch } };
@@ -59,6 +60,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
59
60
  ({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
60
61
  }
61
62
  catch (err) {
63
+ if (err instanceof NothingToSubmitError) {
64
+ return { ok: false, status: 400, error: err.message };
65
+ }
62
66
  // Retriable either way: a resubmit commits nothing new and pushes only
63
67
  // what has not reached the remote.
64
68
  if (err instanceof ContentWriteLockBusyError) {
@@ -107,13 +111,33 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
107
111
  };
108
112
  }
109
113
  // Create or update PR (sync via githubService, or async via task queue)
110
- const prResult = await syncSubmitPr(ctx, branchContext, { submitter, changedPaths });
111
- // Update metadata with status and PR info
114
+ const submittedAt = new Date().toISOString();
115
+ const prResult = await syncSubmitPr(ctx, branchContext, {
116
+ submitter,
117
+ changedPaths,
118
+ submittedAt,
119
+ });
112
120
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
121
+ const pushed = prResult.pushedToGitHub ? { pushedToGitHubAt: submittedAt } : {};
122
+ // GitHub found nothing between the pushed branch and its base, though the diff above found
123
+ // changes: GitHub's base can already hold them, or the diff could not be computed. The branch
124
+ // stays editable; the stamp records that it is now on GitHub, for delete.
125
+ if (prResult.nothingToSubmit) {
126
+ await meta.save({ branch: { name: branchContext.branch.name, ...pushed } });
127
+ const base = branchContext.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
128
+ return {
129
+ ok: false,
130
+ status: 400,
131
+ error: new NothingToSubmitError(branchContext.branch.name, base).message,
132
+ };
133
+ }
113
134
  const updated = await meta.save({
114
135
  branch: {
115
136
  name: branchContext.branch.name,
116
137
  status: 'submitted',
138
+ submittedAt,
139
+ ...pushed,
140
+ syncFailureReason: prResult.syncFailureReason,
117
141
  pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
118
142
  pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
119
143
  ...(prResult.syncStatus !== undefined ? { syncStatus: prResult.syncStatus } : {}),
@@ -5,11 +5,13 @@ import { SchemaUnavailableError } from '../schema/schema-unavailable-error.js';
5
5
  import { ReferenceValidator } from '../validation/reference-validator.js';
6
6
  import { findUnknownKeys, mergeBodyIntoData, normalizeReferenceValues, validateEntryData, } from '../validation/entry-validator.js';
7
7
  import { validateEntryLinks } from '../validation/entry-link-validator.js';
8
+ import { findMarkdownSafetyIssues, splitByStored } from '../validation/markdown-safety.js';
8
9
  import { collectReferenceIds } from '../validation/field-traversal.js';
9
10
  import { branchNameSchema, logicalPathSchema, slugSchema } from './validators.js';
10
11
  import { entryLogicalPath, parseSlug } from '../paths/index.js';
11
12
  import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
12
13
  import { isDataOnlyFormat } from '../utils/format.js';
14
+ import { findBodyFieldName } from '../utils/body-field.js';
13
15
  /**
14
16
  * Parse an API path into logical path segments, prepending the content root if needed.
15
17
  * Shared by all content API handlers.
@@ -123,6 +125,23 @@ const storedReferenceSites = async (store, collectionPath, slug, fields) => {
123
125
  const doc = await store.read(collectionPath, slug, { resolveReferences: false });
124
126
  return new Set(collectReferenceIds(fields, doc.data).map((ref) => referenceSite(ref.path, ref.id)));
125
127
  };
128
+ function withoutKey(record, key) {
129
+ if (!(key in record))
130
+ return record;
131
+ const { [key]: _dropped, ...rest } = record;
132
+ return rest;
133
+ }
134
+ /** The policy's issues in the stored entry, which a save may keep. */
135
+ const storedMarkdownSafetyIssues = async (store, collectionPath, slug, fields) => {
136
+ // A stored file that does not read keeps nothing, so the save's code is refused, not a 500.
137
+ const doc = await store
138
+ .read(collectionPath, slug, { resolveReferences: false })
139
+ .catch(() => undefined);
140
+ if (doc === undefined)
141
+ return [];
142
+ const data = 'body' in doc ? mergeBodyIntoData(fields, doc.data, doc.body) : doc.data;
143
+ return findMarkdownSafetyIssues(fields, doc.format, data);
144
+ };
126
145
  const writeContentHandler = async (gc, ctx, req, params, body) => {
127
146
  const { branchContext } = gc;
128
147
  const flatSchema = branchContext.flatSchema;
@@ -216,15 +235,20 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
216
235
  if (resolvedEntryType?.unavailable) {
217
236
  throw new SchemaUnavailableError(resolvedEntryType.name, resolvedEntryType.unavailable);
218
237
  }
219
- const data = body.data ?? {};
220
238
  const isDataOnly = isDataOnlyFormat(body.format);
239
+ // An md/mdx file's body field is its body, so a frontmatter key of that name is dropped: no
240
+ // check would see it, and a read or a resolved reference would serve it as the body.
241
+ const requestData = isDataOnly || body.data === undefined
242
+ ? body.data
243
+ : withoutKey(body.data, findBodyFieldName(fields));
244
+ const data = requestData ?? {};
221
245
  // The store's own `undefined` (blind write) is never reachable from here: an omitted token is
222
246
  // a create, so an update that lost its token 409s instead of overwriting unchecked.
223
247
  const expectedVersion = body.expectedVersion ?? null;
224
248
  const createConflictError = body.expectedVersion === null
225
249
  ? `An entry with slug "${slug}" already exists`
226
250
  : `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
227
- const danglingWarnings = [];
251
+ const keptWarnings = [];
228
252
  try {
229
253
  const exists = await store.documentExists(schemaItem.logicalPath, slug);
230
254
  // Create guard: a create against a slug that already has content must never silently
@@ -276,6 +300,23 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
276
300
  // as the schema's isBody field.
277
301
  const dataForValidation = isDataOnly ? data : mergeBodyIntoData(fields, data, body.body ?? '');
278
302
  const fieldErrors = validateEntryData(fields, dataForValidation);
303
+ // Code in markdown or MDX (validation/markdown-safety.ts). A field the stored entry held
304
+ // with code is kept, with a warning, when saved unchanged; any other code is refused.
305
+ const unsafe = findMarkdownSafetyIssues(fields, body.format, dataForValidation);
306
+ if (unsafe.length > 0) {
307
+ const stored = exists
308
+ ? await storedMarkdownSafetyIssues(store, schemaItem.logicalPath, slug, fields)
309
+ : [];
310
+ const { refused, kept } = splitByStored(unsafe, stored);
311
+ fieldErrors.push(...refused);
312
+ for (const e of kept) {
313
+ keptWarnings.push({
314
+ level: 'warning',
315
+ fieldPath: e.fieldPath,
316
+ message: `holds code that runs when the page renders, kept because the saved entry already had it: ${e.message}. Ask a developer to move it into a component.`,
317
+ });
318
+ }
319
+ }
279
320
  // Reference existence (server-only: reads the content ID index). Editor
280
321
  // payloads may still carry resolved `{ id, ... }` objects from a prior
281
322
  // read, so collapse them to id strings before checking.
@@ -292,7 +333,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
292
333
  : new Set();
293
334
  for (const e of refResult.errors) {
294
335
  if (e.dangling && storedSites.has(referenceSite(e.fieldPath, e.id))) {
295
- danglingWarnings.push({
336
+ keptWarnings.push({
296
337
  level: 'warning',
297
338
  fieldPath: e.fieldPath,
298
339
  message: `references a missing entry (${e.id}). It is kept as it was; repoint or clear it.`,
@@ -323,7 +364,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
323
364
  // Adopter save-time validation, run BEFORE the file is written: 'error' issues
324
365
  // refuse the save (e.g. a body that would break the site's production build),
325
366
  // 'warning' issues are returned alongside the successful write.
326
- let validationWarnings = danglingWarnings.length > 0 ? danglingWarnings : undefined;
367
+ let validationWarnings = keptWarnings.length > 0 ? keptWarnings : undefined;
327
368
  const validateEntry = ctx.services.config.validateEntry;
328
369
  // Collapse resolved reference objects back to bare ID strings before persisting (the reference
329
370
  // validator above gets its own copy). The editor's GET resolves references by default, so form
@@ -331,7 +372,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
331
372
  // left unnormalized, a save would freeze that snapshot into the file (resolution only
332
373
  // re-resolves plain ID strings), permanently severing the reference from its target. Idempotent:
333
374
  // a payload that already holds ID strings is unchanged.
334
- const normalizedData = body.data === undefined ? undefined : normalizeReferenceValues(fields, body.data);
375
+ const normalizedData = requestData === undefined ? undefined : normalizeReferenceValues(fields, requestData);
335
376
  // Computed before the validateEntry hook and the entry-link scan too, not just the write, so
336
377
  // every consumer agrees on the same bytes — otherwise an adopter's hook could see a resolved
337
378
  // object while the file got an ID string, depending on whether the post came from the editor.
@@ -366,7 +407,9 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
366
407
  issues = await validateEntry({
367
408
  entryPath: logicalPathSegments.join('/'),
368
409
  branch: params.branch,
369
- ...(params.entryType ? { entryType: params.entryType } : {}),
410
+ // The type the store writes with, so a request that omits `entryType` can't skip a
411
+ // type-gated rule.
412
+ ...(entryTypeName ? { entryType: entryTypeName } : {}),
370
413
  format: body.format,
371
414
  data: normalizedData ?? {},
372
415
  body: body.body,
@@ -8,11 +8,19 @@ export interface GitHubSyncResult {
8
8
  prUrl?: string;
9
9
  prNumber?: number;
10
10
  syncStatus?: SyncStatus;
11
+ /** Client-safe reason a direct GitHub call failed, recorded as the branch's syncFailureReason. */
12
+ syncFailureReason?: string;
13
+ /** GitHub refused the PR because the pushed branch has no commits its base lacks. */
14
+ nothingToSubmit?: boolean;
15
+ /** The direct path, whose submit pushes to GitHub itself: the branch is there whatever the PR call did. */
16
+ pushedToGitHub?: boolean;
11
17
  }
12
18
  /** What the PR body records about a submit. */
13
19
  export interface SubmissionRecord {
14
20
  submitter?: SubmissionEditor;
15
21
  changedPaths: readonly string[];
22
+ /** The submit's `submittedAt`, carried to the worker so its failure handling targets this submit. */
23
+ submittedAt?: string;
16
24
  }
17
25
  /**
18
26
  * Uses githubService directly if available, otherwise queues a task for the worker.
@@ -28,9 +36,9 @@ export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext, su
28
36
  export declare function syncConvertToDraft(ctx: ApiContext, context: BranchContext): Promise<void>;
29
37
  /**
30
38
  * Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
31
- * put it there: a content branch reaches GitHub only through submit, which always opens a PR,
32
- * so a recorded PR number is the proof. Without one, a same-named GitHub branch is someone
33
- * else's (the create-time collision check is best-effort) and is never touched.
39
+ * put it there: a recorded PR number or `pushedToGitHubAt` stamp is the proof (a submit whose PR
40
+ * GitHub refused pushed the branch but has no PR number). Without either, a same-named GitHub
41
+ * branch is someone else's (the create-time collision check is best-effort) and is never touched.
34
42
  *
35
43
  * Returns a client-facing warning when the delete could not be done or queued; never throws.
36
44
  */
@@ -1,10 +1,10 @@
1
1
  import { enqueueTask } from '../task-queue/cms-task-queue.js';
2
2
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
3
3
  import { clientOperatingStrategy } from '../operating-mode/index.js';
4
- import { getErrorMessage } from '../utils/error.js';
4
+ import { getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
5
5
  import { sanitizeBranchName } from '../paths/branch-name.js';
6
6
  import { buildPrSection, mergePrSection } from '../submission-attribution.js';
7
- import { isRefAlreadyGoneError } from '../github-service.js';
7
+ import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
8
8
  /**
9
9
  * Uses githubService directly if available, otherwise queues a task for the worker.
10
10
  *
@@ -65,6 +65,7 @@ export async function syncSubmitPr(ctx, context, submission) {
65
65
  prUrl: context.branch.pullRequestUrl,
66
66
  prNumber: context.branch.pullRequestNumber,
67
67
  syncStatus: 'synced',
68
+ pushedToGitHub: true,
68
69
  };
69
70
  }
70
71
  else {
@@ -92,16 +93,21 @@ export async function syncSubmitPr(ctx, context, submission) {
92
93
  prUrl: result.url,
93
94
  prNumber: result.number,
94
95
  syncStatus: 'synced',
96
+ pushedToGitHub: true,
95
97
  };
96
98
  }
97
99
  }
98
100
  catch (err) {
99
- const message = err instanceof Error ? err.message : 'Unknown error';
101
+ const message = getErrorMessage(err);
100
102
  console.error(`CanopyCMS: Failed to create/update PR for ${context.branch.name}:`, message);
103
+ if (isNoCommitsBetweenError(err))
104
+ return { nothingToSubmit: true, pushedToGitHub: true };
101
105
  return {
102
106
  prUrl: context.branch.pullRequestUrl,
103
107
  prNumber: context.branch.pullRequestNumber,
104
108
  syncStatus: 'sync-failed',
109
+ syncFailureReason: sanitizeErrorMessage(message),
110
+ pushedToGitHub: true,
105
111
  };
106
112
  }
107
113
  }
@@ -124,6 +130,7 @@ export async function syncSubmitPr(ctx, context, submission) {
124
130
  // A content submit is an explicit "ready for review" action: convert a
125
131
  // pre-existing draft PR to ready.
126
132
  markReadyIfDraft: true,
133
+ submittedAt: submission.submittedAt,
127
134
  },
128
135
  });
129
136
  }
@@ -159,14 +166,15 @@ export async function syncConvertToDraft(ctx, context) {
159
166
  }
160
167
  /**
161
168
  * Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
162
- * put it there: a content branch reaches GitHub only through submit, which always opens a PR,
163
- * so a recorded PR number is the proof. Without one, a same-named GitHub branch is someone
164
- * else's (the create-time collision check is best-effort) and is never touched.
169
+ * put it there: a recorded PR number or `pushedToGitHubAt` stamp is the proof (a submit whose PR
170
+ * GitHub refused pushed the branch but has no PR number). Without either, a same-named GitHub
171
+ * branch is someone else's (the create-time collision check is best-effort) and is never touched.
165
172
  *
166
173
  * Returns a client-facing warning when the delete could not be done or queued; never throws.
167
174
  */
168
175
  export async function syncDeleteRemoteBranch(ctx, context) {
169
- if (!context.branch.pullRequestNumber)
176
+ const { pullRequestNumber, pushedToGitHubAt } = context.branch;
177
+ if (!pullRequestNumber && !pushedToGitHubAt)
170
178
  return undefined;
171
179
  const { githubService } = ctx.services;
172
180
  const branch = context.branch.name;
@@ -187,7 +195,7 @@ export async function syncDeleteRemoteBranch(ctx, context) {
187
195
  return undefined;
188
196
  const result = await enqueueGitHubTask(ctx, context, {
189
197
  action: 'delete-remote-branch',
190
- payload: { branch, pullRequestNumber: context.branch.pullRequestNumber },
198
+ payload: { branch, pullRequestNumber, pushedToGitHubAt },
191
199
  });
192
200
  return result.syncStatus === 'sync-failed'
193
201
  ? 'Deleting the branch on GitHub could not be queued; delete it there by hand'
@@ -68,6 +68,12 @@ export declare class BranchMetadataFileManager {
68
68
  * creates only its own directory (`withOccFileLock`), so the save fails there.
69
69
  */
70
70
  save(incoming: BranchMetadataUpdate): Promise<BranchMetadataFile>;
71
+ /**
72
+ * {@link save}, applied only when `onlyIf` accepts the metadata on disk; resolves null when it
73
+ * does not, having written nothing. `onlyIf` runs inside the lock stack against the version
74
+ * the write is checked against, so the decision and the write are one compare-and-set.
75
+ */
76
+ saveIf(incoming: BranchMetadataUpdate, onlyIf: (existing: BranchMetadataFile | null) => boolean): Promise<BranchMetadataFile | null>;
71
77
  /** Invalidate the registry cache so the next list() regenerates. */
72
78
  private invalidateRegistry;
73
79
  }
@@ -106,6 +106,17 @@ export class BranchMetadataFileManager {
106
106
  * creates only its own directory (`withOccFileLock`), so the save fails there.
107
107
  */
108
108
  async save(incoming) {
109
+ const saved = await this.saveIf(incoming, () => true);
110
+ if (saved === null)
111
+ throw new Error('unreachable: an unconditional save was skipped');
112
+ return saved;
113
+ }
114
+ /**
115
+ * {@link save}, applied only when `onlyIf` accepts the metadata on disk; resolves null when it
116
+ * does not, having written nothing. `onlyIf` runs inside the lock stack against the version
117
+ * the write is checked against, so the decision and the write are one compare-and-set.
118
+ */
119
+ async saveIf(incoming, onlyIf) {
109
120
  try {
110
121
  await fs.stat(this.branchRoot);
111
122
  }
@@ -119,6 +130,8 @@ export class BranchMetadataFileManager {
119
130
  try {
120
131
  saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
121
132
  const { meta: existing, version } = await this.load();
133
+ if (!onlyIf(existing))
134
+ return null;
122
135
  const merged = mergeBranchMetadata(existing, version, incoming);
123
136
  const written = await this.write(merged, version);
124
137
  merged.version = written.version;
@@ -137,7 +150,8 @@ export class BranchMetadataFileManager {
137
150
  // does, and the registry's eager regeneration is O(branch count) fs reads
138
151
  // on EFS — holding the lock through it would stretch every save's critical
139
152
  // section for no correctness gain.
140
- await this.invalidateRegistry();
153
+ if (saved !== null)
154
+ await this.invalidateRegistry();
141
155
  return saved;
142
156
  }
143
157
  /** Invalidate the registry cache so the next list() regenerates. */