@nuasite/cms 0.45.0 → 0.46.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
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.45.0",
17
+ "version": "0.46.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@nuasite/cms-core": "0.45.0",
30
- "@nuasite/cms-types": "0.45.0",
29
+ "@nuasite/cms-core": "0.46.1",
30
+ "@nuasite/cms-types": "0.46.1",
31
31
  "@astrojs/compiler": "^3.0.1",
32
32
  "@babel/parser": "^7.29.2",
33
33
  "node-html-parser": "^7.1.0",
@@ -35,8 +35,8 @@
35
35
  "yaml": "^2.8.3"
36
36
  },
37
37
  "devDependencies": {
38
- "@nuasite/cms-sidecar": "0.45.0",
39
- "@nuasite/collections-admin": "0.45.0",
38
+ "@nuasite/cms-sidecar": "0.46.1",
39
+ "@nuasite/collections-admin": "0.46.1",
40
40
  "@babel/types": "^7.29.0",
41
41
  "@types/react": "^19.2.7",
42
42
  "@types/react-dom": "^19.2.3",
@@ -50,6 +50,8 @@
50
50
  "@milkdown/prose": "^7.20.0",
51
51
  "@milkdown/transformer": "^7.20.0",
52
52
  "@milkdown/utils": "^7.20.0",
53
+ "mdast-util-directive": "^3.1.0",
54
+ "micromark-extension-directive": "^3.0.2",
53
55
  "remark-mdx": "^3.1.0",
54
56
  "unified": "^11.0.5",
55
57
  "@preact/signals": "^2.9.0",
@@ -74,8 +76,8 @@
74
76
  "typescript": "^6.0.2",
75
77
  "vite": "^7.0.0",
76
78
  "@aws-sdk/client-s3": "^3.0.0",
77
- "@nuasite/cms-sidecar": "0.45.0",
78
- "@nuasite/collections-admin": "0.45.0",
79
+ "@nuasite/cms-sidecar": "0.46.1",
80
+ "@nuasite/collections-admin": "0.46.1",
79
81
  "react": "^19.0.0",
80
82
  "react-dom": "^19.0.0"
81
83
  },
@@ -20,7 +20,7 @@ import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-p
20
20
  import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
21
21
  import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
22
22
  import { STRINGS } from '../strings'
23
- import { setBulletListStyleCommand, styledListPlugin } from '../styled-list-plugin'
23
+ import { mdxDirectiveSafetyPlugin, setBulletListStyleCommand, styledListPlugin } from '../styled-list-plugin'
24
24
  import { LinkEditPopover } from './link-edit-popover'
25
25
  import { MdxComponentIcon } from './mdx-block-view'
26
26
  import { MdxComponentPicker } from './mdx-component-picker'
@@ -94,11 +94,15 @@ export function MarkdownInlineEditor({
94
94
  })
95
95
  .use(commonmark)
96
96
 
97
- // Styled bullet lists are opt-in: only load the plugin (and its `-` bullet
97
+ // Styled bullet lists are opt-in: only load the full plugin (and its `-` bullet
98
98
  // normalization) when the site configures list styles, so sites that don't use
99
- // the feature keep their previous list serialization untouched.
99
+ // the feature keep their previous list serialization untouched. For .mdx editing
100
+ // without list styles, still load the directive-safety subset so `:::list{.class}`
101
+ // and stray colons don't crash acorn under remark-mdx.
100
102
  if (listStyles.length > 0) {
101
103
  builder.use(styledListPlugin)
104
+ } else if (isMdxRef.current) {
105
+ builder.use(mdxDirectiveSafetyPlugin)
102
106
  }
103
107
 
104
108
  builder
@@ -4,8 +4,11 @@ import type { Node as PmNode } from '@milkdown/prose/model'
4
4
  import type { Command } from '@milkdown/prose/state'
5
5
  import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
6
6
  import { $command, $remark } from '@milkdown/utils'
