@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.
- package/dist/types/format-toolbar.d.ts +3 -1
- package/dist/types/format-toolbar.d.ts.map +1 -1
- package/dist/types/image-popover.d.ts +7 -0
- package/dist/types/image-popover.d.ts.map +1 -0
- package/dist/types/link-popover.d.ts +4 -0
- package/dist/types/link-popover.d.ts.map +1 -1
- package/dist/types/mdx-body-editor.d.ts +4 -2
- package/dist/types/mdx-body-editor.d.ts.map +1 -1
- package/dist/types/milkdown-utils.d.ts +1 -0
- package/dist/types/milkdown-utils.d.ts.map +1 -1
- package/dist/types/styled-list-plugin.d.ts +19 -0
- package/dist/types/styled-list-plugin.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/youtube-popover.d.ts +6 -0
- package/dist/types/youtube-popover.d.ts.map +1 -0
- package/dist/types/youtube.d.ts +7 -0
- package/dist/types/youtube.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/format-toolbar.tsx +99 -5
- package/src/image-popover.tsx +62 -0
- package/src/link-popover.tsx +7 -3
- package/src/mdx-body-editor.tsx +7 -2
- package/src/milkdown-utils.ts +10 -3
- package/src/styled-list-plugin.ts +361 -0
- package/src/youtube-popover.tsx +62 -0
- package/src/youtube.ts +27 -0
|
@@ -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
|
+
}
|