@caretcms/core 0.1.2 → 0.3.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 (190) hide show
  1. package/README.md +109 -13
  2. package/dist/contracts.d.ts +1 -0
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +1 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/index.d.ts +31 -9
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +146 -30
  9. package/dist/index.js.map +1 -1
  10. package/dist/loader.d.ts +17 -6
  11. package/dist/loader.d.ts.map +1 -1
  12. package/dist/loader.js +57 -14
  13. package/dist/loader.js.map +1 -1
  14. package/dist/markdown/block-context.d.ts +21 -0
  15. package/dist/markdown/block-context.d.ts.map +1 -0
  16. package/dist/markdown/block-context.js +27 -0
  17. package/dist/markdown/block-context.js.map +1 -0
  18. package/dist/markdown/canonical-body.d.ts +30 -0
  19. package/dist/markdown/canonical-body.d.ts.map +1 -0
  20. package/dist/markdown/canonical-body.js +54 -0
  21. package/dist/markdown/canonical-body.js.map +1 -0
  22. package/dist/markdown/contracts.d.ts +66 -0
  23. package/dist/markdown/contracts.d.ts.map +1 -0
  24. package/dist/markdown/contracts.js +102 -0
  25. package/dist/markdown/contracts.js.map +1 -0
  26. package/dist/markdown/html-to-snodes.d.ts +26 -0
  27. package/dist/markdown/html-to-snodes.d.ts.map +1 -0
  28. package/dist/markdown/html-to-snodes.js +118 -0
  29. package/dist/markdown/html-to-snodes.js.map +1 -0
  30. package/dist/markdown/remark.d.ts +35 -0
  31. package/dist/markdown/remark.d.ts.map +1 -0
  32. package/dist/markdown/remark.js +66 -0
  33. package/dist/markdown/remark.js.map +1 -0
  34. package/dist/markdown/satteri.d.ts +19 -0
  35. package/dist/markdown/satteri.d.ts.map +1 -0
  36. package/dist/markdown/satteri.js +144 -0
  37. package/dist/markdown/satteri.js.map +1 -0
  38. package/dist/markdown/serialize.d.ts +60 -0
  39. package/dist/markdown/serialize.d.ts.map +1 -0
  40. package/dist/markdown/serialize.js +264 -0
  41. package/dist/markdown/serialize.js.map +1 -0
  42. package/dist/markdown/splice.d.ts +32 -0
  43. package/dist/markdown/splice.d.ts.map +1 -0
  44. package/dist/markdown/splice.js +44 -0
  45. package/dist/markdown/splice.js.map +1 -0
  46. package/dist/markdown/stamp.d.ts +68 -0
  47. package/dist/markdown/stamp.d.ts.map +1 -0
  48. package/dist/markdown/stamp.js +131 -0
  49. package/dist/markdown/stamp.js.map +1 -0
  50. package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
  51. package/dist/runtime/auth/rate-limiter.js +5 -0
  52. package/dist/runtime/auth/rate-limiter.js.map +1 -1
  53. package/dist/runtime/auth/session.d.ts +3 -3
  54. package/dist/runtime/auth/session.d.ts.map +1 -1
  55. package/dist/runtime/auth/session.js +68 -21
  56. package/dist/runtime/auth/session.js.map +1 -1
  57. package/dist/runtime/config.d.ts +3 -0
  58. package/dist/runtime/config.d.ts.map +1 -1
  59. package/dist/runtime/config.js +4 -1
  60. package/dist/runtime/config.js.map +1 -1
  61. package/dist/runtime/content.d.ts.map +1 -1
  62. package/dist/runtime/content.js +4 -2
  63. package/dist/runtime/content.js.map +1 -1
  64. package/dist/runtime/i18n.d.ts +86 -0
  65. package/dist/runtime/i18n.d.ts.map +1 -0
  66. package/dist/runtime/i18n.js +168 -0
  67. package/dist/runtime/i18n.js.map +1 -0
  68. package/dist/runtime/middleware.d.ts.map +1 -1
  69. package/dist/runtime/middleware.js +61 -6
  70. package/dist/runtime/middleware.js.map +1 -1
  71. package/dist/runtime/mutations/contracts.d.ts +28 -1
  72. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  73. package/dist/runtime/mutations/contracts.js +76 -0
  74. package/dist/runtime/mutations/contracts.js.map +1 -1
  75. package/dist/runtime/mutations/engine.d.ts +6 -1
  76. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  77. package/dist/runtime/mutations/engine.js +271 -51
  78. package/dist/runtime/mutations/engine.js.map +1 -1
  79. package/dist/runtime/providers.d.ts +2 -1
  80. package/dist/runtime/providers.d.ts.map +1 -1
  81. package/dist/runtime/providers.js +7 -1
  82. package/dist/runtime/providers.js.map +1 -1
  83. package/dist/runtime/publish.d.ts +13 -1
  84. package/dist/runtime/publish.d.ts.map +1 -1
  85. package/dist/runtime/publish.js +80 -4
  86. package/dist/runtime/publish.js.map +1 -1
  87. package/dist/runtime/request-context.d.ts +14 -2
  88. package/dist/runtime/request-context.d.ts.map +1 -1
  89. package/dist/runtime/request-context.js +7 -1
  90. package/dist/runtime/request-context.js.map +1 -1
  91. package/dist/runtime/rewrite.d.ts.map +1 -1
  92. package/dist/runtime/rewrite.js +109 -3
  93. package/dist/runtime/rewrite.js.map +1 -1
  94. package/dist/runtime/rich-allowlist.d.ts.map +1 -1
  95. package/dist/runtime/rich-allowlist.js +5 -1
  96. package/dist/runtime/rich-allowlist.js.map +1 -1
  97. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  98. package/dist/runtime/routes/admin-entry.js +114 -18
  99. package/dist/runtime/routes/admin-entry.js.map +1 -1
  100. package/dist/runtime/routes/admin.d.ts.map +1 -1
  101. package/dist/runtime/routes/admin.js +33 -15
  102. package/dist/runtime/routes/admin.js.map +1 -1
  103. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  104. package/dist/runtime/routes/auth-login.js +4 -0
  105. package/dist/runtime/routes/auth-login.js.map +1 -1
  106. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  107. package/dist/runtime/routes/auth-logout.js +7 -2
  108. package/dist/runtime/routes/auth-logout.js.map +1 -1
  109. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  110. package/dist/runtime/routes/auth-session.js +4 -2
  111. package/dist/runtime/routes/auth-session.js.map +1 -1
  112. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  113. package/dist/runtime/routes/collection-list.js +64 -41
  114. package/dist/runtime/routes/collection-list.js.map +1 -1
  115. package/dist/runtime/routes/entries.d.ts.map +1 -1
  116. package/dist/runtime/routes/entries.js +16 -6
  117. package/dist/runtime/routes/entries.js.map +1 -1
  118. package/dist/runtime/routes/history.d.ts.map +1 -1
  119. package/dist/runtime/routes/history.js +50 -22
  120. package/dist/runtime/routes/history.js.map +1 -1
  121. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  122. package/dist/runtime/routes/mutate.js +40 -6
  123. package/dist/runtime/routes/mutate.js.map +1 -1
  124. package/dist/runtime/routes/publish.d.ts.map +1 -1
  125. package/dist/runtime/routes/publish.js +5 -2
  126. package/dist/runtime/routes/publish.js.map +1 -1
  127. package/dist/runtime/routes/schema.d.ts.map +1 -1
  128. package/dist/runtime/routes/schema.js +11 -3
  129. package/dist/runtime/routes/schema.js.map +1 -1
  130. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  131. package/dist/runtime/routes/studio-home.js +38 -19
  132. package/dist/runtime/routes/studio-home.js.map +1 -1
  133. package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
  134. package/dist/runtime/routes/theme-stylesheet.js +11 -1
  135. package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
  136. package/dist/runtime/schema-registry.d.ts +8 -1
  137. package/dist/runtime/schema-registry.d.ts.map +1 -1
  138. package/dist/runtime/schema-registry.js +47 -3
  139. package/dist/runtime/schema-registry.js.map +1 -1
  140. package/dist/runtime/storage/atomic-write.d.ts.map +1 -1
  141. package/dist/runtime/storage/atomic-write.js +8 -3
  142. package/dist/runtime/storage/atomic-write.js.map +1 -1
  143. package/dist/runtime/storage/markdown-adapter.d.ts +19 -0
  144. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  145. package/dist/runtime/storage/markdown-adapter.js +41 -0
  146. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  147. package/dist/runtime/storage/session-overlay-adapter.d.ts +8 -0
  148. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  149. package/dist/runtime/storage/session-overlay-adapter.js +24 -2
  150. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  151. package/dist/runtime/themes/preset-tokens.js +4 -4
  152. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  153. package/dist/runtime/utils.d.ts +10 -0
  154. package/dist/runtime/utils.d.ts.map +1 -1
  155. package/dist/runtime/utils.js +16 -0
  156. package/dist/runtime/utils.js.map +1 -1
  157. package/dist/runtime/views/studio-layout.d.ts +3 -0
  158. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  159. package/dist/runtime/views/studio-layout.js +36 -13
  160. package/dist/runtime/views/studio-layout.js.map +1 -1
  161. package/dist/schema-utils.d.ts +7 -0
  162. package/dist/schema-utils.d.ts.map +1 -1
  163. package/dist/schema-utils.js +143 -0
  164. package/dist/schema-utils.js.map +1 -1
  165. package/dist/types.d.ts +83 -1
  166. package/dist/types.d.ts.map +1 -1
  167. package/package.json +2 -2
  168. package/static/cms/admin-entry.css +135 -13
  169. package/static/cms/admin-entry.js +509 -86
  170. package/static/cms/editor/guards.js +1 -1
  171. package/static/cms/editor/image-edit.js +42 -11
  172. package/static/cms/editor/image.css +7 -1
  173. package/static/cms/editor/md-block-edit.js +176 -0
  174. package/static/cms/editor/md-serialize.js +301 -0
  175. package/static/cms/editor/panel.css +69 -25
  176. package/static/cms/editor/panel.js +58 -1
  177. package/static/cms/editor/rich-toolbar.js +4 -3
  178. package/static/cms/editor/sanitize.js +2 -1
  179. package/static/cms/editor/sync.js +277 -56
  180. package/static/cms/editor/text-edit.js +68 -17
  181. package/static/cms/editor/text-link-interactions.js +4 -4
  182. package/static/cms/editor/toast.css +114 -2
  183. package/static/cms/editor/toast.js +121 -22
  184. package/static/cms/editor/tokens-and-text.css +73 -3
  185. package/static/cms/editor/toolbar.css +19 -6
  186. package/static/cms/editor/toolbar.js +64 -23
  187. package/static/cms/editor.css +1 -1
  188. package/static/cms/editor.js +14 -3
  189. package/static/cms/studio-tokens.css +4 -4
  190. package/static/cms/studio.css +24 -0