7
+ import { directiveFromMarkdown } from 'mdast-util-directive'
8
+ import { directive } from 'micromark-extension-directive'
7
9
 
8
10
  const LIST_DIRECTIVE_NAME = 'list'
11
+ const DIRECTIVE_TYPES = new Set(['textDirective', 'leafDirective', 'containerDirective'])
9
12
 
10
13
  function normalizeListStyleClass(value: unknown): string | null {
11
14
  if (typeof value !== 'string') return null
@@ -56,6 +59,61 @@ function stripRawClosingDirective(listNode: any, siblings: any[], indexAfterList
56
59
  return true
57
60
  }
58
61
 
62
+ interface DirectiveLike {
63
+ type: string
64
+ name?: unknown
65
+ value?: unknown
66
+ attributes?: unknown
67
+ children?: DirectiveLike[]
68
+ }
69
+
70
+ function directiveMarker(type: string): string {
71
+ if (type === 'containerDirective') return ':::'
72
+ if (type === 'leafDirective') return '::'
73
+ return ':'
74
+ }
75
+
76
+ // Rebuild the `{...}` attribute block from parsed directive attributes. A bare token
77
+ // like `:::youtube{id}` parses to `{ id: '' }`, so an empty value must round-trip back
78
+ // to the bare key (not `key=""`).
79
+ function stringifyAttributes(attributes: unknown): string {
80
+ if (!attributes || typeof attributes !== 'object') return ''
81
+ const parts: string[] = []
82
+ for (const [key, value] of Object.entries(attributes)) {
83
+ if (key === 'class' && typeof value === 'string') {
84
+ for (const cls of value.split(/\s+/).filter(Boolean)) parts.push(`.${cls}`)
85
+ } else if (key === 'id' && typeof value === 'string') {
86
+ parts.push(`#${value}`)
87
+ } else if (value === '' || value == null) {
88
+ parts.push(key)
89
+ } else {
90
+ parts.push(`${key}="${String(value)}"`)
91
+ }
92
+ }
93
+ return parts.length > 0 ? `{${parts.join(' ')}}` : ''
94
+ }
95
+
96
+ // Restore a non-list directive to its literal markdown source. Once `remark-directive`
97
+ // is registered, a stray colon in prose (`klíč:hodnota`) parses as a `textDirective` —
98
+ // which has no Milkdown node, and Milkdown throws on unknown node types. Turning it back
99
+ // into text keeps the editor alive without dropping the user's content.
100
+ function neutralizeDirective(node: DirectiveLike): DirectiveLike[] {
101
+ const marker = directiveMarker(node.type)
102
+ const name = typeof node.name === 'string' ? node.name : ''
103
+ const label = Array.isArray(node.children) ? node.children : []
104
+ const attributes = stringifyAttributes(node.attributes)
105
+
106
+ if (node.type === 'textDirective') {
107
+ const parts: DirectiveLike[] = [{ type: 'text', value: `${marker}${name}` }]
108
+ if (label.length > 0) parts.push({ type: 'text', value: '[' }, ...label, { type: 'text', value: ']' })
109
+ if (attributes) parts.push({ type: 'text', value: attributes })
110
+ return parts
111
+ }
112
+
113
+ const opener: DirectiveLike = { type: 'paragraph', children: [{ type: 'text', value: `${marker}${name}${attributes}` }] }
114
+ return [opener, ...label]
115
+ }
116
+
59
117
  function transformListDirectives(parent: any): void {
60
118
  if (!Array.isArray(parent?.children)) return
61
119
 
@@ -85,7 +143,17 @@ function transformListDirectives(parent: any): void {
85
143
  continue
86
144
  }
87
145
 
146
+ // Recurse depth-first so nested directives are restored before we touch this node.
88
147
  transformListDirectives(child)
148
+
149
+ // Any directive node still standing would crash Milkdown — restore it to text.
150
+ if (child && DIRECTIVE_TYPES.has(child.type)) {
151
+ const replacement = child.type === 'containerDirective' && child.name === LIST_DIRECTIVE_NAME
152
+ ? (Array.isArray(child.children) ? child.children : []) // bare `:::list` without a usable class: unwrap, keep the list
153
+ : neutralizeDirective(child)
154
+ parent.children.splice(index, 1, ...replacement)
155
+ index += replacement.length - 1
156
+ }
89
157
  }
90
158
  }
