@nuasite/cms-mdx-editor 0.43.3 → 0.44.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.
@@ -0,0 +1,361 @@
1
+ import { bulletListAttr, bulletListSchema, orderedListAttr, orderedListSchema } from '@milkdown/preset-commonmark'
2
+ import type { Attrs, Node as PmNode } from '@milkdown/prose/model'
3
+ import type { Command } from '@milkdown/prose/state'
4
+ import type { JSONRecord, MarkdownNode, Root, SerializerState } from '@milkdown/transformer'
5
+ import { $command, $remark } from '@milkdown/utils'
6
+ import type { Plugin } from 'unified'
7
+
8
+ const LIST_DIRECTIVE_NAME = 'list'
9
+ const LIST_STYLE_CLASS_RE = /^[A-Za-z0-9_-]+$/
10
+
11
+ interface ListDirectiveNode extends MutableMarkdownNode {
12
+ type: 'containerDirective'
13
+ name?: unknown
14
+ attributes?: unknown
15
+ }
16
+
17
+ interface DirectiveAttributes {
18
+ class?: unknown
19
+ }
20
+
21
+ interface MutableMarkdownNode {
22
+ type: string
23
+ value?: unknown
24
+ children?: MutableMarkdownNode[]
25
+ ordered?: unknown
26
+ start?: unknown
27
+ spread?: unknown
28
+ listStyle?: unknown
29
+ attributes?: unknown
30
+ }
31
+
32
+ interface MarkdownStringifyState {
33
+ containerFlow: (node: MutableMarkdownNode, info: unknown) => string
34
+ }
35
+
36
+ export function normalizeListStyleClass(value: unknown): string | null {
37
+ if (typeof value !== 'string') return null
38
+ const [className] = value.trim().split(/\s+/)
39
+ if (!className || !LIST_STYLE_CLASS_RE.test(className)) return null
40
+ return className
41
+ }
42
+
43
+ function childrenOf(node: MutableMarkdownNode | undefined): MutableMarkdownNode[] | null {
44
+ return Array.isArray(node?.children) ? node.children : null
45
+ }
46
+
47
+ function lastChildOf(node: MutableMarkdownNode | undefined): MutableMarkdownNode | undefined {
48
+ const children = childrenOf(node)
49
+ return children && children.length > 0 ? children[children.length - 1] : undefined
50
+ }
51
+
52
+ function getAttributes(node: { attributes?: unknown }): DirectiveAttributes | null {
53
+ if (!node.attributes || typeof node.attributes !== 'object' || Array.isArray(node.attributes)) return null
54
+ return node.attributes
55
+ }
56
+
57
+ function getDirectiveClass(node: ListDirectiveNode): string | null {
58
+ return normalizeListStyleClass(getAttributes(node)?.class)
59
+ }
60
+
61
+ function isParagraphText(node: MutableMarkdownNode | undefined, value?: string): boolean {
62
+ const children = childrenOf(node)
63
+ if (node?.type !== 'paragraph' || children?.length !== 1) return false
64
+ const text = children[0]
65
+ if (text?.type !== 'text' || typeof text.value !== 'string') return false
66
+ return value === undefined ? true : text.value === value
67
+ }
68
+
69
+ function parseOpeningDirective(node: MutableMarkdownNode | undefined): string | null {
70
+ if (!isParagraphText(node)) return null
71
+ const text = childrenOf(node)?.[0]
72
+ if (typeof text?.value !== 'string') return null
73
+ const match = /^:::list\{\.([A-Za-z0-9_-]+)\}$/.exec(text.value)
74
+ return match?.[1] ?? null
75
+ }
76
+
77
+ function isListNode(node: MutableMarkdownNode | undefined): node is MutableMarkdownNode {
78
+ return node?.type === 'list'
79
+ }
80
+
81
+ function stripRawClosingDirective(listNode: MutableMarkdownNode, siblings: MutableMarkdownNode[], indexAfterList: number): boolean {
82
+ if (isParagraphText(siblings[indexAfterList], ':::')) {
83
+ siblings.splice(indexAfterList, 1)
84
+ return true
85
+ }
86
+
87
+ const lastItem = lastChildOf(listNode)
88
+ const lastBlock = lastChildOf(lastItem)
89
+ if (!lastBlock || lastBlock.type !== 'paragraph') return false
90
+ const lastBlockChildren = childrenOf(lastBlock)
91
+ const lastText = lastChildOf(lastBlock)
92
+ if (!lastBlockChildren || lastText?.type !== 'text' || typeof lastText.value !== 'string') return false
93
+
94
+ if (lastText.value === ':::') {
95
+ lastBlockChildren.pop()
96
+ } else if (lastText.value.endsWith('\n:::')) {
97
+ lastText.value = lastText.value.slice(0, -4)
98
+ } else {
99
+ return false
100
+ }
101
+
102
+ if (lastBlockChildren.length === 0) {
103
+ const lastItemChildren = childrenOf(lastItem)
104
+ lastItemChildren?.pop()
105
+ }
106
+ return true
107
+ }
108
+
109
+ export function transformListDirectives(parent: MutableMarkdownNode): void {
110
+ const children = childrenOf(parent)
111
+ if (!children) return
112
+
113
+ for (let index = 0; index < children.length; index++) {
114
+ const child = children[index]
115
+ if (!child) continue
116
+
117
+ if (child.type === 'containerDirective') {
118
+ const directive = child as ListDirectiveNode
119
+ const className = directive.name === LIST_DIRECTIVE_NAME ? getDirectiveClass(directive) : null
120
+ const directiveChildren = childrenOf(directive)
121
+ const list = directiveChildren?.length === 1 ? directiveChildren[0] : undefined
122
+ if (className && isListNode(list)) {
123
+ children[index] = {
124
+ ...list,
125
+ listStyle: className,
126
+ }
127
+ continue
128
+ }
129
+ }
130
+
131
+ const rawClassName = parseOpeningDirective(child)
132
+ const rawList = children[index + 1]
133
+ if (rawClassName && isListNode(rawList) && stripRawClosingDirective(rawList, children, index + 2)) {
134
+ children.splice(index, 1)
135
+ children[index] = {
136
+ ...rawList,
137
+ listStyle: rawClassName,
138
+ }
139
+ continue
140
+ }
141
+
142
+ transformListDirectives(child)
143
+ }
144
+ }
145
+
146
+ function hasContainerFlow(value: unknown): value is MarkdownStringifyState {
147
+ return Boolean(value && typeof value === 'object' && 'containerFlow' in value && typeof value.containerFlow === 'function')
148
+ }
149
+
150
+ const listDirectiveHandler = (node: MutableMarkdownNode, _parent: unknown, state: unknown, info: unknown): string => {
151
+ if (!hasContainerFlow(state)) return ''
152
+ const className = normalizeListStyleClass(getAttributes(node)?.class)
153
+ const children = state.containerFlow(node, info)
154
+ return `:::list${className ? `{.${className}}` : ''}\n${children}\n:::`
155
+ }
156
+
157
+ const remarkListDirective: Plugin<[], Root> = function() {
158
+ const extensions = this.data('toMarkdownExtensions') ?? []
159
+ const listDirectiveExtension = {
160
+ handlers: {
161
+ containerDirective: listDirectiveHandler,
162
+ },
163
+ } as unknown as (typeof extensions)[number]
164
+ extensions.push(listDirectiveExtension)
165
+ this.data('toMarkdownExtensions', extensions)
166
+
167
+ return (tree) => {
168
+ transformListDirectives(tree)
169
+ }
170
+ }
171
+
172
+ export const remarkListDirectivePlugin = $remark('remarkListDirective', () => remarkListDirective)
173
+
174
+ function getPreviousAttrs(value: Attrs | false | null | undefined): Attrs {
175
+ return value && typeof value === 'object' ? value : {}
176
+ }
177
+
178
+ function listStyleProps(listStyle: string | null): JSONRecord | undefined {
179
+ if (!listStyle) return undefined
180
+ return {
181
+ name: LIST_DIRECTIVE_NAME,
182
+ attributes: { class: listStyle },
183
+ }
184
+ }
185
+
186
+ function serializeListWithOptionalStyle(
187
+ state: SerializerState,
188
+ node: PmNode,
189
+ props: JSONRecord,
190
+ ): void {
191
+ const listStyle = normalizeListStyleClass(node.attrs.listStyle)
192
+ const directiveProps = listStyleProps(listStyle)
193
+ if (directiveProps) state.openNode('containerDirective', undefined, directiveProps)
194
+
195
+ state.openNode('list', undefined, props).next(node.content).closeNode()
196
+
197
+ if (directiveProps) state.closeNode()
198
+ }
199
+
200
+ export const styledBulletListSchema = bulletListSchema.extendSchema((prev) => (ctx) => {
201
+ const schema = prev(ctx)
202
+ return {
203
+ ...schema,
204
+ attrs: {
205
+ ...schema.attrs,
206
+ listStyle: {
207
+ default: null,
208
+ validate: 'string|null',
209
+ },
210
+ },
211
+ parseDOM: [
212
+ {
213
+ tag: 'ul',
214
+ getAttrs: (dom) => {
215
+ const previousAttrs = getPreviousAttrs(schema.parseDOM?.[0]?.getAttrs?.(dom))
216
+ const className = dom instanceof HTMLElement ? normalizeListStyleClass(dom.className) : null
217
+ return {
218
+ ...previousAttrs,
219
+ listStyle: className,
220
+ }
221
+ },
222
+ },
223
+ ],
224
+ toDOM: (node: PmNode) => {
225
+ const className = normalizeListStyleClass(node.attrs.listStyle)
226
+ return [
227
+ 'ul',
228
+ {
229
+ ...ctx.get(bulletListAttr.key)(node),
230
+ ...(className ? { class: className } : {}),
231
+ 'data-spread': node.attrs.spread,
232
+ },
233
+ 0,
234
+ ]
235
+ },
236
+ parseMarkdown: {
237
+ match: ({ type, ordered }: MarkdownNode & { ordered?: unknown }) => type === 'list' && !ordered,
238
+ runner: (state, node: MarkdownNode & { spread?: unknown; listStyle?: unknown }, type) => {
239
+ state.openNode(type, {
240
+ spread: node.spread === true,
241
+ listStyle: normalizeListStyleClass(node.listStyle),
242
+ }).next(node.children).closeNode()
243
+ },
244
+ },
245
+ toMarkdown: {
246
+ match: (node: PmNode) => node.type.name === 'bullet_list',
247
+ runner: (state: SerializerState, node: PmNode) => {
248
+ serializeListWithOptionalStyle(state, node, {
249
+ ordered: false,
250
+ spread: node.attrs.spread === true,
251
+ })
252
+ },
253
+ },
254
+ }
255
+ })
256
+
257
+ export const styledOrderedListSchema = orderedListSchema.extendSchema((prev) => (ctx) => {
258
+ const schema = prev(ctx)
259
+ return {
260
+ ...schema,
261
+ attrs: {
262
+ ...schema.attrs,
263
+ listStyle: {
264
+ default: null,
265
+ validate: 'string|null',
266
+ },
267
+ },
268
+ parseDOM: [
269
+ {
270
+ tag: 'ol',
271
+ getAttrs: (dom) => {
272
+ const previousAttrs = getPreviousAttrs(schema.parseDOM?.[0]?.getAttrs?.(dom))
273
+ const className = dom instanceof HTMLElement ? normalizeListStyleClass(dom.className) : null
274
+ return {
275
+ ...previousAttrs,
276
+ listStyle: className,
277
+ }
278
+ },
279
+ },
280
+ ],
281
+ toDOM: (node: PmNode) => {
282
+ const className = normalizeListStyleClass(node.attrs.listStyle)
283
+ return [
284
+ 'ol',
285
+ {
286
+ ...ctx.get(orderedListAttr.key)(node),
287
+ ...(node.attrs.order === 1 ? {} : { start: node.attrs.order }),
288
+ ...(className ? { class: className } : {}),
289
+ 'data-spread': node.attrs.spread,
290
+ },
291
+ 0,
292
+ ]
293
+ },
294
+ parseMarkdown: {
295
+ match: ({ type, ordered }: MarkdownNode & { ordered?: unknown }) => type === 'list' && !!ordered,
296
+ runner: (state, node: MarkdownNode & { spread?: unknown; start?: unknown; listStyle?: unknown }, type) => {
297
+ state.openNode(type, {
298
+ order: typeof node.start === 'number' ? node.start : 1,
299
+ spread: node.spread === true,
300
+ listStyle: normalizeListStyleClass(node.listStyle),
301
+ }).next(node.children).closeNode()
302
+ },
303
+ },
304
+ toMarkdown: {
305
+ match: (node: PmNode) => node.type.name === 'ordered_list',
306
+ runner: (state: SerializerState, node: PmNode) => {
307
+ serializeListWithOptionalStyle(state, node, {
308
+ ordered: true,
309
+ start: typeof node.attrs.order === 'number' ? node.attrs.order : 1,
310
+ spread: node.attrs.spread === true,
311
+ })
312
+ },
313
+ },
314
+ }
315
+ })
316
+
317
+ export const setListStyleCommand = $command('SetListStyle', () => {
318
+ return (listStyle?: string | null): Command => {
319
+ return (state, dispatch) => {
320
+ const listTypes = new Set([state.schema.nodes.bullet_list, state.schema.nodes.ordered_list].filter(Boolean))
321
+ if (listTypes.size === 0) return false
322
+
323
+ const nextStyle = normalizeListStyleClass(listStyle)
324
+ const positions = new Map<number, PmNode>()
325
+ const { from, to, $from } = state.selection
326
+
327
+ for (let depth = $from.depth; depth > 0; depth--) {
328
+ const node = $from.node(depth)
329
+ if (listTypes.has(node.type)) {
330
+ positions.set($from.before(depth), node)
331
+ break
332
+ }
333
+ }
334
+
335
+ state.doc.nodesBetween(from, to, (node, pos) => {
336
+ if (listTypes.has(node.type)) positions.set(pos, node)
337
+ })
338
+
339
+ if (positions.size === 0) return false
340
+
341
+ if (dispatch) {
342
+ let tr = state.tr
343
+ for (const [pos, node] of positions) {
344
+ tr = tr.setNodeMarkup(pos, undefined, {
345
+ ...node.attrs,
346
+ listStyle: nextStyle,
347
+ })
348
+ }
349
+ dispatch(tr.scrollIntoView())
350
+ }
351
+ return true
352
+ }
353
+ }
354
+ })
355
+
356
+ export const styledListPlugin = [
357
+ remarkListDirectivePlugin,
358
+ styledBulletListSchema,
359
+ styledOrderedListSchema,
360
+ setListStyleCommand,
361
+ ].flat()
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Inline popover for the YouTube toolbar button: paste a URL or bare id. Apply
3
+ * extracts the 11-char video id and inserts a `:::youtube{<id>}` directive; if no
4
+ * id can be parsed the popover stays open with an error rather than emitting junk.
5
+ * Mirrors the LinkPopover style.
6
+ */
7
+ import { useState } from 'react'
8
+ import { popoverBtn, popoverInput, popoverWrap } from './link-popover'
9
+ import { extractYoutubeId } from './youtube'
10
+
11
+ export interface YoutubePopoverProps {
12
+ onApply: (id: string) => void
13
+ onClose: () => void
14
+ }
15
+
16
+ const error: React.CSSProperties = { fontSize: 11, color: '#dc2626', whiteSpace: 'nowrap' }
17
+
18
+ export function YoutubePopover({ onApply, onClose }: YoutubePopoverProps) {
19
+ const [value, setValue] = useState('')
20
+ const [invalid, setInvalid] = useState(false)
21
+
22
+ const apply = () => {
23
+ const id = extractYoutubeId(value)
24
+ if (!id) {
25
+ setInvalid(true)
26
+ return
27
+ }
28
+ onApply(id)
29
+ }
30
+
31
+ return (
32
+ <div style={popoverWrap} data-mdx-action="youtube" onMouseDown={e => e.stopPropagation()}>
33
+ <input
34
+ style={popoverInput}
35
+ autoFocus
36
+ placeholder="YouTube URL or ID"
37
+ value={value}
38
+ onChange={e => {
39
+ setValue(e.currentTarget.value)
40
+ if (invalid) setInvalid(false)
41
+ }}
42
+ onKeyDown={e => {
43
+ if (e.key === 'Enter') {
44
+ e.preventDefault()
45
+ apply()
46
+ }
47
+ if (e.key === 'Escape') onClose()
48
+ }}
49
+ />
50
+ {invalid ? <span style={error}>No video id found</span> : null}
51
+ <button
52
+ type="button"
53
+ style={{ ...popoverBtn, background: '#2563eb', borderColor: '#2563eb', color: '#fff' }}
54
+ onMouseDown={e => e.preventDefault()}
55
+ onClick={apply}
56
+ >
57
+ Insert
58
+ </button>
59
+ <button type="button" style={popoverBtn} onMouseDown={e => e.preventDefault()} onClick={onClose}>Cancel</button>
60
+ </div>
61
+ )
62
+ }
package/src/youtube.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** YouTube video ids are always 11 chars from this alphabet. */
2
+ const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/
3
+
4
+ /** Path-based forms: youtu.be/<id>, youtube.com/embed/<id>, youtube.com/shorts/<id>, youtube.com/v/<id>. */
5
+ const PATH_FORM_RE = /(?:youtu\.be\/|\/(?:embed|shorts|v)\/)([A-Za-z0-9_-]{11})/
6
+
7
+ /**
8
+ * Extract the 11-char video id from a YouTube URL or a bare id.
9
+ * Returns null when no id can be found, so callers can reject bad input
10
+ * instead of emitting a broken `:::youtube{…}` directive.
11
+ */
12
+ export function extractYoutubeId(input: string): string | null {
13
+ const value = input.trim()
14
+ if (!value) return null
15
+
16
+ // Bare id.
17
+ if (VIDEO_ID_RE.test(value)) return value
18
+
19
+ // watch?v=<id> (and any other `v` query param).
20
+ const queryMatch = /[?&]v=([A-Za-z0-9_-]{11})/.exec(value)
21
+ if (queryMatch) return queryMatch[1] ?? null
22
+
23
+ const pathMatch = PATH_FORM_RE.exec(value)
24
+ if (pathMatch) return pathMatch[1] ?? null
25
+
26
+ return null
27
+ }