@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
package/package.json
CHANGED
|
@@ -1,8 +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 { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
|
|
4
5
|
import { getProjectRoot } from './config'
|
|
5
|
-
import { parseContentConfig, type ParsedConfig } from './content-config-ast'
|
|
6
|
+
import { parseContentConfig, type ParsedConfig, type ParsedField } from './content-config-ast'
|
|
6
7
|
import { slugifyHref } from './shared'
|
|
7
8
|
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
|
|
8
9
|
|
|
@@ -213,9 +214,14 @@ function inferFieldType(value: unknown, key: string): FieldType {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
/**
|
|
216
|
-
* Merge field observations from multiple files to determine final field definition
|
|
217
|
+
* Merge field observations from multiple files to determine final field definition.
|
|
218
|
+
* `depth` guards against pathological deeply-nested content blowing the stack —
|
|
219
|
+
* real-world YAML/JSON rarely exceeds 5 levels, so the cap is well above realistic use.
|
|
217
220
|
*/
|
|
218
|
-
|
|
221
|
+
const MAX_NESTED_FIELD_DEPTH = 16
|
|
222
|
+
|
|
223
|
+
function mergeFieldObservations(observations: FieldObservation[], depth: number = 0): FieldDefinition[] {
|
|
224
|
+
if (depth >= MAX_NESTED_FIELD_DEPTH) return []
|
|
219
225
|
const fields: FieldDefinition[] = []
|
|
220
226
|
|
|
221
227
|
for (const obs of observations) {
|
|
@@ -283,12 +289,26 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
|
|
|
283
289
|
for (const item of objectItems) {
|
|
284
290
|
collectFieldObservations(subFieldMap, item, objectItems.length)
|
|
285
291
|
}
|
|
286
|
-
field.fields = mergeFieldObservations(Array.from(subFieldMap.values()))
|
|
292
|
+
field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
|
|
298
|
+
// For plain object values, recurse into sub-fields so the editor can render them.
|
|
299
|
+
if (fieldType === 'object') {
|
|
300
|
+
const objectValues = nonNullValues.filter(
|
|
301
|
+
(v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v),
|
|
302
|
+
)
|
|
303
|
+
if (objectValues.length > 0) {
|
|
304
|
+
const subFieldMap = new Map<string, FieldObservation>()
|
|
305
|
+
for (const item of objectValues) {
|
|
306
|
+
collectFieldObservations(subFieldMap, item, objectValues.length)
|
|
307
|
+
}
|
|
308
|
+
field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
292
312
|
fields.push(field)
|
|
293
313
|
}
|
|
294
314
|
|
|
@@ -311,7 +331,7 @@ function collectFieldObservations(
|
|
|
311
331
|
}
|
|
312
332
|
}
|
|
313
333
|
|
|
314
|
-
function
|
|
334
|
+
function assembleCollectionDefinition(
|
|
315
335
|
collectionName: string,
|
|
316
336
|
contentDir: string,
|
|
317
337
|
fieldMap: Map<string, FieldObservation>,
|
|
@@ -340,6 +360,84 @@ function buildCollectionDefinition(
|
|
|
340
360
|
}
|
|
341
361
|
}
|
|
342
362
|
|
|
363
|
+
function getCollectionSourceBasePath(basePath: string, collectionName: string, contentDir: string): string {
|
|
364
|
+
const projectRoot = getProjectRoot()
|
|
365
|
+
const defaultContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir)
|
|
366
|
+
const defaultCollectionPath = path.join(defaultContentDir, collectionName)
|
|
367
|
+
if (path.resolve(basePath) === path.resolve(defaultCollectionPath)) {
|
|
368
|
+
return path.join(contentDir, collectionName)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const relativeBase = path.relative(projectRoot, basePath)
|
|
372
|
+
if (relativeBase && !relativeBase.startsWith('..') && !path.isAbsolute(relativeBase)) {
|
|
373
|
+
return relativeBase
|
|
374
|
+
}
|
|
375
|
+
return basePath
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function buildCollectionDefinition(
|
|
379
|
+
basePath: string,
|
|
380
|
+
sources: Array<{ slug: string; relPath: string }>,
|
|
381
|
+
collectionName: string,
|
|
382
|
+
contentDir: string,
|
|
383
|
+
): Promise<CollectionDefinition | null> {
|
|
384
|
+
if (sources.length === 0) return null
|
|
385
|
+
|
|
386
|
+
const sourceBasePath = getCollectionSourceBasePath(basePath, collectionName, contentDir)
|
|
387
|
+
const hasMd = sources.some(s => s.relPath.endsWith('.md'))
|
|
388
|
+
const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
|
|
389
|
+
|
|
390
|
+
const fieldMap = new Map<string, FieldObservation>()
|
|
391
|
+
const allDirectives: Record<string, { position?: 'sidebar' | 'header'; group?: string }> = {}
|
|
392
|
+
const entryInfos: CollectionEntryInfo[] = []
|
|
393
|
+
let hasDraft = false
|
|
394
|
+
|
|
395
|
+
const fileContents = await Promise.all(
|
|
396
|
+
sources.map(s => fs.readFile(path.join(basePath, s.relPath), 'utf-8')),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
for (let i = 0; i < sources.length; i++) {
|
|
400
|
+
const source = sources[i]!
|
|
401
|
+
const content = fileContents[i]!
|
|
402
|
+
const frontmatter = parseFrontmatter(content)
|
|
403
|
+
|
|
404
|
+
const directives = parseFieldDirectives(content)
|
|
405
|
+
for (const [key, value] of Object.entries(directives)) {
|
|
406
|
+
if (!allDirectives[key]) {
|
|
407
|
+
allDirectives[key] = value
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const entryInfo: CollectionEntryInfo = {
|
|
412
|
+
slug: source.slug,
|
|
413
|
+
sourcePath: path.join(sourceBasePath, source.relPath),
|
|
414
|
+
}
|
|
415
|
+
if (frontmatter) {
|
|
416
|
+
if (typeof frontmatter.title === 'string') {
|
|
417
|
+
entryInfo.title = frontmatter.title
|
|
418
|
+
}
|
|
419
|
+
if (typeof frontmatter.draft === 'boolean' && frontmatter.draft) {
|
|
420
|
+
entryInfo.draft = true
|
|
421
|
+
}
|
|
422
|
+
entryInfo.data = frontmatter
|
|
423
|
+
}
|
|
424
|
+
entryInfos.push(entryInfo)
|
|
425
|
+
|
|
426
|
+
if (!frontmatter) continue
|
|
427
|
+
|
|
428
|
+
if (frontmatter.draft === true) hasDraft = true
|
|
429
|
+
collectFieldObservations(fieldMap, frontmatter, sources.length)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const def = assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
|
|
433
|
+
path: sourceBasePath,
|
|
434
|
+
supportsDraft: hasDraft,
|
|
435
|
+
fileExtension,
|
|
436
|
+
})
|
|
437
|
+
assignFieldMetadata(def.fields, allDirectives)
|
|
438
|
+
return def
|
|
439
|
+
}
|
|
440
|
+
|
|
343
441
|
/**
|
|
344
442
|
* Scan a single collection directory and infer its schema
|
|
345
443
|
*/
|
|
@@ -378,58 +476,86 @@ async function scanCollection(collectionPath: string, collectionName: string, co
|
|
|
378
476
|
}
|
|
379
477
|
|
|
380
478
|
if (sources.length === 0) return null
|
|
479
|
+
return await buildCollectionDefinition(collectionPath, sources, collectionName, contentDir)
|
|
480
|
+
} catch {
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
}
|
|
381
484
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
for (let i = 0; i < sources.length; i++) {
|
|
395
|
-
const source = sources[i]!
|
|
396
|
-
const content = fileContents[i]!
|
|
397
|
-
const frontmatter = parseFrontmatter(content)
|
|
398
|
-
|
|
399
|
-
const directives = parseFieldDirectives(content)
|
|
400
|
-
for (const [key, value] of Object.entries(directives)) {
|
|
401
|
-
if (!allDirectives[key]) {
|
|
402
|
-
allDirectives[key] = value
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const entryInfo: CollectionEntryInfo = {
|
|
407
|
-
slug: source.slug,
|
|
408
|
-
sourcePath: path.join(contentDir, collectionName, source.relPath),
|
|
485
|
+
/** Convert a glob pattern (supports `*`, `**`, `?`, `{a,b}`) to an anchored RegExp. */
|
|
486
|
+
function globToRegExp(glob: string): RegExp {
|
|
487
|
+
let re = ''
|
|
488
|
+
for (let i = 0; i < glob.length; i++) {
|
|
489
|
+
const c = glob[i]!
|
|
490
|
+
if (c === '*') {
|
|
491
|
+
if (glob[i + 1] === '*') {
|
|
492
|
+
re += '.*'
|
|
493
|
+
i++
|
|
494
|
+
if (glob[i + 1] === '/') i++
|
|
495
|
+
} else {
|
|
496
|
+
re += '[^/]*'
|
|
409
497
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
498
|
+
} else if (c === '?') {
|
|
499
|
+
re += '[^/]'
|
|
500
|
+
} else if (c === '{') {
|
|
501
|
+
const end = glob.indexOf('}', i)
|
|
502
|
+
if (end === -1) {
|
|
503
|
+
re += '\\{'
|
|
504
|
+
} else {
|
|
505
|
+
const opts = glob.slice(i + 1, end).split(',').map(s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
|
|
506
|
+
re += `(?:${opts.join('|')})`
|
|
507
|
+
i = end
|
|
418
508
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
509
|
+
} else if ('.+^$()|[]\\'.includes(c)) {
|
|
510
|
+
re += `\\${c}`
|
|
511
|
+
} else {
|
|
512
|
+
re += c
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return new RegExp(`^${re}$`)
|
|
516
|
+
}
|
|
422
517
|
|
|
423
|
-
|
|
424
|
-
|
|
518
|
+
/** Recursively list files under `dir`, returning forward-slash paths relative to `dir`. */
|
|
519
|
+
async function walkFiles(dir: string, prefix = ''): Promise<string[]> {
|
|
520
|
+
let dirEntries: Dirent[]
|
|
521
|
+
try {
|
|
522
|
+
dirEntries = await fs.readdir(dir, { withFileTypes: true })
|
|
523
|
+
} catch {
|
|
524
|
+
return []
|
|
525
|
+
}
|
|
526
|
+
const out: string[] = []
|
|
527
|
+
for (const entry of dirEntries) {
|
|
528
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
|
|
529
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
530
|
+
if (entry.isDirectory()) {
|
|
531
|
+
out.push(...await walkFiles(path.join(dir, entry.name), rel))
|
|
532
|
+
} else if (entry.isFile()) {
|
|
533
|
+
out.push(rel)
|
|
425
534
|
}
|
|
535
|
+
}
|
|
536
|
+
return out
|
|
537
|
+
}
|
|
426
538
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Scan a collection declared in content config via a glob loader (base + pattern),
|
|
541
|
+
* which may share a base directory with another collection (nested layout).
|
|
542
|
+
* Runtime-agnostic: walks the filesystem and matches the glob (no Bun.Glob dependency).
|
|
543
|
+
*/
|
|
544
|
+
async function scanGlobCollection(
|
|
545
|
+
collectionName: string,
|
|
546
|
+
baseRel: string,
|
|
547
|
+
pattern: string,
|
|
548
|
+
contentDir: string,
|
|
549
|
+
): Promise<CollectionDefinition | null> {
|
|
550
|
+
try {
|
|
551
|
+
const absBase = path.join(getProjectRoot(), baseRel)
|
|
552
|
+
const matcher = globToRegExp(pattern)
|
|
553
|
+
const sources = (await walkFiles(absBase))
|
|
554
|
+
.filter(rel => (rel.endsWith('.md') || rel.endsWith('.mdx')) && matcher.test(rel))
|
|
555
|
+
.map(rel => ({ slug: rel.replace(/\.(md|mdx)$/, ''), relPath: rel }))
|
|
556
|
+
|
|
557
|
+
if (sources.length === 0) return null
|
|
558
|
+
return await buildCollectionDefinition(absBase, sources, collectionName, contentDir)
|
|
433
559
|
} catch {
|
|
434
560
|
return null
|
|
435
561
|
}
|
|
@@ -456,17 +582,65 @@ function applyParsedConfig(
|
|
|
456
582
|
for (const pf of parsedColl.fields) {
|
|
457
583
|
const field = fieldsByName.get(pf.name)
|
|
458
584
|
if (!field) continue
|
|
459
|
-
|
|
460
|
-
field.type = pf.type
|
|
461
|
-
if (pf.options) field.options = pf.options
|
|
462
|
-
}
|
|
463
|
-
if (pf.hints) field.hints = pf.hints
|
|
464
|
-
if (pf.astroImage) field.astroImage = true
|
|
465
|
-
field.required = pf.required
|
|
585
|
+
applyParsedFieldOverrides(field, pf)
|
|
466
586
|
}
|
|
467
587
|
}
|
|
468
588
|
}
|
|
469
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Apply parsed schema overrides to an inferred field, recursing into nested object/array fields.
|
|
592
|
+
*
|
|
593
|
+
* Note on schema-vs-inferred merging at nested levels: schema-declared sub-fields replace
|
|
594
|
+
* the inferred list rather than merging. Inferred-only sub-fields are *not* lost — the
|
|
595
|
+
* editor's `ObjectFields` recovers them via its `extraKeys` calculation (field value keys
|
|
596
|
+
* minus schemaNames), routes them through `FrontmatterField` (value-based auto-detect),
|
|
597
|
+
* and offers a remove button. Merging here would defeat that.
|
|
598
|
+
*/
|
|
599
|
+
function applyParsedFieldOverrides(field: FieldDefinition, pf: ParsedField): void {
|
|
600
|
+
if (pf.type) {
|
|
601
|
+
field.type = pf.type
|
|
602
|
+
if (pf.options) field.options = pf.options
|
|
603
|
+
}
|
|
604
|
+
if (pf.itemType) field.itemType = pf.itemType
|
|
605
|
+
if (pf.hints) field.hints = pf.hints
|
|
606
|
+
if (pf.astroImage) field.astroImage = true
|
|
607
|
+
field.required = pf.required
|
|
608
|
+
|
|
609
|
+
if (pf.fields) {
|
|
610
|
+
const existingByName = new Map((field.fields ?? []).map(f => [f.name, f]))
|
|
611
|
+
field.fields = pf.fields.map((subPf) => {
|
|
612
|
+
const existing = existingByName.get(subPf.name)
|
|
613
|
+
if (existing) {
|
|
614
|
+
applyParsedFieldOverrides(existing, subPf)
|
|
615
|
+
return existing
|
|
616
|
+
}
|
|
617
|
+
return parsedFieldToFieldDefinition(subPf)
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Build a FieldDefinition from a parsed schema field when no inferred counterpart exists.
|
|
624
|
+
* Falls back to `'text'` when the parser couldn't pin a type — keeps the field visible
|
|
625
|
+
* and editable. Schema-declared-but-data-absent fields would otherwise vanish.
|
|
626
|
+
*/
|
|
627
|
+
function parsedFieldToFieldDefinition(pf: ParsedField): FieldDefinition {
|
|
628
|
+
const fd: FieldDefinition = {
|
|
629
|
+
name: pf.name,
|
|
630
|
+
// A parsed field with nested children but no explicit type is necessarily an object.
|
|
631
|
+
// Otherwise default to 'text' so users can still fill in schema-declared fields
|
|
632
|
+
// whose helper the parser didn't recognize.
|
|
633
|
+
type: pf.type ?? (pf.fields ? 'object' : 'text'),
|
|
634
|
+
required: pf.required,
|
|
635
|
+
}
|
|
636
|
+
if (pf.options) fd.options = pf.options
|
|
637
|
+
if (pf.itemType) fd.itemType = pf.itemType
|
|
638
|
+
if (pf.hints) fd.hints = pf.hints
|
|
639
|
+
if (pf.astroImage) fd.astroImage = true
|
|
640
|
+
if (pf.fields) fd.fields = pf.fields.map(parsedFieldToFieldDefinition)
|
|
641
|
+
return fd
|
|
642
|
+
}
|
|
643
|
+
|
|
470
644
|
/** Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries. */
|
|
471
645
|
function applyCollectionOrderBy(
|
|
472
646
|
collections: Record<string, CollectionDefinition>,
|
|
@@ -753,7 +927,7 @@ async function scanDataCollection(collectionPath: string, collectionName: string
|
|
|
753
927
|
collectFieldObservations(fieldMap, data, sources.length)
|
|
754
928
|
}
|
|
755
929
|
|
|
756
|
-
return
|
|
930
|
+
return assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
|
|
757
931
|
type: 'data',
|
|
758
932
|
fileExtension: ext,
|
|
759
933
|
})
|
|
@@ -792,6 +966,20 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
|
|
|
792
966
|
|
|
793
967
|
// Post-scan: apply schema-driven field config, detect references, derived fields, and ordering
|
|
794
968
|
const parsed = await parseContentConfig()
|
|
969
|
+
for (const [collectionName, parsedCollection] of parsed) {
|
|
970
|
+
if (collections[collectionName]) continue
|
|
971
|
+
if (!parsedCollection.loaderBase || !parsedCollection.loaderPattern) continue
|
|
972
|
+
const definition = await scanGlobCollection(collectionName, parsedCollection.loaderBase, parsedCollection.loaderPattern, contentDir)
|
|
973
|
+
if (!definition) continue
|
|
974
|
+
// Nest under the collection that owns the shared base directory (e.g. jsem-otazky -> jsem),
|
|
975
|
+
// so the CMS browser can group it under its parent page instead of listing it flat.
|
|
976
|
+
const baseName = parsedCollection.loaderBase.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
|
977
|
+
if (baseName && baseName !== collectionName && collections[baseName]) {
|
|
978
|
+
definition.parentCollection = baseName
|
|
979
|
+
}
|
|
980
|
+
collections[collectionName] = definition
|
|
981
|
+
}
|
|
982
|
+
|
|
795
983
|
applyParsedConfig(collections, parsed)
|
|
796
984
|
detectReferenceFields(collections, parsed)
|
|
797
985
|
detectDerivedHrefFields(collections)
|