91
159
 
@@ -111,6 +179,21 @@ function remarkListDirective(this: { data: () => any }) {
111
179
 
112
180
  export const remarkListDirectivePlugin: any = $remark('remarkListDirective', () => remarkListDirective as any)
113
181
 
182
+ // Parse-only directive support. Registering the micromark + fromMarkdown extensions means
183
+ // directive syntax (`:::list{.class}`, a stray `klíč:hodnota`) is claimed during parsing,
184
+ // so `remark-mdx` never feeds the `{...}` to acorn ("Could not parse expression with
185
+ // acorn"). We deliberately skip `directiveToMarkdown`: its `unsafe` rules would escape
186
+ // every `:` in serialized prose (`klíč\:hodnota`), corrupting content on save.
187
+ function remarkDirectiveParseOnly(this: { data: () => any }) {
188
+ const data = this.data()
189
+ const micromarkExtensions = data.micromarkExtensions || (data.micromarkExtensions = [])
190
+ micromarkExtensions.push(directive())
191
+ const fromMarkdownExtensions = data.fromMarkdownExtensions || (data.fromMarkdownExtensions = [])
192
+ fromMarkdownExtensions.push(directiveFromMarkdown())
193
+ }
194
+
195
+ export const remarkDirectivePlugin: any = $remark('remarkDirective', () => remarkDirectiveParseOnly as any)
196
+
114
197
  export const styledBulletListSchema = bulletListSchema.extendSchema((prev) => (ctx) => {
115
198
  const schema = prev(ctx)
116
199
  return {
@@ -220,7 +303,18 @@ export const setBulletListStyleCommand = $command('SetBulletListStyle', () => {
220
303
  }
221
304
  })
222
305
 
306
+ // Directive parsing + the list transform + the schema, without the styled-list UI
307
+ // command or the `-` bullet normalization. Loaded for `.mdx` editing even when a site
308
+ // has no list styles configured, so `:::list{.class}` and stray colons in MDX content
309
+ // don't crash acorn — while plain bullet lists keep their default `*` serialization.
310
+ export const mdxDirectiveSafetyPlugin = [
311
+ remarkDirectivePlugin,
312
+ remarkListDirectivePlugin,
313
+ styledBulletListSchema,
314
+ ].flat()
315
+
223
316
  export const styledListPlugin = [
317
+ remarkDirectivePlugin,
224
318
  remarkListDirectivePlugin,
225
319
  styledBulletListSchema,
226
320
  setBulletListStyleCommand,
@@ -1,17 +1,22 @@
1
1
  import type { Plugin } from 'unified'
2
2
 
3
- // Render-side counterpart of the editor's styled-list plugin
4
- // (packages/cms-mdx-editor/src/styled-list-plugin.ts). The editor serializes a
5
- // styled list as the container directive `:::list{.className}`. On the site we
6
- // need `remark-directive` to claim that syntax before MDX would otherwise treat
7
- // `{.className}` as a JSX expression and crash acorn. This plugin then turns the
8
- // `list` directive into a plain `<ul class="className">` and — crucially —
9
- // converts every OTHER directive node back to its literal source text, so a
10
- // stray colon in prose (e.g. `klíč:hodnota` a `:hodnota` text directive) is
11
- // never silently dropped by remark-rehype.
3
+ // Render-side counterpart of the editor's directive plugins
4
+ // (packages/cms-mdx-editor/src/{styled-list,youtube}-plugin.ts). The editor
5
+ // serializes a styled list as `:::list{.className}` and a YouTube embed as the leaf
6
+ // directive `::youtube{#id}`. On the site we need `remark-directive` to claim that
7
+ // syntax before MDX would otherwise treat `{}` as a JSX expression and crash acorn.
8
+ // This plugin then:
9
+ // - turns the `list` directive into a plain `<ul class="className">`,
10
+ // - turns the `youtube` directive into an `<iframe class="youtube-embed">` the site
11
+ // styles (mirrors the list pattern: framework emits the element, site owns CSS),
12
+ // - converts every OTHER directive back to its literal source text, so a stray colon
13
+ // in prose (e.g. `klíč:hodnota` → a `:hodnota` text directive) is never silently
14
+ // dropped by remark-rehype.
12
15
 
13
16
  const LIST_DIRECTIVE_NAME = 'list'
17
+ const YOUTUBE_DIRECTIVE_NAME = 'youtube'
14
18
  const LIST_STYLE_CLASS_RE = /^[A-Za-z0-9_-]+$/
19
+ const VIDEO_ID_RE = /^[A-Za-z0-9_-]+$/
15
20
  const DIRECTIVE_TYPES = new Set(['textDirective', 'leafDirective', 'containerDirective'])
16
21
 
17
22
  interface MdastNode {
@@ -76,6 +81,42 @@ function unwrapListDirective(node: MdastNode): MdastNode[] {
76
81
  return children
77
82
  }
78
83
 
84
+ // Pull the video id out of a youtube directive's attributes: the emitted `{#id}`
85
+ // form (→ `id`), the legacy bare `{id}` form (→ a single empty-valued key) and
86
+ // `{id=…}`. Returns null for anything that isn't a plausible video id.
87
+ function youtubeVideoId(attributes: Record<string, unknown> | null | undefined): string | null {
88
+ if (!attributes) return null
89
+ const id = attributes.id
90
+ if (typeof id === 'string' && VIDEO_ID_RE.test(id)) return id
91
+ for (const [key, value] of Object.entries(attributes)) {
92
+ if ((value === '' || value == null) && VIDEO_ID_RE.test(key)) return key
93
+ }
94
+ return null
95
+ }
96
+
97
+ // Turn `::youtube{#id}` into an `<iframe class="youtube-embed">`. The framework emits
98
+ // a working, privacy-friendly embed; the site owns the styling (e.g. responsive 16:9
99
+ // via `.youtube-embed`), exactly like the styled-list `<ul class>`. Returns false when
100
+ // no id is present so the caller can fall back to neutralizing the directive to text.
101
+ function renderYoutubeDirective(node: MdastNode): boolean {
102
+ const id = youtubeVideoId(node.attributes)
103
+ if (!id) return false
104
+ const data = node.data ?? (node.data = {})
105
+ data.hName = 'iframe'
106
+ data.hProperties = {
107
+ src: `https://www.youtube-nocookie.com/embed/${id}`,
108
+ title: 'YouTube video',
109
+ width: '560',
110
+ height: '315',
111
+ loading: 'lazy',
112
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
113
+ allowFullScreen: true,
114
+ className: ['youtube-embed'],
115
+ }
116
+ node.children = []
117
+ return true
118
+ }
119
+
79
120
  // Reconstruct any non-list directive back to literal source so no text is lost.
80
121
  function neutralizeDirective(node: MdastNode): MdastNode[] {
81
122
  const marker = directiveMarker(node.type)
@@ -107,6 +148,9 @@ function transform(node: MdastNode): void {
107
148
 
108
149
  if (!DIRECTIVE_TYPES.has(child.type)) continue
109
150
 
151
+ // YouTube renders in place as an <iframe>; keep the node (now carrying hName).
152
+ if (child.name === YOUTUBE_DIRECTIVE_NAME && renderYoutubeDirective(child)) continue
153
+
110
154
  const replacement = child.type === 'containerDirective' && child.name === LIST_DIRECTIVE_NAME
111
155
  ? unwrapListDirective(child)
112
156
  : neutralizeDirective(child)