@nuasite/cms 0.41.0 → 0.42.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,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()
@@ -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
  /**
@@ -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 filePath = `src/content/${collection}/${normalizedSlug}.${ext}`
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('---')) {
@@ -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 markerEl = root.querySelector('[data-cms-markdown-content]')
477
- if (markerEl) {
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
- collectionWrapperId = id
485
- markdownWrapperNode = parent
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 field overrides for position and grouping.
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 || !overrides.fields) continue
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
@@ -5,11 +5,11 @@
5
5
  * relying on heuristics.
6
6
  */
7
7
  export function rehypeCmsMarker() {
8
- return (tree: any) => {
8
+ return (tree: any, file: any) => {
9
9
  const firstElement = tree.children?.find((n: any) => n.type === 'element')
10
10
  if (firstElement) {
11
11
  firstElement.properties ??= {}
12
- firstElement.properties['dataCmsMarkdownContent'] = ''
12
+ firstElement.properties['dataCmsMarkdownContent'] = file?.path ?? (Array.isArray(file?.history) ? file.history[0] : '') ?? ''
13
13
  }
14
14
  }
15
15
  }
package/src/types.ts CHANGED
@@ -345,6 +345,12 @@ export interface CollectionDefinition {
345
345
  orderBy?: string
346
346
  /** Sort direction for orderBy field */
347
347
  orderDirection?: 'asc' | 'desc'
348
+ /**
349
+ * Name of the collection this one is nested under in the CMS browser, when it shares a base
350
+ * directory with another collection (e.g. a nested `*​/otazky/*` collection grouped under the
351
+ * `*​/index.md` collection at the same base). Purely presentational grouping.
352
+ */
353
+ parentCollection?: string
348
354
  }
349
355
 
350
356
  /** Manifest metadata for versioning and conflict detection */