@nuasite/cms 0.41.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 +10589 -10349
- package/package.json +1 -1
- package/src/collection-scanner.ts +169 -48
- package/src/content-config-ast.ts +81 -3
- package/src/editor/components/collections-browser.tsx +20 -4
- package/src/editor/components/frontmatter-fields.tsx +29 -1
- 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/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 +6 -0
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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'
|
|
@@ -330,7 +331,7 @@ function collectFieldObservations(
|
|
|
330
331
|
}
|
|
331
332
|
}
|
|
332
333
|
|
|
333
|
-
function
|
|
334
|
+
function assembleCollectionDefinition(
|
|
334
335
|
collectionName: string,
|
|
335
336
|
contentDir: string,
|
|
336
337
|
fieldMap: Map<string, FieldObservation>,
|
|
@@ -359,6 +360,84 @@ function buildCollectionDefinition(
|
|
|
359
360
|
}
|
|
360
361
|
}
|
|
361
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
|
+
|
|
362
441
|
/**
|
|
363
442
|
* Scan a single collection directory and infer its schema
|
|
364
443
|
*/
|
|
@@ -397,58 +476,86 @@ async function scanCollection(collectionPath: string, collectionName: string, co
|
|
|
397
476
|
}
|
|
398
477
|
|
|
399
478
|
if (sources.length === 0) return null
|
|
479
|
+
return await buildCollectionDefinition(collectionPath, sources, collectionName, contentDir)
|
|
480
|
+
} catch {
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
}
|
|
400
484
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
for (let i = 0; i < sources.length; i++) {
|
|
414
|
-
const source = sources[i]!
|
|
415
|
-
const content = fileContents[i]!
|
|
416
|
-
const frontmatter = parseFrontmatter(content)
|
|
417
|
-
|
|
418
|
-
const directives = parseFieldDirectives(content)
|
|
419
|
-
for (const [key, value] of Object.entries(directives)) {
|
|
420
|
-
if (!allDirectives[key]) {
|
|
421
|
-
allDirectives[key] = value
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const entryInfo: CollectionEntryInfo = {
|
|
426
|
-
slug: source.slug,
|
|
427
|
-
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 += '[^/]*'
|
|
428
497
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
437
508
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
509
|
+
} else if ('.+^$()|[]\\'.includes(c)) {
|
|
510
|
+
re += `\\${c}`
|
|
511
|
+
} else {
|
|
512
|
+
re += c
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return new RegExp(`^${re}$`)
|
|
516
|
+
}
|
|
441
517
|
|
|
442
|
-
|
|
443
|
-
|
|
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)
|
|
444
534
|
}
|
|
535
|
+
}
|
|
536
|
+
return out
|
|
537
|
+
}
|
|
445
538
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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)
|
|
452
559
|
} catch {
|
|
453
560
|
return null
|
|
454
561
|
}
|
|
@@ -820,7 +927,7 @@ async function scanDataCollection(collectionPath: string, collectionName: string
|
|
|
820
927
|
collectFieldObservations(fieldMap, data, sources.length)
|
|
821
928
|
}
|
|
822
929
|
|
|
823
|
-
return
|
|
930
|
+
return assembleCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
|
|
824
931
|
type: 'data',
|
|
825
932
|
fileExtension: ext,
|
|
826
933
|
})
|
|
@@ -859,6 +966,20 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
|
|
|
859
966
|
|
|
860
967
|
// Post-scan: apply schema-driven field config, detect references, derived fields, and ordering
|
|
861
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
|
+
|
|
862
983
|
applyParsedConfig(collections, parsed)
|
|
863
984
|
detectReferenceFields(collections, parsed)
|
|
864
985
|
detectDerivedHrefFields(collections)
|
|
@@ -29,6 +29,8 @@ export interface ParsedField {
|
|
|
29
29
|
export interface ParsedCollection {
|
|
30
30
|
name: string
|
|
31
31
|
fields: ParsedField[]
|
|
32
|
+
loaderPattern?: string
|
|
33
|
+
loaderBase?: string
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export type ParsedConfig = Map<string, ParsedCollection>
|
|
@@ -129,6 +131,7 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
|
|
|
129
131
|
const bindings: Bindings = new Map()
|
|
130
132
|
const collectionDecls = new Map<string, t.ObjectExpression>()
|
|
131
133
|
const exportMap = new Map<string, string>() // varName → collectionName
|
|
134
|
+
const inlineCollections = new Map<string, t.ObjectExpression>() // collectionName → defineCollection arg (inline form)
|
|
132
135
|
|
|
133
136
|
for (const stmt of ast.program.body) {
|
|
134
137
|
const varDecl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration'
|
|
@@ -151,6 +154,12 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
|
|
|
151
154
|
if (!key) continue
|
|
152
155
|
if (prop.value.type === 'Identifier') {
|
|
153
156
|
exportMap.set(prop.value.name, key)
|
|
157
|
+
} else if (prop.value.type === 'CallExpression' && isDefineCollectionCallee(prop.value.callee)) {
|
|
158
|
+
// Inline form: `collections = { name: defineCollection({...}) }`
|
|
159
|
+
const inlineArg = prop.value.arguments[0]
|
|
160
|
+
if (inlineArg?.type === 'ObjectExpression') {
|
|
161
|
+
inlineCollections.set(key, inlineArg)
|
|
162
|
+
}
|
|
154
163
|
}
|
|
155
164
|
}
|
|
156
165
|
continue
|
|
@@ -165,23 +174,57 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
|
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
|
|
177
|
+
// Unify both styles: inline `name: defineCollection({...})` and the
|
|
178
|
+
// `const x = defineCollection({...}); collections = { name: x }` reference form.
|
|
179
|
+
const collectionObjects = new Map<string, t.ObjectExpression>(inlineCollections)
|
|
168
180
|
for (const [varName, collectionName] of exportMap) {
|
|
169
181
|
const decl = collectionDecls.get(varName)
|
|
170
|
-
if (
|
|
182
|
+
if (decl) collectionObjects.set(collectionName, decl)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const [collectionName, decl] of collectionObjects) {
|
|
186
|
+
const loaderProperty = decl.properties.find(
|
|
187
|
+
p =>
|
|
188
|
+
p.type === 'ObjectProperty'
|
|
189
|
+
&& propertyKeyName(p.key) === 'loader',
|
|
190
|
+
) as t.ObjectProperty | undefined
|
|
191
|
+
const loaderOptions = loaderProperty ? extractGlobLoaderOptions(loaderProperty.value, bindings) : {}
|
|
192
|
+
const loaderPattern = loaderOptions.pattern
|
|
193
|
+
const loaderBase = loaderOptions.base
|
|
171
194
|
|
|
172
195
|
const schemaProperty = decl.properties.find(
|
|
173
196
|
p =>
|
|
174
197
|
p.type === 'ObjectProperty'
|
|
175
198
|
&& propertyKeyName(p.key) === 'schema',
|
|
176
199
|
) as t.ObjectProperty | undefined
|
|
177
|
-
if (!schemaProperty)
|
|
200
|
+
if (!schemaProperty) {
|
|
201
|
+
if (!loaderPattern) continue
|
|
202
|
+
result.set(collectionName, {
|
|
203
|
+
name: collectionName,
|
|
204
|
+
fields: [],
|
|
205
|
+
loaderPattern,
|
|
206
|
+
loaderBase,
|
|
207
|
+
})
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
178
210
|
|
|
179
211
|
const schemaObject = unwrapSchemaToObject(schemaProperty.value, bindings)
|
|
180
|
-
if (!schemaObject)
|
|
212
|
+
if (!schemaObject) {
|
|
213
|
+
if (!loaderPattern) continue
|
|
214
|
+
result.set(collectionName, {
|
|
215
|
+
name: collectionName,
|
|
216
|
+
fields: [],
|
|
217
|
+
loaderPattern,
|
|
218
|
+
loaderBase,
|
|
219
|
+
})
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
181
222
|
|
|
182
223
|
result.set(collectionName, {
|
|
183
224
|
name: collectionName,
|
|
184
225
|
fields: parseSchemaFields(schemaObject, bindings),
|
|
226
|
+
loaderPattern,
|
|
227
|
+
loaderBase,
|
|
185
228
|
})
|
|
186
229
|
}
|
|
187
230
|
|
|
@@ -198,6 +241,41 @@ function propertyKeyName(key: t.Node): string | null {
|
|
|
198
241
|
return null
|
|
199
242
|
}
|
|
200
243
|
|
|
244
|
+
function extractGlobLoaderOptions(node: t.Node, bindings: Bindings): { pattern?: string; base?: string } {
|
|
245
|
+
const resolved = resolveExpression(node, bindings)
|
|
246
|
+
if (resolved.type !== 'CallExpression') return {}
|
|
247
|
+
if (!isGlobCallee(resolved.callee)) return {}
|
|
248
|
+
|
|
249
|
+
const arg = resolved.arguments[0]
|
|
250
|
+
if (!arg) return {}
|
|
251
|
+
const options = resolveExpression(arg, bindings)
|
|
252
|
+
if (options.type !== 'ObjectExpression') return {}
|
|
253
|
+
|
|
254
|
+
const result: { pattern?: string; base?: string } = {}
|
|
255
|
+
for (const prop of options.properties) {
|
|
256
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
257
|
+
const key = propertyKeyName(prop.key)
|
|
258
|
+
if (key !== 'pattern' && key !== 'base') continue
|
|
259
|
+
const value = extractStaticString(prop.value, bindings)
|
|
260
|
+
if (value !== undefined) result[key] = value
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function extractStaticString(node: t.Node, bindings: Bindings): string | undefined {
|
|
267
|
+
const resolved = resolveExpression(node, bindings)
|
|
268
|
+
if (resolved.type === 'StringLiteral') return resolved.value
|
|
269
|
+
if (resolved.type === 'TemplateLiteral' && resolved.expressions.length === 0) {
|
|
270
|
+
return resolved.quasis[0]?.value.cooked ?? resolved.quasis[0]?.value.raw
|
|
271
|
+
}
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isGlobCallee(callee: t.Node): boolean {
|
|
276
|
+
return callee.type === 'Identifier' && callee.name === 'glob'
|
|
277
|
+
}
|
|
278
|
+
|
|
201
279
|
/**
|
|
202
280
|
* Unwrap a `schema:` value down to the top-level (z|n).object({ ... }) ObjectExpression.
|
|
203
281
|
* Handles direct calls, the Astro callback form `({ image }) => z.object({...})`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { signal } from '@preact/signals'
|
|
2
2
|
import { useMemo, useState } from 'preact/hooks'
|
|
3
3
|
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
4
5
|
import { deleteMarkdownPage } from '../markdown-api'
|
|
5
6
|
import {
|
|
6
7
|
closeCollectionsBrowser,
|
|
@@ -241,20 +242,35 @@ export function CollectionsBrowser() {
|
|
|
241
242
|
)
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
// Collection list
|
|
245
|
+
// Collection list — group nested (child) collections under their parent
|
|
246
|
+
const ordered: Array<{ col: typeof collections[number]; nested: boolean }> = []
|
|
247
|
+
for (const col of collections.filter(c => !c.parentCollection)) {
|
|
248
|
+
ordered.push({ col, nested: false })
|
|
249
|
+
for (const child of collections.filter(c => c.parentCollection === col.name)) {
|
|
250
|
+
ordered.push({ col: child, nested: true })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Append any orphaned children whose parent isn't present, so nothing is hidden.
|
|
254
|
+
for (const col of collections.filter(c => c.parentCollection && !collections.some(p => p.name === c.parentCollection))) {
|
|
255
|
+
ordered.push({ col, nested: false })
|
|
256
|
+
}
|
|
257
|
+
|
|
245
258
|
return (
|
|
246
259
|
<ModalBackdrop onClose={handleClose} extraClass="flex flex-col max-h-[80vh]">
|
|
247
260
|
<ModalHeader title="Collections" onClose={handleClose} />
|
|
248
261
|
<div class="p-5 space-y-2 overflow-y-auto flex-1 min-h-0">
|
|
249
|
-
{
|
|
262
|
+
{ordered.map(({ col, nested }) => (
|
|
250
263
|
<button
|
|
251
264
|
key={col.name}
|
|
252
265
|
type="button"
|
|
253
266
|
onClick={() => selectBrowserCollection(col.name)}
|
|
254
|
-
class=
|
|
267
|
+
class={cn(
|
|
268
|
+
'group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer',
|
|
269
|
+
nested && 'ml-8 w-[calc(100%-2rem)] border-l-2 border-l-cms-primary/40',
|
|
270
|
+
)}
|
|
255
271
|
data-cms-ui
|
|
256
272
|
>
|
|
257
|
-
<div class=
|
|
273
|
+
<div class={cn('shrink-0 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center', nested ? 'w-8 h-8' : 'w-10 h-10')}>
|
|
258
274
|
<CollectionIcon />
|
|
259
275
|
</div>
|
|
260
276
|
<div class="flex-1 min-w-0">
|
|
@@ -573,7 +573,35 @@ export function SchemaFrontmatterField({
|
|
|
573
573
|
</div>
|
|
574
574
|
)
|
|
575
575
|
|
|
576
|
-
case 'date':
|
|
576
|
+
case 'date': {
|
|
577
|
+
// A `date` field's value is often a full datetime (e.g. "2026-04-14T08:35:00"),
|
|
578
|
+
// which an <input type="date"> can't display (it needs YYYY-MM-DD). Show only the
|
|
579
|
+
// date part, but preserve the original time component on change so editing the date
|
|
580
|
+
// doesn't silently drop the time.
|
|
581
|
+
const raw = value == null ? '' : String(value)
|
|
582
|
+
// Preserve the full time component on change — including fractional seconds and any
|
|
583
|
+
// timezone designator (Z or ±HH:MM) — so editing only the date never drops them.
|
|
584
|
+
const timeSuffix = raw.match(/T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?/)?.[0] ?? ''
|
|
585
|
+
return (
|
|
586
|
+
<div class="flex flex-col gap-1" data-cms-ui>
|
|
587
|
+
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
588
|
+
<input
|
|
589
|
+
type="date"
|
|
590
|
+
value={raw.slice(0, 10)}
|
|
591
|
+
min={hints?.min != null ? String(hints.min) : undefined}
|
|
592
|
+
max={hints?.max != null ? String(hints.max) : undefined}
|
|
593
|
+
required={field.required}
|
|
594
|
+
onInput={(e) => {
|
|
595
|
+
const d = (e.target as HTMLInputElement).value
|
|
596
|
+
onChange(d ? `${d}${timeSuffix}` : '')
|
|
597
|
+
}}
|
|
598
|
+
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
|
|
599
|
+
data-cms-ui
|
|
600
|
+
/>
|
|
601
|
+
</div>
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
577
605
|
case 'datetime':
|
|
578
606
|
case 'time':
|
|
579
607
|
case 'month':
|
|
@@ -60,7 +60,7 @@ export function MarkdownEditorOverlay() {
|
|
|
60
60
|
: activeCollectionDef?.fileExtension === 'mdx'
|
|
61
61
|
|
|
62
62
|
const [isSaving, setIsSaving] = useState(false)
|
|
63
|
-
const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection)
|
|
63
|
+
const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection || config.value.openMetadataByDefault === true)
|
|
64
64
|
// Track whether the user has manually edited the slug (disables auto-slug from title)
|
|
65
65
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
|
|
66
66
|
// Preview mode state
|
|
@@ -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
|
}
|