@@ -2,7 +2,7 @@ export function mountEditorGuards(state) {
2
2
  document.body.classList.add('cms-edit-mode');
3
3
 
4
4
  window.addEventListener('beforeunload', (e) => {
5
- if (state.dirtyEl) {
5
+ if (state.dirtyEls.size > 0) {
6
6
  e.preventDefault();
7
7
  e.returnValue = '';
8
8
  }
@@ -72,6 +72,7 @@ export function mountImageEditors({
72
72
  const input = document.createElement('input');
73
73
  input.type = 'file';
74
74
  input.accept = 'image/jpeg,image/png,image/webp,image/avif';
75
+ input.setAttribute('aria-label', 'Upload replacement image');
75
76
  input.style.display = 'none';
76
77
  wrapper.appendChild(input);
77
78
 
@@ -80,6 +81,34 @@ export function mountImageEditors({
80
81
  input.click();
81
82
  });
82
83
 
84
+ // Save-conflict resolution for images: the upload itself already succeeded
85
+ // (we hold the stored `mineUrl`); only writing it into the entry conflicted.
86
+ // Keep the uploaded image shown and let the user choose, rather than
87
+ // silently swapping in whatever landed elsewhere.
88
+ const runImageConflict = (resolved, marker, mineUrl, latestValue) => {
89
+ showToast.conflict({
90
+ message: 'This image changed elsewhere while you were editing.',
91
+ onKeepMine: async () => {
92
+ setStatus('saving', 'Saving...');
93
+ const retry = await saveField(resolved.collection, resolved.id, resolved.field, mineUrl);
94
+ flash(img, retry.ok);
95
+ if (retry.ok) {
96
+ syncBoundImages(marker, mineUrl);
97
+ showToast('Your image saved', 'success');
98
+ } else if (retry.reason === 'conflict') {
99
+ runImageConflict(resolved, marker, mineUrl, retry.latestValue);
100
+ } else if (retry.reason !== 'unauthorized') {
101
+ showToast('Failed to save image. Try again.', 'error');
102
+ }
103
+ },
104
+ onLoadLatest: () => {
105
+ const src = typeof latestValue === 'string' ? latestValue : mineUrl;
106
+ syncBoundImages(marker, src);
107
+ showToast('Loaded latest image', 'success');
108
+ },
109
+ });
110
+ };
111
+
83
112
  input.addEventListener('change', async () => {
84
113
  const file = input.files?.[0];
85
114
  if (!file) return;
@@ -110,27 +139,29 @@ export function mountImageEditors({
110
139
  body: formData,
111
140
  });
112
141
  if (uploadRes.status === 401) {
142
+ // Restore the real image before the redirect — the optimistic blob is
143
+ // revoked in `finally`, so leaving it as src would flash a broken image.
144
+ img.src = originalSrc;
113
145
  onUnauthorized();
114
146
  return;
115
147
  }
116
148
  if (!uploadRes.ok) throw new Error('Upload failed');
117
149
  const { url } = await uploadRes.json();
118
150
 
151
+ // Point at the durable uploaded URL now, so the shown image survives the
152
+ // optimistic blob being revoked in `finally` (and any conflict prompt).
153
+ img.src = url;
154
+
119
155
  const result = await saveField(resolved.collection, resolved.id, resolved.field, url);
120
156
  if (result.ok) {
121
157
  syncBoundImages(marker, url);
122
158
  showToast('Image updated', 'success');
123
- } else {
124
- const rollbackSrc =
125
- result.reason === 'conflict' && typeof result.latestValue === 'string'
126
- ? result.latestValue
127
- : originalSrc;
128
- syncBoundImages(marker, rollbackSrc);
129
- if (result.reason === 'conflict') {
130
- showToast('Image changed elsewhere. Loaded latest image.', 'error');
131
- } else if (result.reason !== 'unauthorized') {
132
- showToast('Failed to save image. Changes reverted.', 'error');
133
- }
159
+ } else if (result.reason === 'conflict') {
160
+ // Keep the uploaded image shown; let the user choose.
161
+ runImageConflict(resolved, marker, url, result.latestValue);
162
+ } else if (result.reason !== 'unauthorized') {
163
+ syncBoundImages(marker, originalSrc);
164
+ showToast('Failed to save image. Changes reverted.', 'error');
134
165
  }
135
166
 
136
167
  flash(img, result.ok);
@@ -65,10 +65,16 @@
65
65
  z-index: 11;
66
66
  }
67
67
 
68
- .cms-img-wrapper:hover .cms-img-overlay {
68
+ .cms-img-wrapper:hover .cms-img-overlay,
69
+ .cms-img-wrapper:focus-within .cms-img-overlay {
69
70
  opacity: 1;
70
71
  }
71
72
 
73
+ .cms-img-overlay:focus-visible {
74
+ outline: 2px solid var(--cms-accent, #3b82f6);
75
+ outline-offset: -2px;
76
+ }
77
+
72
78
  .cms-img-overlay-content {
73
79
  display: flex;
74
80
  flex-direction: column;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Inline editing for markdown body blocks (`data-caret-md`).
3
+ *
4
+ * A stamped block (paragraph / heading / list item, bound by the render-time
5
+ * mdast plugin) becomes contenteditable; the floating rich toolbar provides
6
+ * bold/italic/link. On blur the block's sanitized inner HTML is sent as an
7
+ * `md_block` mutation — the server derives the markdown, verifies the source
8
+ * range hash, and stores the draft in the editor's overlay. The `.md` file is
9
+ * only touched at Publish.
10
+ *
11
+ * No `expectedRevision` is sent: body drafts live in a PER-EDITOR overlay, so
12
+ * there is no cross-editor race on the draft itself — the source-range hash in
13
+ * `data-caret-md-src` is the staleness guard that matters, and a 409 here
14
+ * means the underlying file changed (publish, external edit) and the page must
15
+ * be reloaded to pick up fresh stamps.
16
+ */
17
+
18
+ import { serializeBlock, domToSNodes, SerializeError } from './md-serialize.js';
19
+ import { sanitizeHtml } from './sanitize.js';
20
+ import { buildCmsUrl } from './config.js';
21
+ import { mutateHeaders } from './security.js';
22
+
23
+ const snapshots = new WeakMap();
24
+
25
+ // All md-block saves are chained through one queue (same pattern as
26
+ // save-queue.js): two fast blur-saves of the same block can otherwise race on
27
+ // the network and land out of order — both return ok, and the older content
28
+ // silently wins. A per-element generation counter additionally drops a queued
29
+ // save that a newer blur has superseded.
30
+ let saveChain = Promise.resolve();
31
+ const generations = new WeakMap();
32
+
33
+ function enqueueSave(task) {
34
+ const run = saveChain.then(task, task);
35
+ saveChain = run.then(
36
+ () => undefined,
37
+ () => undefined,
38
+ );
39
+ return run;
40
+ }
41
+
42
+ /** Parse `collection::id::body::blockPath`, or null. */
43
+ function parseMdBinding(raw) {
44
+ const parts = (raw || '').split('::');
45
+ if (parts.length !== 4 || parts[2] !== 'body') return null;
46
+ if (!/^\d+(\.\d+)*$/.test(parts[3])) return null;
47
+ return { collection: parts[0], id: parts[1], blockPath: parts[3] };
48
+ }
49
+
50
+ /** Serialization context from the DOM — mirrors the server's derivation. */
51
+ function blockContextOf(el) {
52
+ const heading = /^h([1-6])$/.exec(el.tagName.toLowerCase());
53
+ if (heading) return { block: 'heading', level: Number(heading[1]) };
54
+ const nested = el.tagName.toLowerCase() === 'li' || el.closest('li, blockquote') !== null;
55
+ return { block: 'paragraph', nested };
56
+ }
57
+
58
+ async function saveMdBlock(binding, src, html) {
59
+ const res = await fetch(buildCmsUrl('/mutate'), {
60
+ method: 'POST',
61
+ headers: await mutateHeaders(),
62
+ body: JSON.stringify({
63
+ type: 'md_block',
64
+ collection: binding.collection,
65
+ id: binding.id,
66
+ blockPath: binding.blockPath,
67
+ src,
68
+ html,
69
+ }),
70
+ });
71
+ if (res.status === 401) return { ok: false, reason: 'unauthorized' };
72
+ if (res.status === 409) return { ok: false, reason: 'stale' };
73
+ if (!res.ok) return { ok: false, reason: 'error' };
74
+ return { ok: true };
75
+ }
76
+
77
+ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized }) {
78
+ document.querySelectorAll('[data-caret-md]').forEach((el) => {
79
+ if (!(el instanceof HTMLElement)) return;
80
+ if (el.dataset.caretMdMounted === 'true') return;
81
+
82
+ const binding = parseMdBinding(el.getAttribute('data-caret-md'));
83
+ const src = el.getAttribute('data-caret-md-src') || '';
84
+ if (!binding || !src) return;
85
+ el.dataset.caretMdMounted = 'true';
86
+
87
+ const ctx = blockContextOf(el);
88
+
89
+ el.setAttribute('contenteditable', 'true');
90
+ el.classList.add('cms-editable', 'cms-md-block');
91
+
92
+ el.addEventListener('keydown', (event) => {
93
+ if (event.key !== 'Enter') return;
94
+ if (event.shiftKey && ctx.block === 'paragraph' && !ctx.nested) {
95
+ return; // hard break in a top-level paragraph — allowed
96
+ }
97
+ event.preventDefault();
98
+ showToast(
99
+ event.shiftKey
100
+ ? 'Line breaks are not supported in this block'
101
+ : 'Adding new blocks is coming in a future version',
102
+ 'error',
103
+ );
104
+ });
105
+
106
+ el.addEventListener('focus', () => {
107
+ snapshots.set(el, el.innerHTML);
108
+ });
109
+
110
+ el.addEventListener('input', () => {
111
+ if (el.innerHTML !== snapshots.get(el)) state.dirtyEls.add(el);
112
+ else state.dirtyEls.delete(el);
113
+ });
114
+
115
+ el.addEventListener('blur', async () => {
116
+ if (el._caretSkipSave) {
117
+ el._caretSkipSave = false;
118
+ state.dirtyEls.delete(el);
119
+ el.innerHTML = snapshots.get(el) || '';
120
+ return;
121
+ }
122
+
123
+ const snapshot = snapshots.get(el) ?? '';
124
+ const clean = sanitizeHtml(el.innerHTML);
125
+ el.innerHTML = clean; // normalize what the user sees to what will save
126
+ if (clean === snapshot) return;
127
+
128
+ // Pre-flight the serialization locally so unrepresentable content fails
129
+ // with an immediate message instead of a server round-trip. The server
130
+ // re-derives independently — this is UX, not the trust boundary.
131
+ try {
132
+ serializeBlock(domToSNodes(el), ctx);
133
+ } catch (error) {
134
+ if (error instanceof SerializeError) {
135
+ showToast('This formatting cannot be saved in a markdown block', 'error');
136
+ state.dirtyEls.add(el);
137
+ return;
138
+ }
139
+ throw error;
140
+ }
141
+
142
+ const generation = (generations.get(el) ?? 0) + 1;
143
+ generations.set(el, generation);
144
+
145
+ el.classList.add('cms-saving');
146
+ const result = await enqueueSave(() => {
147
+ // A newer blur queued behind us supersedes this content — skip so the
148
+ // older payload can't overwrite the newer draft.
149
+ if (generations.get(el) !== generation) return { ok: true, skipped: true };
150
+ return saveMdBlock(binding, src, clean);
151
+ });
152
+ el.classList.remove('cms-saving');
153
+ if (result.skipped) return;
154
+ flash(el, result.ok);
155
+
156
+ if (result.ok) {
157
+ state.dirtyEls.delete(el);
158
+ snapshots.set(el, clean);
159
+ showToast('Draft saved — publish to update the file', 'success');
160
+ // Signal the toolbar so server-delivery mode reveals its Publish/Discard
161
+ // controls (hidden until a body draft is pending).
162
+ window.dispatchEvent(new CustomEvent('cms:draftSaved'));
163
+ } else if (result.reason === 'stale') {
164
+ state.dirtyEls.delete(el);
165
+ el.innerHTML = snapshot;
166
+ showToast('This content changed since the page loaded. Reload to edit it.', 'error');
167
+ } else if (result.reason === 'unauthorized') {
168
+ onUnauthorized();
169
+ } else {
170
+ state.dirtyEls.delete(el);
171
+ el.innerHTML = snapshot;
172
+ showToast('Failed to save. Changes reverted.', 'error');
173
+ }
174
+ });
175
+ });
176
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * DOM → markdown block serializer (browser side).
3
+ *
4
+ * NOTE: `serializeBlock` and its helpers below are a HAND MIRROR of
5
+ * src/markdown/serialize.ts (this file ships as raw unbundled JS and can't
6
+ * import it). tests/unit/md-serialize-parity.test.ts runs both implementations
7
+ * over a shared corpus of node trees and fails CI on any divergence.
8
+ *
9
+ * `domToSNodes` is the only DOM-touching part: it converts a block element's
10
+ * child nodes into the same minimal tree the TS serializer is tested against,
11
+ * so the serialization logic itself stays DOM-free and parity-checkable.
12
+ *
13
+ * @typedef {{ type: 'text', value: string }
14
+ * | { type: 'element', tag: string, attrs: Record<string,string>, children: SNode[] }} SNode
15
+ */
16
+
17
+ export class SerializeError extends Error {
18
+ /** @param {string} message */
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'SerializeError';
22
+ }
23
+ }
24
+
25
+ const INLINE_ESCAPE_RE = /[\\`*_[\]<&>~]/g;
26
+
27
+ /**
28
+ * @param {string} value
29
+ * @returns {string}
30
+ */
31
+ function escapeText(value) {
32
+ // Collapse ASCII whitespace runs to a single space FIRST — matches HTML
33
+ // rendering and makes <br> the serializer's only newline source. nbsp is
34
+ // deliberately NOT collapsed.
35
+ return value.replace(/[ \t\r\n]+/g, ' ').replace(INLINE_ESCAPE_RE, (c) => `\\${c}`);
36
+ }
37
+
38
+ /**
39
+ * @param {string} md
40
+ * @returns {string}
41
+ */
42
+ function escapeLineStart(md) {
43
+ const ws = /^\s*/.exec(md)[0];
44
+ const rest = md.slice(ws.length);
45
+
46
+ const ordered = /^(\d{1,9})([.)])(\s|$)/.exec(rest);
47
+ if (ordered) {
48
+ return `${ws}${ordered[1]}\\${ordered[2]}${rest.slice(ordered[1].length + 1)}`;
49
+ }
50
+
51
+ // Dash/equals runs cover setext underlines (ONE-or-more `-`/`=`) and
52
+ // thematic breaks with interior spaces; `*`/`_` variants are already
53
+ // neutralized by escapeText.
54
+ const marker =
55
+ /^(?:#{1,6}(?=\s|$)|>|[-+*](?=\s|$)|-(?:[ \t]*-)*[ \t]*$|=(?:[ \t]*=)*[ \t]*$|~{3,}|`{3,}|\|)/.test(
56
+ rest,
57
+ );
58
+ return marker ? `${ws}\\${rest}` : md;
59
+ }
60
+
61
+ /**
62
+ * @param {Extract<SNode, { type: 'element' }>} node
63
+ * @returns {string}
64
+ */
65
+ function tagOf(node) {
66
+ const t = node.tag.toLowerCase();
67
+ if (t === 'b') return 'strong';
68
+ if (t === 'i') return 'em';
69
+ return t;
70
+ }
71
+
72
+ /**
73
+ * @param {readonly SNode[]} nodes
74
+ * @returns {string}
75
+ */
76
+ function rawText(nodes) {
77
+ let out = '';
78
+ for (const n of nodes) {
79
+ if (n.type === 'text') out += n.value;
80
+ else if (n.tag.toLowerCase() === 'br') out += ' ';
81
+ else out += rawText(n.children);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * @param {string} content
88
+ * @returns {string}
89
+ */
90
+ function codeSpan(content) {
91
+ if (content === '') return '``';
92
+ const runs = content.match(/`+/g) ?? [];
93
+ let fenceLen = 1;
94
+ while (runs.some((r) => r.length === fenceLen)) fenceLen++;
95
+ const fence = '`'.repeat(fenceLen);
96
+ const needsPad =
97
+ content.startsWith('`') ||
98
+ content.endsWith('`') ||
99
+ (content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '');
100
+ return needsPad ? `${fence} ${content} ${fence}` : `${fence}${content}${fence}`;
101
+ }
102
+
103
+ /**
104
+ * @param {string} href
105
+ * @returns {string}
106
+ */
107
+ function linkDestination(href) {
108
+ // Control chars (incl. \n, \r) have no representable escape in either
109
+ // destination form — hard-fail rather than inject markdown structure.
110
+ // eslint-disable-next-line no-control-regex
111
+ if (/[\u0000-\u001f\u007f]/u.test(href)) {
112
+ throw new SerializeError('control character in link destination');
113
+ }
114
+ if (/^[^\s<>()\\]*$/.test(href)) return href;
115
+ return `<${href.replace(/([<>\\])/g, '\\$1')}>`;
116
+ }
117
+
118
+ /**
119
+ * @param {Record<string,string>} a
120
+ * @param {Record<string,string>} b
121
+ * @returns {boolean}
122
+ */
123
+ function sameAttrs(a, b) {
124
+ const ka = Object.keys(a);
125
+ const kb = Object.keys(b);
126
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
127
+ }
128
+
129
+ /**
130
+ * @param {readonly SNode[]} nodes
131
+ * @returns {SNode[]}
132
+ */
133
+ function mergeAdjacent(nodes) {
134
+ /** @type {SNode[]} */
135
+ const out = [];
136
+ for (const node of nodes) {
137
+ const prev = out[out.length - 1];
138
+ if (node.type === 'text' && prev?.type === 'text') {
139
+ prev.value += node.value;
140
+ continue;
141
+ }
142
+ if (
143
+ node.type === 'element' &&
144
+ prev?.type === 'element' &&
145
+ tagOf(prev) === tagOf(node) &&
146
+ tagOf(node) !== 'br' &&
147
+ sameAttrs(prev.attrs, node.attrs)
148
+ ) {
149
+ prev.children = [...prev.children, ...node.children];
150
+ continue;
151
+ }
152
+ out.push(node.type === 'text' ? { ...node } : { ...node, children: [...node.children] });
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * @param {readonly SNode[]} nodes
159
+ * @param {number} i
160
+ * @returns {string}
161
+ */
162
+ function nextLeadChar(nodes, i) {
163
+ const next = nodes[i + 1];
164
+ if (!next) return '';
165
+ if (next.type === 'text') return next.value[0] ?? '';
166
+ return '<';
167
+ }
168
+
169
+ /**
170
+ * @param {readonly SNode[]} nodes
171
+ * @param {number} i
172
+ * @param {'*' | '_' | null} prevMarker
173
+ * @returns {'*' | '_'}
174
+ */
175
+ function emphasisMarker(nodes, i, prevMarker) {
176
+ if (prevMarker === null) return '*';
177
+ const flipped = prevMarker === '*' ? '_' : '*';
178
+ if (flipped === '_' && /[A-Za-z0-9_]/.test(nextLeadChar(nodes, i))) return '*';
179
+ return flipped;
180
+ }
181
+
182
+ /**
183
+ * @param {readonly SNode[]} rawNodes
184
+ * @param {{ allowBreak: boolean }} opts
185
+ * @returns {string}
186
+ */
187
+ function serializeChildren(rawNodes, opts) {
188
+ const nodes = mergeAdjacent(rawNodes);
189
+ let out = '';
190
+ /** @type {'*' | '_' | null} */
191
+ let prevMarker = null;
192
+ for (let i = 0; i < nodes.length; i++) {
193
+ const node = nodes[i];
194
+ if (node.type === 'text') {
195
+ out += escapeText(node.value);
196
+ prevMarker = null;
197
+ continue;
198
+ }
199
+ const tag = tagOf(node);
200
+ switch (tag) {
201
+ case 'strong': {
202
+ const inner = serializeChildren(node.children, opts);
203
+ if (inner === '') {
204
+ prevMarker = null;
205
+ break;
206
+ }
207
+ const m = emphasisMarker(nodes, i, prevMarker);
208
+ out += `${m}${m}${inner}${m}${m}`;
209
+ prevMarker = m;
210
+ break;
211
+ }
212
+ case 'em': {
213
+ const inner = serializeChildren(node.children, opts);
214
+ if (inner === '') {
215
+ prevMarker = null;
216
+ break;
217
+ }
218
+ const m = emphasisMarker(nodes, i, prevMarker);
219
+ out += `${m}${inner}${m}`;
220
+ prevMarker = m;
221
+ break;
222
+ }
223
+ case 'code': {
224
+ prevMarker = null;
225
+ out += codeSpan(rawText(node.children));
226
+ break;
227
+ }
228
+ case 'a': {
229
+ const href = node.attrs.href;
230
+ if (href === undefined) throw new SerializeError('<a> without href');
231
+ prevMarker = null;
232
+ const inner = serializeChildren(node.children, opts);
233
+ out += `[${inner}](${linkDestination(href)})`;
234
+ break;
235
+ }
236
+ case 'br': {
237
+ if (!opts.allowBreak) {
238
+ throw new SerializeError('hard break not allowed in a nested block');
239
+ }
240
+ prevMarker = null;
241
+ out += '\\\n';
242
+ break;
243
+ }
244
+ default:
245
+ throw new SerializeError(`unsupported inline element <${node.tag}>`);
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+
251
+ /**
252
+ * Serialize the inline children of one block to markdown source.
253
+ * @param {readonly SNode[]} children
254
+ * @param {{ block: 'heading', level: number } | { block: 'paragraph', nested: boolean }} ctx
255
+ * @returns {string}
256
+ */
257
+ export function serializeBlock(children, ctx) {
258
+ if (ctx.block === 'heading') {
259
+ if (!Number.isInteger(ctx.level) || ctx.level < 1 || ctx.level > 6) {
260
+ throw new SerializeError(`invalid heading level: ${ctx.level}`);
261
+ }
262
+ const inline = serializeChildren(children, { allowBreak: false });
263
+ return `${'#'.repeat(ctx.level)} ${inline}`;
264
+ }
265
+ const inline = serializeChildren(children, { allowBreak: !ctx.nested });
266
+ // Every line (hard breaks are the only line boundary) must be prevented
267
+ // from opening a new block — CommonMark's block scanner runs before inline
268
+ // parsing sees the trailing backslash.
269
+ return inline.split('\\\n').map(escapeLineStart).join('\\\n');
270
+ }
271
+
272
+ /**
273
+ * Convert a block element's DOM children into the minimal SNode tree the
274
+ * serializer consumes. Only `href` is carried (the sole attribute the closed
275
+ * set represents); every other element passes through so the serializer can
276
+ * reject anything the sanitizer failed to strip.
277
+ * @param {Node} element
278
+ * @returns {SNode[]}
279
+ */
280
+ export function domToSNodes(element) {
281
+ /** @type {SNode[]} */
282
+ const out = [];
283
+ for (const child of element.childNodes) {
284
+ if (child.nodeType === 3 /* TEXT_NODE */) {
285
+ out.push({ type: 'text', value: /** @type {Text} */ (child).data });
286
+ } else if (child.nodeType === 1 /* ELEMENT_NODE */) {
287
+ const elem = /** @type {Element} */ (child);
288
+ /** @type {Record<string,string>} */
289
+ const attrs = {};
290
+ const href = elem.getAttribute('href');
291
+ if (href !== null) attrs.href = href;
292
+ out.push({
293
+ type: 'element',
294
+ tag: elem.tagName.toLowerCase(),
295
+ attrs,
296
+ children: domToSNodes(elem),
297
+ });
298
+ }
299
+ }
300
+ return out;
301
+ }