@agent-native/core-corpus 0.216.0 → 0.218.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.
Files changed (196) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
  3. package/corpus/templates/analytics/AGENTS.md +1 -1
  4. package/corpus/templates/analytics/CHANGELOG.md +3 -0
  5. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
  6. package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
  7. package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
  8. package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
  9. package/corpus/templates/analytics/actions/view-screen.ts +29 -3
  10. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
  11. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  12. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  14. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  15. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  16. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  17. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  18. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  19. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  20. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  21. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  22. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  23. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  24. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  25. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  26. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  27. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  28. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  29. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  30. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  31. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  32. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  33. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  34. package/corpus/templates/assets/CHANGELOG.md +6 -0
  35. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  36. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  37. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  38. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  39. package/corpus/templates/brain/vitest.config.ts +7 -0
  40. package/corpus/templates/calendar/CHANGELOG.md +12 -0
  41. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  42. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  43. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  44. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  45. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  46. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  47. package/corpus/templates/clips/CHANGELOG.md +7 -20
  48. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  49. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  50. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  51. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  52. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  54. package/corpus/templates/community/qr/package.json +1 -1
  55. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  56. package/corpus/templates/content/CHANGELOG.md +10 -0
  57. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  58. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  59. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  60. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  61. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  62. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  63. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  64. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  65. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  66. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  67. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  68. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  69. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  70. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  71. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  72. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  73. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  74. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  75. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  76. package/corpus/templates/content/actions/delete-document.ts +41 -3
  77. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  78. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  79. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  80. package/corpus/templates/content/actions/get-document.ts +2 -0
  81. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  82. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  83. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  84. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  85. package/corpus/templates/content/actions/update-document.ts +139 -20
  86. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  87. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
  88. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
  89. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  90. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  91. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  92. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  93. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  94. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  95. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  96. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  97. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  98. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  99. package/corpus/templates/content/app/global.css +31 -0
  100. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  101. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  102. package/corpus/templates/content/app/i18n-data.ts +1 -0
  103. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  104. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  105. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  106. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  107. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  108. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  109. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  110. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  111. package/corpus/templates/content/parity/matrix.md +42 -42
  112. package/corpus/templates/content/parity/matrix.ts +3 -1
  113. package/corpus/templates/content/server/db/schema.ts +20 -0
  114. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  115. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  116. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  117. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  118. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  119. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  120. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  121. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  122. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  123. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  124. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  125. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  126. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  127. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  128. package/corpus/templates/content/server/plugins/db.ts +28 -0
  129. package/corpus/templates/content/shared/api.ts +30 -1
  130. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  131. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  132. package/corpus/templates/content/shared/og-description.ts +258 -36
  133. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  134. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  135. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  136. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  137. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  138. package/corpus/templates/design/CHANGELOG.md +5 -0
  139. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  140. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  141. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  142. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  143. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  144. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  145. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  146. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  147. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  148. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  149. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  150. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  151. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  152. package/corpus/templates/design/app/global.css +6 -0
  153. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  154. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  155. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  156. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  157. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  158. package/corpus/templates/design/app/pages/Index.tsx +28 -67
  159. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  160. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  161. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  162. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  163. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  164. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  165. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  166. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  167. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  168. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  169. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  170. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  171. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  172. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  173. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  174. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  175. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  176. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  177. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  178. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  179. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  180. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  181. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  182. package/corpus/templates/plan/CHANGELOG.md +6 -0
  183. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  184. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  185. package/corpus/templates/slides/CHANGELOG.md +7 -0
  186. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  187. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  188. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  189. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  190. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  191. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  192. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  193. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  194. package/package.json +1 -1
  195. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  196. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ContentDatabaseItemLayout,
3
3
  ContentDatabaseItemLeadingKind,
4
+ ContentDatabaseItemLine2Source,
4
5
  ContentDatabaseItemTitleWeight,
5
6
  } from "./api";
6
7
  import {
@@ -25,6 +26,11 @@ export const CONTENT_DATABASE_ITEM_TITLE_WEIGHTS = [
25
26
 
26
27
  export const CONTENT_DATABASE_ITEM_LINE2_LINES = [1, 2, 3] as const;
27
28
 
29
+ export const CONTENT_DATABASE_ITEM_LINE2_SOURCES = [
30
+ "property",
31
+ "page",
32
+ ] as const satisfies readonly ContentDatabaseItemLine2Source[];
33
+
28
34
  /** Most properties line 3 or the trailing slot may show. */
29
35
  export const MAX_ITEM_LAYOUT_META_PROPERTIES = 6;
30
36
 
@@ -92,7 +98,7 @@ export function itemLayoutLeadingCandidates<
92
98
  );
93
99
  }
