@uniweb/content-writer 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "ProseMirror document structure to Markdown converter",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/normalize.js CHANGED
@@ -193,14 +193,8 @@ function mapEditorNode(node) {
193
193
  case 'Icon':
194
194
  return mapUniwebIcon(node)
195
195
  case 'WarningBlock':
196
- case 'details': {
197
- const attrs = EDITOR_CONTAINERS[node.type](node)
198
- return {
199
- type: 'inset_block',
200
- attrs,
201
- content: node.type === 'details' ? detailsBody(node) : asBlocks(node.content),
202
- }
203
- }
196
+ case 'details':
197
+ return EDITOR_CONTAINERS[node.type](node)
204
198
  case 'emoji':
205
199
  // The node carries the character alongside a name; the character is the
206
200
  // whole markdown form. Guarded rather than assumed to be populated.
@@ -256,8 +250,25 @@ const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
256
250
  * missing entry here is a visible gap rather than a silent assumption.
257
251
  */
258
252
  const EDITOR_CONTAINERS = {
259
- WarningBlock: node => ({ component: 'Alert', type: node.attrs?.type || 'info' }),
260
- details: () => ({ component: 'Details' }),
253
+ // A callout's SEVERITY is the concept, not a parameter on one — a warning and
254
+ // a note are different kinds of thing, so they are different tags. This is
255
+ // where the editor's `type` attribute goes.
256
+ WarningBlock: node => ({
257
+ type: 'concept_block',
258
+ attrs: { tag: node.attrs?.type || 'info' },
259
+ content: asBlocks(node.content),
260
+ }),
261
+ // The summary becomes the leading HEADING rather than a paragraph, which is
262
+ // what makes the mapping lossless in both directions: a concept block's items
263
+ // come from its headings, so the summary recovers as that item's title and
264
+ // the body as its paragraphs. Flattening it to a paragraph — what this did
265
+ // while the target was an inset — made the two indistinguishable on the way
266
+ // back.
267
+ details: node => ({
268
+ type: 'concept_block',
269
+ attrs: { tag: 'details' },
270
+ content: detailsBody(node),
271
+ }),
261
272
  }
262
273
 
263
274
  /** Inline node types, for deciding whether a container's body needs wrapping. */
@@ -289,11 +300,18 @@ function asBlocks(content) {
289
300
  }
290
301
 
291
302
  /**
292
- * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into the
293
- * container's body, summary first.
303
+ * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into a
304
+ * concept block's body, summary first — as a HEADING.
294
305
  *
295
- * The summary leads rather than riding on an attribute: `detailsSummary` can
296
- * carry marks, and `summary="…"` would flatten emphasis and links.
306
+ * The heading is what makes the mapping lossless. A concept block's items come
307
+ * from its headings, so a summary written as `# …` recovers as that item's
308
+ * title and the body as its paragraphs; written as a paragraph (which is what
309
+ * this did while the target was an inset) the two become indistinguishable on
310
+ * the way back, and a disclosure loses which half was its label.
311
+ *
312
+ * The summary stays inline content rather than moving to an attribute for the
313
+ * original reason: `detailsSummary` can carry marks, and `summary="…"` would
314
+ * flatten emphasis and links.
297
315
  *
298
316
  * @param {Object} node
299
317
  * @returns {Array}
@@ -301,8 +319,11 @@ function asBlocks(content) {
301
319
  function detailsBody(node) {
302
320
  const body = []
303
321
  for (const child of node.content || []) {
304
- if (child.type === 'detailsSummary') body.push(...asBlocks(child.content))
305
- else if (child.type === 'detailsContent') body.push(...(child.content || []))
322
+ if (child.type === 'detailsSummary') {
323
+ if (child.content?.length) {
324
+ body.push({ type: 'heading', attrs: { level: 1 }, content: child.content })
325
+ }
326
+ } else if (child.type === 'detailsContent') body.push(...(child.content || []))
306
327
  else body.push(child)
307
328
  }
308
329
  return body
@@ -263,22 +263,34 @@ describe('containers — the block form of an inset', () => {
263
263
  expect(rt(source)).toBe(source)
264
264
  })
265
265
 
266
- test('the editor container shapes normalize onto it', () => {
266
+ test('the editor containers normalize onto CONCEPT blocks, not insets', () => {
267
+ // Changed 2026-07-31. These used to become `@Alert` and `@Details` — an
268
+ // inset naming the component that renders it, which is a rendering
269
+ // decision living in content. A concept block names what the content IS
270
+ // and leaves rendering to the foundation.
267
271
  const text = t => ({ type: 'text', text: t })
268
272
  const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
269
273
 
270
- // WarningBlock ↔ @Alert, defaulting to info, NOT warning — a name-derived
271
- // mapping drops exactly this.
274
+ // The editor's `type` becomes the TAG: a warning and a note are different
275
+ // kinds of thing, not one kind with a parameter. `info` is still the
276
+ // default, and still not `warning` — a name-derived mapping drops that.
272
277
  expect(doc({ type: 'WarningBlock', attrs: {}, content: [text('Careful.')] }))
273
- .toBe('```@Alert{type=info}\nCareful.\n```')
274
-
275
- // The summary is the first block and keeps its marks.
278
+ .toBe('```md:info\nCareful.\n```')
279
+ expect(doc({ type: 'WarningBlock', attrs: { type: 'warning' }, content: [text('Careful.')] }))
280
+ .toBe('```md:warning\nCareful.\n```')
281
+
282
+ // The summary becomes the leading HEADING, which is what makes this
283
+ // lossless: a concept block's items come from its headings, so the summary
284
+ // recovers as the item's title and the body as its paragraphs. As a
285
+ // paragraph — what this produced while the target was an inset — the two
286
+ // were indistinguishable coming back.
276
287
  expect(doc({
277
288
  type: 'details',
278
289
  content: [
279
290
  { type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
280
291
  { type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
281
292
  ],
282
- })).toBe('```@Details\nBreaking **changes**\n\nBody.\n```')
293
+ })).toBe('```md:details\n# Breaking **changes**\n\nBody.\n```')
283
294
  })
295
+
284
296
  })