@nuasite/cms 0.40.0 → 0.42.0
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 +13711 -13225
- package/package.json +1 -1
- package/src/collection-scanner.ts +247 -59
- package/src/content-config-ast.ts +172 -27
- package/src/editor/components/collections-browser.tsx +20 -4
- package/src/editor/components/fields.tsx +313 -52
- package/src/editor/components/frontmatter-fields.tsx +83 -3
- package/src/editor/components/frontmatter-sidebar.tsx +1 -0
- package/src/editor/components/markdown-editor-overlay.tsx +1 -1
- package/src/editor/components/markdown-inline-editor.tsx +50 -0
- package/src/editor/components/toolbar.tsx +17 -2
- package/src/editor/milkdown-utils.ts +9 -2
- package/src/editor/styled-list-plugin.ts +233 -0
- package/src/editor/types.ts +3 -0
- package/src/field-types.ts +15 -0
- package/src/handlers/markdown-ops.ts +75 -1
- package/src/html-processor.ts +22 -7
- package/src/index.ts +9 -2
- package/src/rehype-cms-marker.ts +2 -2
- package/src/types.ts +9 -0
|
@@ -20,6 +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
24
|
import { LinkEditPopover } from './link-edit-popover'
|
|
24
25
|
import { MdxComponentIcon } from './mdx-block-view'
|
|
25
26
|
import { MdxComponentPicker } from './mdx-component-picker'
|
|
@@ -48,6 +49,7 @@ export function MarkdownInlineEditor({
|
|
|
48
49
|
const [isReady, setIsReady] = useState(false)
|
|
49
50
|
const [isDragging, setIsDragging] = useState(false)
|
|
50
51
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
|
52
|
+
const listStyles = (config.value.listStyles ?? []).filter(style => style.label && style.class)
|
|
51
53
|
|
|
52
54
|
// Track active formatting for toolbar highlighting
|
|
53
55
|
const [activeFormats, setActiveFormats] = useState<ActiveFormats>(defaultActiveFormats)
|
|
@@ -91,6 +93,15 @@ export function MarkdownInlineEditor({
|
|
|
91
93
|
})
|
|
92
94
|
})
|
|
93
95
|
.use(commonmark)
|
|
96
|
+
|
|
97
|
+
// Styled bullet lists are opt-in: only load the plugin (and its `-` bullet
|
|
98
|
+
// normalization) when the site configures list styles, so sites that don't use
|
|
99
|
+
// the feature keep their previous list serialization untouched.
|
|
100
|
+
if (listStyles.length > 0) {
|
|
101
|
+
builder.use(styledListPlugin)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
builder
|
|
94
105
|
.use(gfm)
|
|
95
106
|
.use(listener)
|
|
96
107
|
|
|
@@ -124,6 +135,21 @@ export function MarkdownInlineEditor({
|
|
|
124
135
|
}
|
|
125
136
|
}, [])
|
|
126
137
|
|
|
138
|
+
// Adopt content that streams in AFTER the editor mounted with a placeholder.
|
|
139
|
+
// The collections browser opens the modal immediately (empty), then fetches
|
|
140
|
+
// the entry body, so `initialContent` changes once content arrives. The
|
|
141
|
+
// Milkdown instance is created once, so re-seed it here — but only while the
|
|
142
|
+
// user hasn't edited the placeholder yet, so in-progress edits are never lost.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!isReady) return
|
|
145
|
+
if (initialContent === initialContentRef.current) return
|
|
146
|
+
if (contentRef.current === initialContentRef.current) {
|
|
147
|
+
editorInstanceRef.current?.action(replaceAll(initialContent))
|
|
148
|
+
setContent(initialContent)
|
|
149
|
+
}
|
|
150
|
+
initialContentRef.current = initialContent
|
|
151
|
+
}, [initialContent, isReady])
|
|
152
|
+
|
|
127
153
|
const handleSave = useCallback(() => {
|
|
128
154
|
onSave(content)
|
|
129
155
|
resetMarkdownEditorState()
|
|
@@ -235,6 +261,15 @@ export function MarkdownInlineEditor({
|
|
|
235
261
|
}
|
|
236
262
|
}, [runCommand, checkInList])
|
|
237
263
|
|
|
264
|
+
const handleListStyle = useCallback((listStyle: string | null) => {
|
|
265
|
+
if (!editorInstanceRef.current) return
|
|
266
|
+
try {
|
|
267
|
+
editorInstanceRef.current.action(callCommand(setBulletListStyleCommand.key, listStyle))
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error('Failed to set list style:', error)
|
|
270
|
+
}
|
|
271
|
+
}, [])
|
|
272
|
+
|
|
238
273
|
const handleInsertHeading = useCallback((level: number) => {
|
|
239
274
|
if (!editorInstanceRef.current) return
|
|
240
275
|
try {
|
|
@@ -537,6 +572,21 @@ export function MarkdownInlineEditor({
|
|
|
537
572
|
</text>
|
|
538
573
|
</svg>
|
|
539
574
|
</ToolbarButton>
|
|
575
|
+
{listStyles.length > 0 && (
|
|
576
|
+
<select
|
|
577
|
+
class={cn(
|
|
578
|
+
'h-8 max-w-40 rounded-cms-sm border border-white/15 bg-cms-dark px-2 text-xs text-white/90 outline-none transition-colors',
|
|
579
|
+
'hover:bg-white/10 focus:border-cms-primary focus:ring-1 focus:ring-cms-primary',
|
|
580
|
+
!activeFormats.bulletList && 'opacity-60',
|
|
581
|
+
)}
|
|
582
|
+
title="List style"
|
|
583
|
+
value={activeFormats.listStyle ?? ''}
|
|
584
|
+
onChange={(event) => handleListStyle((event.currentTarget as HTMLSelectElement).value || null)}
|
|
585
|
+
>
|
|
586
|
+
<option value="">Default</option>
|
|
587
|
+
{listStyles.map(style => <option key={style.class} value={style.class}>{style.label}</option>)}
|
|
588
|
+
</select>
|
|
589
|
+
)}
|
|
540
590
|
<ToolbarButton
|
|
541
591
|
onClick={handleQuote}
|
|
542
592
|
title="Quote"
|
|
@@ -28,7 +28,7 @@ export interface ToolbarProps {
|
|
|
28
28
|
collectionDefinitions?: Record<string, CollectionDefinition>
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
type MenuItem = { label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean }
|
|
31
|
+
type MenuItem = { label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean; indented?: boolean }
|
|
32
32
|
type MenuSection = { label: string; icon: ComponentChildren; items: MenuItem[] }
|
|
33
33
|
|
|
34
34
|
const GridIcon = () => (
|
|
@@ -98,9 +98,23 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
98
98
|
if (collectionDefinitions) {
|
|
99
99
|
const entries = Object.entries(collectionDefinitions)
|
|
100
100
|
if (entries.length > 0) {
|
|
101
|
-
|
|
101
|
+
// Group nested (child) collections under their parent, child entries indented.
|
|
102
|
+
const ordered: Array<[string, CollectionDefinition, boolean]> = []
|
|
103
|
+
for (const [name, def] of entries.filter(([, d]) => !d.parentCollection)) {
|
|
104
|
+
ordered.push([name, def, false])
|
|
105
|
+
for (const [childName, childDef] of entries.filter(([, d]) => d.parentCollection === name)) {
|
|
106
|
+
ordered.push([childName, childDef, true])
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Orphaned children whose parent isn't present stay top-level so nothing is hidden.
|
|
110
|
+
for (const [name, def] of entries.filter(([, d]) => d.parentCollection && !collectionDefinitions[d.parentCollection])) {
|
|
111
|
+
ordered.push([name, def, false])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const contentItems: MenuItem[] = ordered.map(([name, def, indented]) => ({
|
|
102
115
|
label: def.label,
|
|
103
116
|
icon: <GridIcon />,
|
|
117
|
+
indented,
|
|
104
118
|
onClick: () => callbacks.onOpenCollection?.(name),
|
|
105
119
|
}))
|
|
106
120
|
|
|
@@ -410,6 +424,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
410
424
|
}}
|
|
411
425
|
class={cn(
|
|
412
426
|
'w-full pl-9 pr-4 py-2 text-sm text-left transition-colors cursor-pointer flex items-center gap-3',
|
|
427
|
+
item.indented && 'pl-16 text-xs text-white/45',
|
|
413
428
|
item.isActive
|
|
414
429
|
? 'bg-white/20 text-white'
|
|
415
430
|
: 'text-white/60 hover:bg-white/10 hover:text-white',
|
|
@@ -10,6 +10,7 @@ export interface ActiveFormats {
|
|
|
10
10
|
linkHref: string | null
|
|
11
11
|
bulletList: boolean
|
|
12
12
|
orderedList: boolean
|
|
13
|
+
listStyle: string | null
|
|
13
14
|
blockquote: boolean
|
|
14
15
|
heading: number | null
|
|
15
16
|
}
|
|
@@ -22,6 +23,7 @@ export const defaultActiveFormats: ActiveFormats = {
|
|
|
22
23
|
linkHref: null,
|
|
23
24
|
bulletList: false,
|
|
24
25
|
orderedList: false,
|
|
26
|
+
listStyle: null,
|
|
25
27
|
blockquote: false,
|
|
26
28
|
heading: null,
|
|
27
29
|
}
|
|
@@ -71,12 +73,16 @@ export function getActiveFormats(view: EditorView): ActiveFormats {
|
|
|
71
73
|
// Check block types (lists, blockquote, heading)
|
|
72
74
|
let bulletList = false
|
|
73
75
|
let orderedList = false
|
|
76
|
+
let listStyle: string | null = null
|
|
74
77
|
let blockquote = false
|
|
75
78
|
let heading: number | null = null
|
|
76
79
|
|
|
77
80
|
for (let depth = $from.depth; depth > 0; depth--) {
|
|
78
81
|
const node = $from.node(depth)
|
|
79
|
-
if (node.type.name === 'bullet_list')
|
|
82
|
+
if (node.type.name === 'bullet_list') {
|
|
83
|
+
bulletList = true
|
|
84
|
+
listStyle = typeof node.attrs.listStyle === 'string' ? node.attrs.listStyle : null
|
|
85
|
+
}
|
|
80
86
|
if (node.type.name === 'ordered_list') orderedList = true
|
|
81
87
|
if (node.type.name === 'blockquote') blockquote = true
|
|
82
88
|
}
|
|
@@ -85,7 +91,7 @@ export function getActiveFormats(view: EditorView): ActiveFormats {
|
|
|
85
91
|
heading = $from.parent.attrs.level as number
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, blockquote, heading }
|
|
94
|
+
return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, listStyle, blockquote, heading }
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/**
|
|
@@ -150,6 +156,7 @@ function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
|
|
|
150
156
|
&& a.linkHref === b.linkHref
|
|
151
157
|
&& a.bulletList === b.bulletList
|
|
152
158
|
&& a.orderedList === b.orderedList
|
|
159
|
+
&& a.listStyle === b.listStyle
|
|
153
160
|
&& a.blockquote === b.blockquote
|
|
154
161
|
&& a.heading === b.heading
|
|
155
162
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { remarkStringifyOptionsCtx } from '@milkdown/core'
|
|
2
|
+
import { bulletListAttr, bulletListSchema } from '@milkdown/preset-commonmark'
|
|
3
|
+
import type { Node as PmNode } from '@milkdown/prose/model'
|
|
4
|
+
import type { Command } from '@milkdown/prose/state'
|
|
5
|
+
import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
|
|
6
|
+
import { $command, $remark } from '@milkdown/utils'
|
|
7
|
+
|
|
8
|
+
const LIST_DIRECTIVE_NAME = 'list'
|
|
9
|
+
|
|
10
|
+
function normalizeListStyleClass(value: unknown): string | null {
|
|
11
|
+
if (typeof value !== 'string') return null
|
|
12
|
+
const [className] = value.trim().split(/\s+/)
|
|
13
|
+
return className || null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getDirectiveClass(node: any): string | null {
|
|
17
|
+
const className = node.attributes?.class
|
|
18
|
+
return normalizeListStyleClass(className)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isParagraphText(node: any, value?: string): boolean {
|
|
22
|
+
if (node?.type !== 'paragraph' || node.children?.length !== 1) return false
|
|
23
|
+
const text = node.children[0]
|
|
24
|
+
if (text?.type !== 'text') return false
|
|
25
|
+
return value === undefined ? true : text.value === value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseOpeningDirective(node: any): string | null {
|
|
29
|
+
if (!isParagraphText(node)) return null
|
|
30
|
+
const value = node.children[0].value
|
|
31
|
+
const match = /^:::list\{\.([^}\s]+)\}$/.exec(value)
|
|
32
|
+
return match?.[1] ?? null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripRawClosingDirective(listNode: any, siblings: any[], indexAfterList: number): boolean {
|
|
36
|
+
if (isParagraphText(siblings[indexAfterList], ':::')) {
|
|
37
|
+
siblings.splice(indexAfterList, 1)
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lastItem = listNode.children?.at(-1)
|
|
42
|
+
const lastBlock = lastItem?.children?.at(-1)
|
|
43
|
+
if (!lastBlock || lastBlock.type !== 'paragraph') return false
|
|
44
|
+
const lastText = lastBlock.children?.at(-1)
|
|
45
|
+
if (lastText?.type !== 'text' || typeof lastText.value !== 'string') return false
|
|
46
|
+
|
|
47
|
+
if (lastText.value === ':::') {
|
|
48
|
+
lastBlock.children.pop()
|
|
49
|
+
} else if (lastText.value.endsWith('\n:::')) {
|
|
50
|
+
lastText.value = lastText.value.slice(0, -4)
|
|
51
|
+
} else {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (lastBlock.children.length === 0) lastItem.children.pop()
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function transformListDirectives(parent: any): void {
|
|
60
|
+
if (!Array.isArray(parent?.children)) return
|
|
61
|
+
|
|
62
|
+
for (let index = 0; index < parent.children.length; index++) {
|
|
63
|
+
const child = parent.children[index]
|
|
64
|
+
|
|
65
|
+
if (child?.type === 'containerDirective' && child.name === LIST_DIRECTIVE_NAME) {
|
|
66
|
+
const className = getDirectiveClass(child)
|
|
67
|
+
const list = child.children?.length === 1 ? child.children[0] : null
|
|
68
|
+
if (className && list?.type === 'list' && !list.ordered) {
|
|
69
|
+
parent.children[index] = {
|
|
70
|
+
...list,
|
|
71
|
+
listStyle: className,
|
|
72
|
+
}
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rawClassName = parseOpeningDirective(child)
|
|
78
|
+
const rawList = parent.children[index + 1]
|
|
79
|
+
if (rawClassName && rawList?.type === 'list' && !rawList.ordered && stripRawClosingDirective(rawList, parent.children, index + 2)) {
|
|
80
|
+
parent.children.splice(index, 1)
|
|
81
|
+
parent.children[index] = {
|
|
82
|
+
...rawList,
|
|
83
|
+
listStyle: rawClassName,
|
|
84
|
+
}
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
transformListDirectives(child)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function listDirectiveHandler(node: any, _parent: any, state: any, info: any): string {
|
|
93
|
+
const className = normalizeListStyleClass(node.attributes?.class)
|
|
94
|
+
const children = state.containerFlow(node, info)
|
|
95
|
+
return `:::list${className ? `{.${className}}` : ''}\n${children}\n:::`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function remarkListDirective(this: { data: () => any }) {
|
|
99
|
+
const data = this.data()
|
|
100
|
+
const toMarkdownExtensions = data.toMarkdownExtensions || (data.toMarkdownExtensions = [])
|
|
101
|
+
toMarkdownExtensions.push({
|
|
102
|
+
handlers: {
|
|
103
|
+
containerDirective: listDirectiveHandler,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return (tree: any) => {
|
|
108
|
+
transformListDirectives(tree)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const remarkListDirectivePlugin: any = $remark('remarkListDirective', () => remarkListDirective as any)
|
|
113
|
+
|
|
114
|
+
export const styledBulletListSchema = bulletListSchema.extendSchema((prev) => (ctx) => {
|
|
115
|
+
const schema = prev(ctx)
|
|
116
|
+
return {
|
|
117
|
+
...schema,
|
|
118
|
+
attrs: {
|
|
119
|
+
...schema.attrs,
|
|
120
|
+
listStyle: {
|
|
121
|
+
default: null,
|
|
122
|
+
validate: 'string|null',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
parseDOM: [
|
|
126
|
+
{
|
|
127
|
+
tag: 'ul',
|
|
128
|
+
getAttrs: (dom) => {
|
|
129
|
+
const previousAttrs = schema.parseDOM?.[0]?.getAttrs?.(dom) ?? {}
|
|
130
|
+
const className = dom instanceof HTMLElement ? normalizeListStyleClass(dom.className) : null
|
|
131
|
+
return {
|
|
132
|
+
...(typeof previousAttrs === 'object' ? previousAttrs : {}),
|
|
133
|
+
listStyle: className,
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
toDOM: (node: PmNode) => {
|
|
139
|
+
const className = normalizeListStyleClass(node.attrs.listStyle)
|
|
140
|
+
return [
|
|
141
|
+
'ul',
|
|
142
|
+
{
|
|
143
|
+
...ctx.get(bulletListAttr.key)(node),
|
|
144
|
+
...(className ? { class: className } : {}),
|
|
145
|
+
'data-spread': node.attrs.spread,
|
|
146
|
+
},
|
|
147
|
+
0,
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
parseMarkdown: {
|
|
151
|
+
match: ({ type, ordered }: MarkdownNode & { ordered?: boolean }) => type === 'list' && !ordered,
|
|
152
|
+
runner: (state, node: MarkdownNode & { spread?: boolean; listStyle?: string }, type) => {
|
|
153
|
+
state.openNode(type, {
|
|
154
|
+
spread: node.spread ?? false,
|
|
155
|
+
listStyle: normalizeListStyleClass(node.listStyle),
|
|
156
|
+
}).next(node.children).closeNode()
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
toMarkdown: {
|
|
160
|
+
match: (node: PmNode) => node.type.name === 'bullet_list',
|
|
161
|
+
runner: (state: SerializerState, node: PmNode) => {
|
|
162
|
+
const listStyle = normalizeListStyleClass(node.attrs.listStyle)
|
|
163
|
+
if (listStyle) {
|
|
164
|
+
state.openNode('containerDirective', undefined, {
|
|
165
|
+
name: LIST_DIRECTIVE_NAME,
|
|
166
|
+
attributes: { class: listStyle },
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
state
|
|
171
|
+
.openNode('list', undefined, {
|
|
172
|
+
ordered: false,
|
|
173
|
+
spread: node.attrs.spread === true,
|
|
174
|
+
})
|
|
175
|
+
.next(node.content)
|
|
176
|
+
.closeNode()
|
|
177
|
+
|
|
178
|
+
if (listStyle) state.closeNode()
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
export const setBulletListStyleCommand = $command('SetBulletListStyle', () => {
|
|
185
|
+
return (listStyle?: string | null): Command => {
|
|
186
|
+
return (state, dispatch) => {
|
|
187
|
+
const bulletListType = state.schema.nodes.bullet_list
|
|
188
|
+
if (!bulletListType) return false
|
|
189
|
+
|
|
190
|
+
const nextStyle = normalizeListStyleClass(listStyle)
|
|
191
|
+
const positions = new Map<number, PmNode>()
|
|
192
|
+
const { from, to, $from } = state.selection
|
|
193
|
+
|
|
194
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
195
|
+
const node = $from.node(depth)
|
|
196
|
+
if (node.type === bulletListType) {
|
|
197
|
+
positions.set($from.before(depth), node)
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
203
|
+
if (node.type === bulletListType) positions.set(pos, node)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
if (positions.size === 0) return false
|
|
207
|
+
|
|
208
|
+
if (dispatch) {
|
|
209
|
+
let tr = state.tr
|
|
210
|
+
for (const [pos, node] of positions) {
|
|
211
|
+
tr = tr.setNodeMarkup(pos, undefined, {
|
|
212
|
+
...node.attrs,
|
|
213
|
+
listStyle: nextStyle,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
dispatch(tr)
|
|
217
|
+
}
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
export const styledListPlugin = [
|
|
224
|
+
remarkListDirectivePlugin,
|
|
225
|
+
styledBulletListSchema,
|
|
226
|
+
setBulletListStyleCommand,
|
|
227
|
+
(ctx: any) => () => {
|
|
228
|
+
ctx.update(remarkStringifyOptionsCtx, (options: any) => ({
|
|
229
|
+
...options,
|
|
230
|
+
bullet: '-',
|
|
231
|
+
}))
|
|
232
|
+
},
|
|
233
|
+
].flat()
|
package/src/editor/types.ts
CHANGED
|
@@ -52,6 +52,9 @@ export interface CmsConfig {
|
|
|
52
52
|
theme?: CmsThemeConfig
|
|
53
53
|
themePreset?: CmsThemePreset
|
|
54
54
|
features?: CmsFeatures
|
|
55
|
+
listStyles?: Array<{ label: string; class: string }>
|
|
56
|
+
/** Open the entry editor's metadata (frontmatter) panel by default instead of collapsed. */
|
|
57
|
+
openMetadataByDefault?: boolean
|
|
55
58
|
/** Maximum upload size in bytes for media uploads (injected by the integration). */
|
|
56
59
|
maxUploadSize?: number
|
|
57
60
|
/**
|
package/src/field-types.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface ImageHints {
|
|
|
55
55
|
accept?: string
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface FileHints {
|
|
59
|
+
accept?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
58
62
|
// --- Internals ---
|
|
59
63
|
|
|
60
64
|
type OrderByDirection = 'asc' | 'desc'
|
|
@@ -122,6 +126,8 @@ export const n = {
|
|
|
122
126
|
},
|
|
123
127
|
/** Image picker (opens media library). Accepts hints for the scanner; no Zod validation applied. */
|
|
124
128
|
image: (_hints?: ImageHints) => withOrderBy(z.string().describe('cms:image')),
|
|
129
|
+
/** File picker (opens media library for any file type — PDFs, docs, etc.). Accepts hints for the scanner; no Zod validation applied. */
|
|
130
|
+
file: (_hints?: FileHints) => withOrderBy(z.string().describe('cms:file')),
|
|
125
131
|
/** URL input */
|
|
126
132
|
url: (hints?: TextHints) => stringField('url', hints),
|
|
127
133
|
/** Email input */
|
|
@@ -130,6 +136,15 @@ export const n = {
|
|
|
130
136
|
tel: (hints?: TextHints) => stringField('tel', hints),
|
|
131
137
|
/** Color picker */
|
|
132
138
|
color: () => withOrderBy(z.string().describe('cms:color')),
|
|
139
|
+
/** Year picker (integer input). Defaults to 1900–2100 when no min/max given. */
|
|
140
|
+
year: (hints?: NumberHints) => {
|
|
141
|
+
let schema = z.number()
|
|
142
|
+
if (hints?.min != null) schema = schema.min(hints.min)
|
|
143
|
+
if (hints?.max != null) schema = schema.max(hints.max)
|
|
144
|
+
return withOrderBy(schema.describe('cms:year'))
|
|
145
|
+
},
|
|
146
|
+
/** Month picker (YYYY-MM). Accepts hints for the scanner; no Zod validation applied. */
|
|
147
|
+
month: (_hints?: DateHints) => withOrderBy(z.string().describe('cms:month')),
|
|
133
148
|
/** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
|
|
134
149
|
date: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODate, z.string()).describe('cms:date')),
|
|
135
150
|
/** Date + time picker (handles YAML Date coercion → ISO datetime string). Accepts hints for the scanner; no Zod validation applied. */
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type { Dirent } from 'node:fs'
|
|
1
2
|
import fs from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import yaml from 'yaml'
|
|
4
5
|
import { getProjectRoot } from '../config'
|
|
6
|
+
import { parseContentConfig } from '../content-config-ast'
|
|
5
7
|
import type { ComponentDefinition } from '../types'
|
|
6
8
|
import { acquireFileLock, isNodeError, relativeImportPath, resolveAndValidatePath, slugify } from '../utils'
|
|
7
9
|
|
|
@@ -149,7 +151,10 @@ export async function handleCreateMarkdown(
|
|
|
149
151
|
return { success: false, error: `Invalid file extension "${ext}". Allowed: ${allowedExtensions.join(', ')}` }
|
|
150
152
|
}
|
|
151
153
|
const isData = ext === 'json' || ext === 'yaml' || ext === 'yml'
|
|
152
|
-
const
|
|
154
|
+
const layout = isData ? 'flat' : await detectCollectionMarkdownLayout(collection)
|
|
155
|
+
const filePath = layout === 'index'
|
|
156
|
+
? `src/content/${collection}/${normalizedSlug}/index.${ext}`
|
|
157
|
+
: `src/content/${collection}/${normalizedSlug}.${ext}`
|
|
153
158
|
const fullPath = resolveAndValidatePath(filePath)
|
|
154
159
|
|
|
155
160
|
let fileContent: string
|
|
@@ -275,6 +280,75 @@ function isDataFile(filePath: string): boolean {
|
|
|
275
280
|
return filePath.endsWith('.json') || filePath.endsWith('.yaml') || filePath.endsWith('.yml')
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
type MarkdownCollectionLayout = 'flat' | 'index'
|
|
284
|
+
|
|
285
|
+
async function detectCollectionMarkdownLayout(collection: string): Promise<MarkdownCollectionLayout> {
|
|
286
|
+
const existingLayout = await inferLayoutFromExistingEntries(collection)
|
|
287
|
+
if (existingLayout) return existingLayout
|
|
288
|
+
|
|
289
|
+
const configLayout = await inferLayoutFromContentConfig(collection)
|
|
290
|
+
if (configLayout) return configLayout
|
|
291
|
+
|
|
292
|
+
return 'flat'
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function inferLayoutFromExistingEntries(collection: string): Promise<MarkdownCollectionLayout | null> {
|
|
296
|
+
const collectionPath = path.join(getProjectRoot(), 'src', 'content', collection)
|
|
297
|
+
|
|
298
|
+
let dirEntries: Dirent[]
|
|
299
|
+
try {
|
|
300
|
+
dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
|
|
301
|
+
} catch {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let flatCount = 0
|
|
306
|
+
let indexCount = 0
|
|
307
|
+
const flatSlugs = new Set<string>()
|
|
308
|
+
|
|
309
|
+
for (const entry of dirEntries) {
|
|
310
|
+
if (!entry.isFile()) continue
|
|
311
|
+
const match = entry.name.match(/^(.+)\.(md|mdx)$/)
|
|
312
|
+
if (!match) continue
|
|
313
|
+
flatCount++
|
|
314
|
+
flatSlugs.add(match[1]!)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const subdirs = dirEntries.filter(entry => entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.'))
|
|
318
|
+
const indexLookups = await Promise.all(subdirs.map(async dir => {
|
|
319
|
+
if (flatSlugs.has(dir.name)) return false
|
|
320
|
+
for (const ext of ['md', 'mdx'] as const) {
|
|
321
|
+
try {
|
|
322
|
+
await fs.access(path.join(collectionPath, dir.name, `index.${ext}`))
|
|
323
|
+
return true
|
|
324
|
+
} catch {
|
|
325
|
+
// try next extension
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false
|
|
329
|
+
}))
|
|
330
|
+
indexCount = indexLookups.filter(Boolean).length
|
|
331
|
+
|
|
332
|
+
if (indexCount > flatCount) return 'index'
|
|
333
|
+
if (flatCount > 0) return 'flat'
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function inferLayoutFromContentConfig(collection: string): Promise<MarkdownCollectionLayout | null> {
|
|
338
|
+
try {
|
|
339
|
+
const parsed = await parseContentConfig()
|
|
340
|
+
const pattern = parsed.get(collection)?.loaderPattern
|
|
341
|
+
if (!pattern) return null
|
|
342
|
+
return isIndexStyleGlobPattern(pattern) ? 'index' : 'flat'
|
|
343
|
+
} catch {
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isIndexStyleGlobPattern(pattern: string): boolean {
|
|
349
|
+
return pattern.includes('index.{') || pattern.includes('*/index') || pattern.includes('**/index')
|
|
350
|
+
}
|
|
351
|
+
|
|
278
352
|
function parseFrontmatter(raw: string): { frontmatter: Record<string, unknown>; content: string } {
|
|
279
353
|
const trimmed = raw.trimStart()
|
|
280
354
|
if (!trimmed.startsWith('---')) {
|
package/src/html-processor.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getProjectRoot } from './config'
|
|
2
4
|
import { processSeoFromHtml } from './seo-processor'
|
|
3
5
|
|
|
4
6
|
import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
|
|
@@ -283,6 +285,7 @@ export async function processHtml(
|
|
|
283
285
|
const components: Record<string, ComponentInstance> = {}
|
|
284
286
|
const sourceLocationMap = new Map<string, { file: string; line: number }>()
|
|
285
287
|
const markedComponentRoots = new Set<HTMLNode>()
|
|
288
|
+
const markdownRegionPaths = new Map<string, string>()
|
|
286
289
|
let collectionWrapperId: string | undefined
|
|
287
290
|
const componentCountPerParent = new Map<string, Map<string, number>>()
|
|
288
291
|
|
|
@@ -462,6 +465,7 @@ export async function processHtml(
|
|
|
462
465
|
// Collection wrapper detection pass: find the element that wraps markdown content
|
|
463
466
|
// This needs to run BEFORE image marking so we can skip images inside markdown
|
|
464
467
|
let markdownWrapperNode: HTMLNode | null = null
|
|
468
|
+
const markdownWrapperNodes = new Set<HTMLNode>()
|
|
465
469
|
|
|
466
470
|
// Three strategies in priority order:
|
|
467
471
|
// 0. Rehype marker: the rehype-cms-marker plugin marks the first rendered element
|
|
@@ -473,16 +477,24 @@ export async function processHtml(
|
|
|
473
477
|
let foundWrapper = false
|
|
474
478
|
|
|
475
479
|
// Strategy 0: Rehype marker — most reliable
|
|
476
|
-
const
|
|
477
|
-
|
|
480
|
+
const markerEls = root.querySelectorAll('[data-cms-markdown-content]')
|
|
481
|
+
for (const markerEl of markerEls) {
|
|
482
|
+
const markerPath = markerEl.getAttribute('data-cms-markdown-content') ?? ''
|
|
478
483
|
markerEl.removeAttribute('data-cms-markdown-content')
|
|
479
484
|
const parent = markerEl.parentNode as HTMLNode | null
|
|
480
485
|
if (parent && parent.tagName) {
|
|
481
486
|
const id = getNextId()
|
|
482
487
|
parent.setAttribute(attributeName, id)
|
|
483
488
|
parent.setAttribute('data-cms-markdown', 'true')
|
|
484
|
-
|
|
485
|
-
|
|
489
|
+
if (markerPath) {
|
|
490
|
+
const relPath = path.relative(getProjectRoot(), markerPath).split(path.sep).join('/').replace(/\\/g, '/')
|
|
491
|
+
markdownRegionPaths.set(id, relPath)
|
|
492
|
+
}
|
|
493
|
+
if (!collectionWrapperId) {
|
|
494
|
+
collectionWrapperId = id
|
|
495
|
+
markdownWrapperNode = parent
|
|
496
|
+
}
|
|
497
|
+
markdownWrapperNodes.add(parent)
|
|
486
498
|
foundWrapper = true
|
|
487
499
|
}
|
|
488
500
|
}
|
|
@@ -517,6 +529,7 @@ export async function processHtml(
|
|
|
517
529
|
node.setAttribute('data-cms-markdown', 'true')
|
|
518
530
|
collectionWrapperId = id
|
|
519
531
|
markdownWrapperNode = node
|
|
532
|
+
markdownWrapperNodes.add(node)
|
|
520
533
|
foundWrapper = true
|
|
521
534
|
}
|
|
522
535
|
}
|
|
@@ -584,6 +597,7 @@ export async function processHtml(
|
|
|
584
597
|
bestWrapper.setAttribute('data-cms-markdown', 'true')
|
|
585
598
|
collectionWrapperId = id
|
|
586
599
|
markdownWrapperNode = bestWrapper
|
|
600
|
+
markdownWrapperNodes.add(bestWrapper)
|
|
587
601
|
foundWrapper = true
|
|
588
602
|
}
|
|
589
603
|
}
|
|
@@ -635,6 +649,7 @@ export async function processHtml(
|
|
|
635
649
|
best.node.setAttribute('data-cms-markdown', 'true')
|
|
636
650
|
collectionWrapperId = id
|
|
637
651
|
markdownWrapperNode = best.node
|
|
652
|
+
markdownWrapperNodes.add(best.node)
|
|
638
653
|
foundWrapper = true
|
|
639
654
|
}
|
|
640
655
|
}
|
|
@@ -644,10 +659,10 @@ export async function processHtml(
|
|
|
644
659
|
|
|
645
660
|
// Helper function to check if a node is inside the markdown wrapper
|
|
646
661
|
const isInsideMarkdownWrapper = (node: HTMLNode): boolean => {
|
|
647
|
-
if (!markdownWrapperNode) return false
|
|
662
|
+
if (!markdownWrapperNode && markdownWrapperNodes.size === 0) return false
|
|
648
663
|
let current = node.parentNode as HTMLNode | null
|
|
649
664
|
while (current) {
|
|
650
|
-
if (current === markdownWrapperNode) return true
|
|
665
|
+
if (current === markdownWrapperNode || markdownWrapperNodes.has(current)) return true
|
|
651
666
|
current = current.parentNode as HTMLNode | null
|
|
652
667
|
}
|
|
653
668
|
return false
|
|
@@ -998,7 +1013,7 @@ export async function processHtml(
|
|
|
998
1013
|
// Add collection info for the wrapper entry
|
|
999
1014
|
collectionName: isCollectionWrapper ? collectionInfo?.name : undefined,
|
|
1000
1015
|
collectionSlug: isCollectionWrapper ? collectionInfo?.slug : undefined,
|
|
1001
|
-
contentPath: isCollectionWrapper ? collectionInfo?.contentPath : undefined,
|
|
1016
|
+
contentPath: markdownRegionPaths.get(id) ?? (isCollectionWrapper ? collectionInfo?.contentPath : undefined),
|
|
1002
1017
|
// Robustness fields
|
|
1003
1018
|
stableId,
|
|
1004
1019
|
// Image metadata for image entries
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,9 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
34
34
|
theme?: Record<string, string>
|
|
35
35
|
themePreset?: string
|
|
36
36
|
features?: CmsFeatures
|
|
37
|
+
listStyles?: Array<{ label: string; class: string }>
|
|
38
|
+
/** Open the entry editor's metadata (frontmatter) panel by default instead of collapsed. */
|
|
39
|
+
openMetadataByDefault?: boolean
|
|
37
40
|
/**
|
|
38
41
|
* Describes the host site's color theme. The CMS draws editor chrome and outlines
|
|
39
42
|
* in a contrasting color. 'auto' (default) detects via prefers-color-scheme and
|
|
@@ -58,10 +61,12 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
|
|
|
58
61
|
*/
|
|
59
62
|
mdxComponentDirs?: string[]
|
|
60
63
|
/**
|
|
61
|
-
* Per-collection
|
|
64
|
+
* Per-collection overrides for the CMS browser.
|
|
62
65
|
* Highest priority — overrides scanner defaults and frontmatter comment directives.
|
|
63
66
|
*/
|
|
64
67
|
collections?: Record<string, {
|
|
68
|
+
/** Display label shown in the CMS (overrides the name-derived default, e.g. "Jsem Otazky" → "Otázky"). */
|
|
69
|
+
label?: string
|
|
65
70
|
fields?: Record<string, { position?: 'sidebar' | 'header'; group?: string }>
|
|
66
71
|
}>
|
|
67
72
|
/**
|
|
@@ -185,7 +190,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
185
190
|
if (options.collections) {
|
|
186
191
|
for (const [collectionName, overrides] of Object.entries(options.collections)) {
|
|
187
192
|
const def = collectionDefinitions[collectionName]
|
|
188
|
-
if (!def
|
|
193
|
+
if (!def) continue
|
|
194
|
+
if (overrides.label) def.label = overrides.label
|
|
195
|
+
if (!overrides.fields) continue
|
|
189
196
|
for (const field of def.fields) {
|
|
190
197
|
const fieldOverride = overrides.fields[field.name]
|
|
191
198
|
if (!fieldOverride) continue
|