@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/dist/editor.js +11704 -11275
- package/package.json +9 -7
- package/src/editor/components/markdown-inline-editor.tsx +7 -3
- package/src/editor/styled-list-plugin.ts +94 -0
- package/src/remark-list-directive.ts +53 -9
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.
|
|
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.
|
|
30
|
-
"@nuasite/cms-types": "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.
|
|
39
|
-
"@nuasite/collections-admin": "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.
|
|
78
|
-
"@nuasite/collections-admin": "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
|
|
4
|
-
// (packages/cms-mdx-editor/src/styled-list-plugin.ts). The editor
|
|
5
|
-
// styled list as
|
|
6
|
-
// need `remark-directive` to claim that
|
|
7
|
-
// `{
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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)
|