94
100
 
95
- /** Text-like properties line 2 can show. The page body arrives separately. */
101
+ /** Text-like properties line 2 can show. Page text is its own choice. */
96
102
  export function itemLayoutLine2Candidates<
97
103
  P extends ContentDatabaseItemLayoutPropertyLike,
98
104
  >(properties: readonly P[]): P[] {
@@ -135,6 +141,14 @@ function propertyIdList(value: unknown) {
135
141
  return ids;
136
142
  }
137
143
 
144
+ /** Line 2 with a new property ID, unless it shows page text. */
145
+ function withLine2PropertyId(
146
+ line2: ContentDatabaseItemLayout["line2"],
147
+ propertyId: string | null,
148
+ ): ContentDatabaseItemLayout["line2"] {
149
+ return line2.source === "page" ? line2 : { propertyId, lines: line2.lines };
150
+ }
151
+
138
152
  function record(value: unknown): Record<string, unknown> {
139
153
  return value && typeof value === "object" && !Array.isArray(value)
140
154
  ? (value as Record<string, unknown>)
@@ -174,7 +188,10 @@ export function normalizeContentDatabaseItemLayout(
174
188
  )
175
189
  ? (raw.titleWeight as ContentDatabaseItemTitleWeight)
176
190
  : "medium",
177
- line2: { propertyId: propertyIdOrNull(line2.propertyId), lines },
191
+ line2:
192
+ line2.source === "page"
193
+ ? { source: "page", propertyId: null, lines }
194
+ : { propertyId: propertyIdOrNull(line2.propertyId), lines },
178
195
  line3: propertyIdList(raw.line3),
179
196
  trailing: propertyIdList(raw.trailing),
180
197
  };
@@ -214,7 +231,8 @@ export function withSavedContentDatabaseItemLayout<
214
231
  export interface ResolvedContentDatabaseItemLayout<P> {
215
232
  leading: { kind: ContentDatabaseItemLeadingKind; property: P | null };
216
233
  titleWeight: ContentDatabaseItemTitleWeight;
217
- line2: { property: P | null; lines: 1 | 2 | 3 };
234
+ /** `pageText` rows show the first lines of their page instead of a property. */
235
+ line2: { property: P | null; pageText: boolean; lines: 1 | 2 | 3 };
218
236
  line3: P[];
219
237
  trailing: P[];
220
238
  }
@@ -248,9 +266,11 @@ export function resolveContentDatabaseItemLayout<
248
266
  : (leadingCandidates[0] ?? null)
249
267
  : null;
250
268
  const line2Candidates = new Set(itemLayoutLine2Candidates(properties));
251
- const line2Property = layout.line2.propertyId
252
- ? (byId.get(layout.line2.propertyId) ?? null)
253
- : null;
269
+ const pageText = layout.line2.source === "page";
270
+ const line2Property =
271
+ !pageText && layout.line2.propertyId
272
+ ? (byId.get(layout.line2.propertyId) ?? null)
273
+ : null;
254
274
  const meta = (ids: readonly string[]) =>
255
275
  ids.flatMap((id) => {
256
276
  const property = byId.get(id);
@@ -272,6 +292,7 @@ export function resolveContentDatabaseItemLayout<
272
292
  line2Property && line2Candidates.has(line2Property)
273
293
  ? line2Property
274
294
  : null,
295
+ pageText,
275
296
  lines: layout.line2.lines,
276
297
  },
277
298
  line3: meta(layout.line3),
@@ -353,10 +374,10 @@ export function pruneContentDatabaseItemLayout(
353
374
  propertyId: resolved.leading.property ? layout.leading.propertyId : null,
354
375
  },
355
376
  titleWeight: layout.titleWeight,
356
- line2: {
357
- propertyId: resolved.line2.property?.definition.id ?? null,
358
- lines: layout.line2.lines,
359
- },
377
+ line2: withLine2PropertyId(
378
+ layout.line2,
379
+ resolved.line2.property?.definition.id ?? null,
380
+ ),
360
381
  line3: ids(resolved.line3),
361
382
  trailing: ids(resolved.trailing),
362
383
  };
@@ -379,10 +400,7 @@ export function dropDeletedContentDatabaseItemLayoutReferences(
379
400
  ? { kind: "page_icon", propertyId: null }
380
401
  : layout.leading,
381
402
  titleWeight: layout.titleWeight,
382
- line2: {
383
- propertyId: kept(layout.line2.propertyId),
384
- lines: layout.line2.lines,
385
- },
403
+ line2: withLine2PropertyId(layout.line2, kept(layout.line2.propertyId)),
386
404
  line3: layout.line3.filter((id) => exists.has(id)),
387
405
  trailing: layout.trailing.filter((id) => exists.has(id)),
388
406
  };
@@ -444,13 +462,12 @@ export function savableContentDatabaseItemLayout(
444
462
  {
445
463
  leading: savedLeading ? layout.leading : showable.leading,
446
464
  titleWeight: layout.titleWeight,
447
- line2: {
448
- propertyId:
449
- saved?.line2.propertyId === layout.line2.propertyId
450
- ? layout.line2.propertyId
451
- : showable.line2.propertyId,
452
- lines: layout.line2.lines,
453
- },
465
+ line2: withLine2PropertyId(
466
+ layout.line2,
467
+ saved?.line2.propertyId === layout.line2.propertyId
468
+ ? layout.line2.propertyId
469
+ : showable.line2.propertyId,
470
+ ),
454
471
  line3: meta("line3"),
455
472
  trailing: meta("trailing"),
456
473
  },
@@ -462,7 +479,7 @@ export function savableContentDatabaseItemLayout(
462
479
  * The layout someone starts editing from on a view with no saved layout. It
463
480
  * matches the default rows: page icon, title, and the first four properties.
464
481
  * Line 2 is the only slot that shows a Blocks field, so one of those goes
465
- * there. The page body itself has no slot yet.
482
+ * there.
466
483
  */
467
484
  export function startingContentDatabaseItemLayout(
468
485
  visibleProperties: readonly ContentDatabaseItemLayoutPropertyLike[],
@@ -482,6 +499,21 @@ export function startingContentDatabaseItemLayout(
482
499
  };
483
500
  }
484
501
 
502
+ /**
503
+ * Whether any List view shows page text in line 2, so list reads carry each
504
+ * row's excerpt. Other collections never read page bodies for their rows.
505
+ */
506
+ export function contentDatabaseViewsShowPageText(
507
+ views: readonly {
508
+ type: string;
509
+ itemLayout?: ContentDatabaseItemLayout | null;
510
+ }[],
511
+ ): boolean {
512
+ return views.some(
513
+ (view) => view.type === "list" && view.itemLayout?.line2.source === "page",
514
+ );
515
+ }
516
+
485
517
  export interface ContentDatabaseItemLayoutPreset {
486
518
  id: ContentDatabaseItemLayoutPresetId;
487
519
  /** Null for Default, which keeps the view's default rows. */
@@ -518,6 +518,18 @@ function mergePlainParagraph(
518
518
  };
519
519
  }
520
520
 
521
+ /** The blocks `after` changed from `before`; none when the structure changed. */
522
+ export function changedBlocksBetween(before: string, after: string): number[] {
523
+ if (before === after) return [];
524
+ const base = nfmToDoc(before).content;
525
+ const candidate = nfmToDoc(after).content;
526
+ return base.length === candidate.length
527
+ ? base.flatMap((block, index) =>
528
+ stableBlock(block) !== stableBlock(candidate[index]) ? [index] : [],
529
+ )
530
+ : [];
531
+ }
532
+
521
533
  export function mergeDocumentBodyIntents(args: {
522
534
  authoredBaseContent: string;
523
535
  authoredCandidateContent: string;
@@ -530,18 +542,13 @@ export function mergeDocumentBodyIntents(args: {
530
542
  args.currentRevision === args.incoming.authoredBaseRevision &&
531
543
  args.currentContent === args.authoredBaseContent
532
544
  ) {
533
- const base = nfmToDoc(args.authoredBaseContent).content;
534
- const candidate = nfmToDoc(args.authoredCandidateContent).content;
535
- const changedBlockIndexes =
536
- base.length === candidate.length
537
- ? base.flatMap((block, index) =>
538
- stableBlock(block) !== stableBlock(candidate[index]) ? [index] : [],
539
- )
540
- : [];
541
545
  return {
542
546
  status: "resolved",
543
547
  content: args.authoredCandidateContent,
544
- changedBlockIndexes,
548
+ changedBlockIndexes: changedBlocksBetween(
549
+ args.authoredBaseContent,
550
+ args.authoredCandidateContent,
551
+ ),
545
552
  displaced: false,
546
553
  };
547
554
  }
@@ -7,7 +7,12 @@ function stripFrontmatter(markdown: string): string {
7
7
  }
8
8
 
9
9
  function stripFencedCode(markdown: string): string {
10
- return markdown.replace(/(?:^|\n)(```|~~~)[\s\S]*?(?:\n\1[ \t]*|$)/g, "\n");
10
+ // A closing fence is at least as long as the opening one and has nothing
11
+ // after it, so a line like ```js inside the block doesn't close it.
12
+ return markdown.replace(
13
+ /(?:^|\n) {0,3}((`|~)\2{2,})[\s\S]*?(?:\n {0,3}\1\2*[ \t]*(?=\n|$)|$)/g,
14
+ "\n",
15
+ );
11
16
  }
12
17
 
13
18
  function decodeCommonEntities(text: string): string {
@@ -22,9 +27,10 @@ function decodeCommonEntities(text: string): string {
22
27
 
23
28
  function normalizeForComparison(text: string): string {
24
29
  return stripMarkdownForPreview(text)
30
+ .normalize("NFC")
25
31
  .toLowerCase()
26
32
  .replace(/&/g, "and")
27
- .replace(/[^a-z0-9]+/g, " ")
33
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
28
34
  .trim();
29
35
  }
30
36
 
@@ -39,11 +45,19 @@ function isHeadingBlock(block: string): boolean {
39
45
  if (/^#{1,6}\s+\S/.test(trimmed)) return true;
40
46
  if (/^<h[1-6]\b/i.test(trimmed)) return true;
41
47
 
48
+ // Lines of paragraph text underlined with `=` or `-`. Under a list, quote
49
+ // or table, a `---` is a divider instead.
42
50
  const lines = trimmed.split("\n");
43
51
  return (
44
- lines.length === 2 &&
45
- lines[0].trim().length > 0 &&
46
- /^\s*(=+|-+)\s*$/.test(lines[1])
52
+ lines.length >= 2 &&
53
+ /^\s*(=+|-+)\s*$/.test(lines[lines.length - 1]) &&
54
+ lines
55
+ .slice(0, -1)
56
+ .every(
57
+ (line) =>
58
+ line.trim().length > 0 &&
59
+ !/^\s{0,3}(?:(?:[-+*]|\d+[.)])(?:\s|$)|>|\|)/.test(line),
60
+ )
47
61
  );
48
62
  }
49
63
 
@@ -52,41 +66,51 @@ function isDividerBlock(block: string): boolean {
52
66
  }
53
67
 
54
68
  export function stripMarkdownForPreview(markdown: string): string {
55
- return decodeCommonEntities(markdown)
56
- .replace(/<[^>\n]+\/>/g, " ")
57
- .replace(/<\/?[^>\n]+>/g, " ")
58
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
59
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
60
- .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
61
- .replace(/`([^`]+)`/g, "$1")
62
- .replace(/^#{1,6}\s+/gm, "")
63
- .replace(/\s+#+\s*$/gm, "")
64
- .replace(/^\s{0,3}>\s?/gm, "")
65
- .replace(/^\s*[-+*]\s+\[[ xX]\]\s+/gm, "")
66
- .replace(/^\s*[-+*]\s+/gm, "")
67
- .replace(/^\s*\d+[.)]\s+/gm, "")
68
- .replace(/^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*$/gm, " ")
69
- .replace(/\|/g, " ")
70
- .replace(/\*\*([^*\n]+)\*\*/g, "$1")
71
- .replace(/__([^_\n]+)__/g, "$1")
72
- .replace(/\*([^*\n]+)\*/g, "$1")
73
- .replace(/_([^_\n]+)_/g, "$1")
74
- .replace(/~~([^~\n]+)~~/g, "$1")
75
- .replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1")
76
- .replace(/\s+/g, " ")
77
- .trim();
69
+ return (
70
+ decodeCommonEntities(markdown)
71
+ .replace(/<[^>\n]+\/>/g, " ")
72
+ .replace(/<\/?[^>\n]+>/g, " ")
73
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
74
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
75
+ .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
76
+ .replace(/`([^`]+)`/g, "$1")
77
+ .replace(/^#{1,6}\s+/gm, "")
78
+ .replace(/\s+#+\s*$/gm, "")
79
+ .replace(/^\s{0,3}>\s?/gm, "")
80
+ .replace(/^\s*[-+*]\s+\[[ xX]\]\s+/gm, "")
81
+ .replace(/^\s*[-+*]\s+/gm, "")
82
+ .replace(/^\s*\d+[.)]\s+/gm, "")
83
+ .replace(/^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*$/gm, " ")
84
+ // Heading underlines and dividers that share a block with text.
85
+ .replace(/^[ \t]{0,3}(?:=+|-+)[ \t]*$/gm, " ")
86
+ .replace(/\|/g, " ")
87
+ .replace(/\*\*([^*\n]+)\*\*/g, "$1")
88
+ .replace(/__([^_\n]+)__/g, "$1")
89
+ .replace(/\*([^*\n]+)\*/g, "$1")
90
+ .replace(/_([^_\n]+)_/g, "$1")
91
+ .replace(/~~([^~\n]+)~~/g, "$1")
92
+ .replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1")
93
+ .replace(/\s+/g, " ")
94
+ .trim()
95
+ );
78
96
  }
79
97
 
80
- function truncateDescription(text: string, maxLength: number): string {
81
- if (text.length <= maxLength) return text;
82
-
98
+ /** Ends text with "...", shortening it at a word so the whole fits. */
99
+ function withEllipsis(text: string, maxLength: number): string {
83
100
  const hardLimit = Math.max(20, maxLength - 3);
84
- const prefix = text.slice(0, hardLimit);
85
- const wordBoundary = prefix.lastIndexOf(" ");
86
- const truncated =
87
- wordBoundary > hardLimit * 0.6 ? prefix.slice(0, wordBoundary) : prefix;
101
+ let kept = text;
102
+ if (kept.length > hardLimit) {
103
+ const prefix = kept.slice(0, hardLimit);
104
+ const wordBoundary = prefix.lastIndexOf(" ");
105
+ kept =
106
+ wordBoundary > hardLimit * 0.6 ? prefix.slice(0, wordBoundary) : prefix;
107
+ }
108
+
109
+ return `${kept.replace(/[.,;:!?\s-]+$/g, "")}...`;
110
+ }
88
111
 
89
- return `${truncated.replace(/[.,;:!?\s-]+$/g, "")}...`;
112
+ function truncateDescription(text: string, maxLength: number): string {
113
+ return text.length <= maxLength ? text : withEllipsis(text, maxLength);
90
114
  }
91
115
 
92
116
  export function buildPublicDocumentDescription({
@@ -126,3 +150,201 @@ export function buildPublicDocumentDescription({
126
150
  ? truncateDescription(fallback, maxLength)
127
151
  : DEFAULT_DESCRIPTION;
128
152
  }
153
+
154
+ /** A line frontmatter holds: a key, list item, indented value, comment or blank. */
155
+ /** A frontmatter key and its colon. A quoted key can hold colons. */
156
+ const FRONTMATTER_KEY_SOURCE = String.raw`(?:"[^"\n]*"|'[^'\n]*'|[^\s#:-][^:\n]*):(?=\s|$)`;
157
+ const FRONTMATTER_KEY = new RegExp(`^${FRONTMATTER_KEY_SOURCE}`);
158
+ const FRONTMATTER_LINE = new RegExp(
159
+ String.raw`^(?:[ \t]*(?:#.*)?|[ \t]+\S.*|-(?:[ \t].*)?|${FRONTMATTER_KEY_SOURCE}.*)$`,
160
+ );
161
+ const FRONTMATTER_START = new RegExp(
162
+ String.raw`^---[ \t]*\n(?:#|${FRONTMATTER_KEY_SOURCE})`,
163
+ );
164
+ // A key cut before its colon: one word, or a quoted name.
165
+ const FRONTMATTER_KEY_START = /^(?:"[^"\n]*|'[^'\n]*|[^\s#:-][^\s:]*)$/;
166
+ // A key written the way frontmatter keys are: one word that doesn't start
167
+ // with a capital, or a quoted name. A label like `Note:` starts prose.
168
+ const CONVENTIONAL_FRONTMATTER_KEY =
169
+ /^(?:"[^"\n]*"|'[^'\n]*'|[^\s\p{Lu}#:"'-][^\s:]*):(?=\s|$)/u;
170
+
171
+ /**
172
+ * The markdown after its frontmatter, or null when a prefix ends inside it.
173
+ * Frontmatter opens with `---` and a key or comment, and holds only YAML lines,
174
+ * at least one a key, up to a closing `---`. A divider at the top of a page is
175
+ * followed by a blank line or by text that isn't YAML.
176
+ */
177
+ function stripExcerptFrontmatter(
178
+ markdown: string,
179
+ truncated: boolean,
180
+ ): string | null {
181
+ if (!FRONTMATTER_START.test(markdown)) return markdown;
182
+ const lines = markdown.split("\n");
183
+ let hasKey = false;
184
+ for (let index = 1; index < lines.length; index++) {
185
+ const line = lines[index];
186
+ if (/^---[ \t]*$/.test(line)) {
187
+ return hasKey ? lines.slice(index + 1).join("\n") : markdown;
188
+ }
189
+ if (FRONTMATTER_KEY.test(line)) hasKey = true;
190
+ // A prefix's last line may be cut short. After a whole line, it counts if
191
+ // it could still begin a YAML line. Cut as the first line, it counts only
192
+ // with a conventional key, so a long `Note: …` paragraph after a top
193
+ // divider stays page text while a long `description:` stays hidden.
194
+ if (truncated && index === lines.length - 1) {
195
+ const couldBeYaml =
196
+ index > 1
197
+ ? FRONTMATTER_LINE.test(line) || FRONTMATTER_KEY_START.test(line)
198
+ : CONVENTIONAL_FRONTMATTER_KEY.test(line);
199
+ if (!couldBeYaml) return markdown;
200
+ break;
201
+ }
202
+ if (!FRONTMATTER_LINE.test(line)) return markdown;
203
+ }
204
+ // A prefix that has shown only YAML lines may end inside the frontmatter,
205
+ // even before its first key.
206
+ return truncated ? null : markdown;
207
+ }
208
+
209
+ /**
210
+ * Drops opening markers with no partner after them, like the `**` of a bold
211
+ * run a cut split. Closed spans keep their text, markers and all.
212
+ */
213
+ function dropUnclosedMarkers(markdown: string): string {
214
+ return markdown.replace(
215
+ /(^|\s)(\*{1,3}|_{1,3}|~~|`+)(?=[\p{L}\p{M}\p{N}])/gu,
216
+ (match, space: string, marker: string, offset: number) =>
217
+ markdown.slice(offset + match.length).includes(marker) ? match : space,
218
+ );
219
+ }
220
+
221
+ const DEFAULT_EXCERPT_LENGTH = 300;
222
+ /** Scripts written without spaces between words. */
223
+ const SPACELESS_SCRIPT =
224
+ /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
225
+
226
+ /**
227
+ * Plain text from the start of a page body, for a list row. Images, code,
228
+ * dividers and a first paragraph that repeats the title drop out, and
229
+ * headings only count when the page has no other text. `truncated` means the
230
+ * markdown is only the body's first part, so it may end partway through;
231
+ * `nextCharacter`, when known, tells whether its last word goes on.
232
+ */
233
+ export function buildDocumentExcerpt({
234
+ content,
235
+ title,
236
+ truncated = false,
237
+ nextCharacter,
238
+ maxLength = DEFAULT_EXCERPT_LENGTH,
239
+ }: {
240
+ content?: string | null;
241
+ title?: string | null;
242
+ truncated?: boolean;
243
+ nextCharacter?: string;
244
+ maxLength?: number;
245
+ }): string {
246
+ const body = stripExcerptFrontmatter(
247
+ content?.replace(/\r\n?/g, "\n") ?? "",
248
+ truncated,
249
+ );
250
+ // A prefix that ends inside the frontmatter has no body text yet.
251
+ if (body === null) return "";
252
+ let markdown = body;
253
+ if (truncated) {
254
+ // Drop an image or tag the cut leaves open. A link it leaves open keeps
255
+ // its text, and when the cut falls in its address, that text is whole.
256
+ let linkTextEnded = false;
257
+ markdown = markdown
258
+ .replace(/!\[[^\]\n]*(?:\]\([^)\n]*)?$/, "")
259
+ .replace(/<[A-Za-z/!][^>\n]*$/, "")
260
+ .replace(
261
+ /\[([^\]\n]*)(\]\([^)\n]*)?$/,
262
+ (_match, text: string, address?: string) => {
263
+ linkTextEnded = address !== undefined;
264
+ return text;
265
+ },
266
+ );
267
+ // Drop a word the cut splits, with any markup or punctuation in front of
268
+ // it. A cut right after punctuation or closing markup ends a whole word,
269
+ // an apostrophe after the cut means the word goes on, and text in a
270
+ // script written without spaces has no word to finish.
271
+ const lastWord = /\s(\S*[\p{L}\p{M}\p{N}'’])$/u.exec(markdown)?.[1];
272
+ const wordGoesOn =
273
+ nextCharacter === undefined || /[\p{L}\p{M}\p{N}'’]/u.test(nextCharacter);
274
+ if (
275
+ lastWord &&
276
+ wordGoesOn &&
277
+ !linkTextEnded &&
278
+ !SPACELESS_SCRIPT.test(lastWord)
279
+ ) {
280
+ markdown = markdown.slice(0, -lastWord.length);
281
+ }
282
+ // Then markup or an opening bracket or quote left with nothing after it.
283
+ markdown = markdown.replace(/(^|\s)[*_~`(["'“‘]+$/u, "$1");
284
+ }
285
+ const blocks = stripFencedCode(markdown)
286
+ // Headings and dividers stand alone even without blank lines around them.
287
+ // A line of `=` or `-` right under a line of text underlines it as a
288
+ // heading, so it stays with that line.
289
+ .replace(/^ {0,3}#{1,6}[ \t].*$/gm, "\n$&\n")
290
+ .replace(/(?<=\S[^\n]*\n) {0,3}(?:=+|-+)[ \t]*$/gm, "$&\n")
291
+ .replace(/^ {0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/gm, "\n")
292
+ .replace(/(^|\n\n)[ \t]{0,3}(?:[-*_][ \t]*){3,}(?=\n|$)/g, "$1\n")
293
+ .split(/\n{2,}/);
294
+ const paragraphs: string[] = [];
295
+ const headings: string[] = [];
296
+ let length = 0;
297
+ for (const [index, block] of blocks.entries()) {
298
+ if (length > maxLength) break;
299
+ if (!block.trim() || isDividerBlock(block)) continue;
300
+ // The cut can only leave markup open in the last block.
301
+ const source =
302
+ truncated && index === blocks.length - 1
303
+ ? dropUnclosedMarkers(block)
304
+ : block;
305
+ const text = stripMarkdownForPreview(
306
+ source.replace(/!\[[^\]]*\]\([^)]*\)/g, " "),
307
+ );
308
+ if (!text) continue;
309
+ if (isHeadingBlock(block)) {
310
+ headings.push(text);
311
+ } else if (paragraphs.length > 0 || !isTitleRepeat(text, title ?? "")) {
312
+ paragraphs.push(text);
313
+ length += text.length + 1;
314
+ }
315
+ }
316
+ const text = (paragraphs.length > 0 ? paragraphs : headings).join(" ");
317
+ if (text.length > maxLength) return truncateDescription(text, maxLength);
318
+ return truncated && text ? withEllipsis(text, maxLength) : text;
319
+ }
320
+
321
+ /**
322
+ * How much of a page body a list row's excerpt reads: enough markdown for
323
+ * three lines of text once links and formatting are stripped.
324
+ */
325
+ export const EXCERPT_SOURCE_CHARS = 1000;
326
+
327
+ /**
328
+ * A list row's excerpt, from only the first `EXCERPT_SOURCE_CHARS` characters
329
+ * of the page body. A whole body and a list read's prefix, which takes one
330
+ * character more to tell a longer body from one that fits, give the same text.
331
+ */
332
+ export function buildListRowExcerpt({
333
+ content,
334
+ title,
335
+ }: {
336
+ content?: string | null;
337
+ title?: string | null;
338
+ }): string {
339
+ // Count characters the way the database does, not UTF-16 units. No
340
+ // character takes more than two units.
341
+ const characters = Array.from(
342
+ (content ?? "").slice(0, 2 * (EXCERPT_SOURCE_CHARS + 1)),
343
+ );
344
+ return buildDocumentExcerpt({
345
+ content: characters.slice(0, EXCERPT_SOURCE_CHARS).join(""),
346
+ title,
347
+ truncated: characters.length > EXCERPT_SOURCE_CHARS,
348
+ nextCharacter: characters[EXCERPT_SOURCE_CHARS],
349
+ });
350
+ }
@@ -483,6 +483,9 @@ function diffTokens(text: string): string[] {
483
483
  // is no longer than either, as diff-match-patch's semantic cleanup does.
484
484
  // Equalities between replacements stay, so separate word swaps remain
485
485
  // separately reviewable, and a line never folds, so blocks stay separate.
486
+ // The exception is a line the merged run begins or ends with: that is one
487
+ // insertion (text plus a new paragraph) Myers aligned across the existing
488
+ // separator, and it would otherwise split whenever another hunk exists.
486
489
  type ChangeRun = {
487
490
  deleted: string;
488
491
  inserted: string;
@@ -518,12 +521,17 @@ function foldShortEqualities(parts: DiffPart[]): DiffPart[] {
518
521
  const right = segments[index + 1];
519
522
  if (typeof equality !== "string" || typeof left !== "object") continue;
520
523
  if (typeof right !== "object") continue;
524
+ const insertions = left.ownDeleted === 0 && right.ownDeleted === 0;
521
525
  const oneSided =
522
- (left.ownDeleted === 0 && right.ownDeleted === 0) ||
523
- (left.ownInserted === 0 && right.ownInserted === 0);
526
+ insertions || (left.ownInserted === 0 && right.ownInserted === 0);
527
+ const merged = insertions
528
+ ? left.inserted + equality + right.inserted
529
+ : left.deleted + equality + right.deleted;
524
530
  if (
525
531
  !oneSided ||
526
- equality.includes("\n") ||
532
+ (equality.includes("\n") &&
533
+ !merged.startsWith(equality) &&
534
+ !merged.endsWith(equality)) ||
527
535
  equality.length > Math.max(left.ownDeleted, left.ownInserted) ||
528
536
  equality.length > Math.max(right.ownDeleted, right.ownInserted)
529
537
  )
@@ -814,16 +822,24 @@ function wholeWordReplacements(
814
822
  const removed = operation.before.changedText;
815
823
  const inserted = operation.after.changedText;
816
824
  const lexicalChange = /[\p{L}\p{M}\p{N}]/u.test(removed + inserted);
825
+ // An edge that is a line break, as when a whole paragraph is deleted,
826
+ // sits beside the neighboring block's word without splitting it.
827
+ const lineEdge = (edge: (text: string) => boolean) =>
828
+ edge(removed) && (!inserted || edge(inserted));
829
+ const widensBefore =
830
+ isWordBefore(before, from) && !lineEdge((text) => text.startsWith("\n"));
831
+ const widensAfter =
832
+ isWordAt(before, to) && !lineEdge((text) => text.endsWith("\n"));
817
833
  let group = { from, to, members: [operation] };
818
834
  if (
819
835
  lexicalChange &&
820
836
  !/<br\/?\s*>/u.test(removed + inserted) &&
821
837
  (removed || !/\s/u.test(inserted)) &&
822
- (isWordBefore(before, from) || isWordAt(before, to))
838
+ (widensBefore || widensAfter)
823
839
  ) {
824
- while (isWordBefore(before, group.from))
840
+ while (widensBefore && isWordBefore(before, group.from))
825
841
  group.from -= characterBefore(before, group.from).length;
826
- while (isWordAt(before, group.to))
842
+ while (widensAfter && isWordAt(before, group.to))
827
843
  group.to += characterAt(before, group.to).length;
828
844
  }
829
845
  // Two edits inside one word are one review decision.
@@ -1022,6 +1038,7 @@ export function markdownSuggestionOperationsForReplacements(input: {
1022
1038
  const [{ from, to }] = replacements;
1023
1039
  if (
1024
1040
  operations.length > 1 &&
1041
+ !before.slice(from, to).includes("\n") &&
1025
1042
  operations.every(
1026
1043
  (operation) =>
1027
1044
  operation.anchor.from >= from && operation.anchor.to <= to,