uniweb 0.8.8 → 0.8.10

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,358 @@
1
+ /**
2
+ * uniweb inspect — Show parsed content shape of markdown files.
3
+ *
4
+ * Usage:
5
+ * uniweb inspect pages/home/hero.md Single section
6
+ * uniweb inspect pages/home/ All sections in a page folder
7
+ * uniweb inspect pages/home/hero.md --raw ProseMirror AST instead of flat shape
8
+ * uniweb inspect pages/home/ --full Include empty fields (matches runtime)
9
+ * uniweb inspect pages/home/ --sequence Include sequence array
10
+ */
11
+
12
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
13
+ import { resolve, extname, basename } from 'node:path'
14
+ import yaml from 'js-yaml'
15
+
16
+ // Colors for terminal output
17
+ const colors = {
18
+ reset: '\x1b[0m',
19
+ red: '\x1b[31m',
20
+ yellow: '\x1b[33m',
21
+ dim: '\x1b[2m',
22
+ }
23
+
24
+ /**
25
+ * Parse CLI arguments for inspect command
26
+ */
27
+ function parseArgs(args) {
28
+ const flags = {
29
+ target: null,
30
+ raw: false,
31
+ full: false,
32
+ sequence: false,
33
+ help: false,
34
+ }
35
+
36
+ for (const arg of args) {
37
+ if (arg === '--raw') flags.raw = true
38
+ else if (arg === '--full') flags.full = true
39
+ else if (arg === '--sequence') flags.sequence = true
40
+ else if (arg === '--help' || arg === '-h') flags.help = true
41
+ else if (!arg.startsWith('-')) flags.target = arg
42
+ }
43
+
44
+ return flags
45
+ }
46
+
47
+ /**
48
+ * Dynamically import content-reader and semantic-parser.
49
+ * These are transitive deps of @uniweb/build, available in any project workspace.
50
+ */
51
+ async function loadDependencies() {
52
+ try {
53
+ const [contentReader, semanticParser] = await Promise.all([
54
+ import('@uniweb/content-reader'),
55
+ import('@uniweb/semantic-parser'),
56
+ ])
57
+ return {
58
+ markdownToProseMirror: contentReader.markdownToProseMirror,
59
+ parseContent: semanticParser.parseContent,
60
+ }
61
+ } catch {
62
+ console.error(`${colors.red}✗${colors.reset} Could not load @uniweb/content-reader or @uniweb/semantic-parser.`)
63
+ console.error(` These packages must be installed in the workspace (they come with @uniweb/build).`)
64
+ process.exit(1)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Extract frontmatter and markdown body from a section file.
70
+ * Exact match of content-collector lines 634-639.
71
+ */
72
+ function extractFrontmatter(content) {
73
+ let frontMatter = {}
74
+ let markdown = content
75
+
76
+ if (content.trim().startsWith('---')) {
77
+ const parts = content.split('---\n')
78
+ if (parts.length >= 3) {
79
+ try {
80
+ frontMatter = yaml.load(parts[1]) || {}
81
+ } catch (err) {
82
+ console.warn(`${colors.yellow}Warning: YAML parse error: ${err.message}${colors.reset}`)
83
+ frontMatter = {}
84
+ }
85
+ markdown = parts.slice(2).join('---\n')
86
+ }
87
+ }
88
+
89
+ return { frontMatter, markdown }
90
+ }
91
+
92
+ /**
93
+ * Split frontmatter into reserved keys and custom params.
94
+ * Exact match of content-collector line 642.
95
+ */
96
+ function splitParams(frontMatter) {
97
+ const { type, component, preset, input, props, fetch, data, id, background, theme, ...params } = frontMatter
98
+ return {
99
+ type: type || component || null,
100
+ preset: preset || null,
101
+ reserved: { data, id, background, theme, input },
102
+ params,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Extract inset references from a ProseMirror document.
108
+ * Exact match of content-collector lines 192-218.
109
+ *
110
+ * @param {object} doc - ProseMirror document (mutated in place)
111
+ * @returns {Array} Array of { refId, type, params, description }
112
+ */
113
+ function extractInsets(doc) {
114
+ if (!doc?.content || !Array.isArray(doc.content)) return []
115
+
116
+ const insets = []
117
+ let refIndex = 0
118
+
119
+ for (let i = 0; i < doc.content.length; i++) {
120
+ const node = doc.content[i]
121
+ if (node.type === 'inset_ref') {
122
+ const { component, alt, ...params } = node.attrs || {}
123
+ const refId = `inset_${refIndex++}`
124
+ insets.push({
125
+ refId,
126
+ type: component,
127
+ params: Object.keys(params).length > 0 ? params : {},
128
+ description: alt || null,
129
+ })
130
+ // Replace in-place with placeholder
131
+ doc.content[i] = {
132
+ type: 'inset_placeholder',
133
+ attrs: { refId },
134
+ }
135
+ }
136
+ }
137
+
138
+ return insets
139
+ }
140
+
141
+ /**
142
+ * Guarantee item has flat content structure.
143
+ * Keep in sync with @uniweb/runtime/src/prepare-props.js
144
+ */
145
+ function guaranteeItemStructure(item) {
146
+ return {
147
+ title: item.title || '',
148
+ pretitle: item.pretitle || '',
149
+ subtitle: item.subtitle || '',
150
+ paragraphs: item.paragraphs || [],
151
+ links: item.links || [],
152
+ imgs: item.imgs || [],
153
+ lists: item.lists || [],
154
+ icons: item.icons || [],
155
+ videos: item.videos || [],
156
+ buttons: item.buttons || [],
157
+ data: item.data || {},
158
+ cards: item.cards || [],
159
+ documents: item.documents || [],
160
+ forms: item.forms || [],
161
+ quotes: item.quotes || [],
162
+ headings: item.headings || [],
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Guarantee content structure exists.
168
+ * Keep in sync with @uniweb/runtime/src/prepare-props.js
169
+ */
170
+ function guaranteeContentStructure(parsedContent) {
171
+ const content = parsedContent || {}
172
+
173
+ return {
174
+ title: content.title || '',
175
+ pretitle: content.pretitle || '',
176
+ subtitle: content.subtitle || '',
177
+ subtitle2: content.subtitle2 || '',
178
+ alignment: content.alignment || null,
179
+ paragraphs: content.paragraphs || [],
180
+ links: content.links || [],
181
+ imgs: content.imgs || [],
182
+ lists: content.lists || [],
183
+ icons: content.icons || [],
184
+ videos: content.videos || [],
185
+ insets: content.insets || [],
186
+ buttons: content.buttons || [],
187
+ data: content.data || {},
188
+ cards: content.cards || [],
189
+ documents: content.documents || [],
190
+ forms: content.forms || [],
191
+ quotes: content.quotes || [],
192
+ headings: content.headings || [],
193
+ items: (content.items || []).map(guaranteeItemStructure),
194
+ sequence: content.sequence || [],
195
+ raw: content.raw,
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Remove empty fields from content for clean output.
201
+ */
202
+ function removeEmptyFields(obj) {
203
+ const result = {}
204
+ for (const [key, value] of Object.entries(obj)) {
205
+ if (key === 'raw') continue // Always skip raw ProseMirror in clean output
206
+ if (value === null || value === undefined) continue
207
+ if (value === '') continue
208
+ if (Array.isArray(value) && value.length === 0) continue
209
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) continue
210
+
211
+ // Recursively clean items
212
+ if (key === 'items' && Array.isArray(value)) {
213
+ result[key] = value.map(item => removeEmptyFields(item))
214
+ } else {
215
+ result[key] = value
216
+ }
217
+ }
218
+ return result
219
+ }
220
+
221
+ /**
222
+ * Process a single markdown file and return its parsed shape.
223
+ */
224
+ function processFile(fileContent, fileName, deps, options) {
225
+ const { raw, full, sequence } = options
226
+ const { markdownToProseMirror, parseContent } = deps
227
+
228
+ const { frontMatter, markdown } = extractFrontmatter(fileContent)
229
+ const { type, preset, reserved, params } = splitParams(frontMatter)
230
+
231
+ // Parse markdown to ProseMirror
232
+ const doc = markdownToProseMirror(markdown)
233
+
234
+ // Extract insets (mutates doc)
235
+ const insets = extractInsets(doc)
236
+
237
+ // Build result
238
+ const result = {}
239
+
240
+ if (type) result.type = type
241
+ if (preset) result.preset = preset
242
+
243
+ // Include non-empty reserved fields
244
+ for (const [key, value] of Object.entries(reserved)) {
245
+ if (value !== undefined && value !== null) result[key] = value
246
+ }
247
+
248
+ // Include params if any
249
+ if (Object.keys(params).length > 0) result.params = params
250
+
251
+ // Check if file is a child section
252
+ if (basename(fileName).startsWith('@')) {
253
+ result.child = true
254
+ }
255
+
256
+ if (raw) {
257
+ // Raw mode: return ProseMirror AST
258
+ result.prosemirror = doc
259
+ if (insets.length > 0) result.insets = insets
260
+ return result
261
+ }
262
+
263
+ // Parse to flat content shape
264
+ const parsed = parseContent(doc)
265
+
266
+ // Apply guarantees
267
+ let content = guaranteeContentStructure(parsed)
268
+
269
+ if (!full) {
270
+ content = removeEmptyFields(content)
271
+ } else {
272
+ // In full mode, still remove raw
273
+ delete content.raw
274
+ }
275
+
276
+ if (!sequence) {
277
+ delete content.sequence
278
+ }
279
+
280
+ result.content = content
281
+
282
+ if (insets.length > 0) result.insets = insets
283
+
284
+ return result
285
+ }
286
+
287
+ /**
288
+ * Natural sort comparator for filenames (handles numeric prefixes).
289
+ */
290
+ function naturalSort(a, b) {
291
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
292
+ }
293
+
294
+ /**
295
+ * Main inspect entry point.
296
+ */
297
+ export async function inspect(args) {
298
+ const flags = parseArgs(args)
299
+
300
+ if (flags.help || !flags.target) {
301
+ console.log(`
302
+ Usage: uniweb inspect <path> [options]
303
+
304
+ Inspect the parsed content shape of a markdown file or page folder.
305
+
306
+ Arguments:
307
+ <path> Path to a .md file or a page folder
308
+
309
+ Options:
310
+ --raw Show ProseMirror AST instead of flat content shape
311
+ --full Include empty fields (matches runtime guarantees)
312
+ --sequence Include sequence array (document-order elements)
313
+ -h, --help Show this help
314
+ `)
315
+ return
316
+ }
317
+
318
+ const target = resolve(process.cwd(), flags.target)
319
+ const deps = await loadDependencies()
320
+
321
+ let stat
322
+ try {
323
+ stat = statSync(target)
324
+ } catch {
325
+ console.error(`${colors.red}✗${colors.reset} Not found: ${flags.target}`)
326
+ process.exit(1)
327
+ }
328
+
329
+ const options = { raw: flags.raw, full: flags.full, sequence: flags.sequence }
330
+
331
+ if (stat.isFile()) {
332
+ if (extname(target) !== '.md') {
333
+ console.error(`${colors.red}✗${colors.reset} Expected a .md file: ${flags.target}`)
334
+ process.exit(1)
335
+ }
336
+ const content = readFileSync(target, 'utf8')
337
+ const result = processFile(content, basename(target), deps, options)
338
+ console.log(JSON.stringify(result, null, 2))
339
+ } else if (stat.isDirectory()) {
340
+ const files = readdirSync(target)
341
+ .filter(f => extname(f) === '.md' && !f.startsWith('_') && f !== 'README.md')
342
+ .sort(naturalSort)
343
+
344
+ if (files.length === 0) {
345
+ console.error(`${colors.yellow}No .md files found in: ${flags.target}${colors.reset}`)
346
+ return
347
+ }
348
+
349
+ const results = files.map(file => {
350
+ const content = readFileSync(resolve(target, file), 'utf8')
351
+ const result = processFile(content, file, deps, options)
352
+ result._file = file
353
+ return result
354
+ })
355
+
356
+ console.log(JSON.stringify(results, null, 2))
357
+ }
358
+ }
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import { build } from './commands/build.js'
21
21
  import { docs } from './commands/docs.js'
22
22
  import { doctor } from './commands/doctor.js'
23
23
  import { i18n } from './commands/i18n.js'
24
+ import { inspect } from './commands/inspect.js'
24
25
  import { add } from './commands/add.js'
25
26
  import {
26
27
  resolveTemplate,
@@ -328,6 +329,12 @@ async function main() {
328
329
  return
329
330
  }
330
331
 
332
+ // Handle inspect command
333
+ if (command === 'inspect') {
334
+ await inspect(args.slice(1))
335
+ return
336
+ }
337
+
331
338
  // Handle add command
332
339
  if (command === 'add') {
333
340
  await add(args.slice(1))
@@ -568,6 +575,7 @@ ${colors.bright}Commands:${colors.reset}
568
575
  create [name] Create a new project
569
576
  add <type> [name] Add a foundation, site, or extension to a project
570
577
  build Build the current project
578
+ inspect <path> Inspect parsed content shape of a markdown file or folder
571
579
  docs Generate component documentation
572
580
  doctor Diagnose project configuration issues
573
581
  i18n <cmd> Internationalization (extract, sync, status)
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises'
9
9
  import { existsSync, readdirSync } from 'node:fs'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
+ import yaml from 'js-yaml'
12
13
  import { copyTemplateDirectory, registerVersions } from '../templates/processor.js'
13
14
  import { getVersionsForTemplates } from '../versions.js'
14
15
 
@@ -103,13 +104,19 @@ export async function applyContent(contentDir, targetDir, context, options = {})
103
104
  '.gitignore',
104
105
  ])
105
106
 
106
- await copyContentRecursive(contentDir, targetDir, context, STRUCTURAL_FILES, options)
107
+ // Config files that should be merged, not overwritten.
108
+ // Keys listed here are preserved from the scaffolded version.
109
+ const MERGE_FILES = {
110
+ 'site.yml': ['name', 'foundation'],
111
+ }
112
+
113
+ await copyContentRecursive(contentDir, targetDir, context, STRUCTURAL_FILES, MERGE_FILES, options)
107
114
  }
108
115
 
109
116
  /**
110
117
  * Recursively copy content files, skipping structural files
111
118
  */
112
- async function copyContentRecursive(sourceDir, targetDir, context, structuralFiles, options) {
119
+ async function copyContentRecursive(sourceDir, targetDir, context, structuralFiles, mergeFiles, options) {
113
120
  await fs.mkdir(targetDir, { recursive: true })
114
121
 
115
122
  const entries = readdirSync(sourceDir, { withFileTypes: true })
@@ -119,7 +126,7 @@ async function copyContentRecursive(sourceDir, targetDir, context, structuralFil
119
126
 
120
127
  if (entry.isDirectory()) {
121
128
  const targetSubDir = join(targetDir, entry.name)
122
- await copyContentRecursive(sourcePath, targetSubDir, context, structuralFiles, options)
129
+ await copyContentRecursive(sourcePath, targetSubDir, context, structuralFiles, mergeFiles, options)
123
130
  } else {
124
131
  // Determine the output filename (strip .hbs extension)
125
132
  const outputName = entry.name.endsWith('.hbs')
@@ -131,13 +138,30 @@ async function copyContentRecursive(sourceDir, targetDir, context, structuralFil
131
138
 
132
139
  const targetPath = join(targetDir, outputName)
133
140
 
141
+ // Get new content (process .hbs or read as-is)
142
+ let newContent
134
143
  if (entry.name.endsWith('.hbs')) {
135
- // Process through Handlebars
136
144
  const Handlebars = (await import('handlebars')).default
137
- const content = await fs.readFile(sourcePath, 'utf-8')
138
- const template = Handlebars.compile(content)
139
- const result = template(context)
140
- await fs.writeFile(targetPath, result)
145
+ const raw = await fs.readFile(sourcePath, 'utf-8')
146
+ const template = Handlebars.compile(raw)
147
+ newContent = template(context)
148
+ }
149
+
150
+ // Merge config files instead of overwriting
151
+ const preserveKeys = mergeFiles[outputName]
152
+ if (preserveKeys && existsSync(targetPath)) {
153
+ const existingContent = await fs.readFile(targetPath, 'utf-8')
154
+ const existing = yaml.load(existingContent) || {}
155
+ const incoming = yaml.load(newContent || await fs.readFile(sourcePath, 'utf-8')) || {}
156
+
157
+ // Template values as base, preserve specified keys from scaffolded version
158
+ const merged = { ...incoming }
159
+ for (const key of preserveKeys) {
160
+ if (existing[key] !== undefined) merged[key] = existing[key]
161
+ }
162
+ await fs.writeFile(targetPath, yaml.dump(merged, { lineWidth: -1 }))
163
+ } else if (newContent !== undefined) {
164
+ await fs.writeFile(targetPath, newContent)
141
165
  } else {
142
166
  // Copy as-is
143
167
  await fs.copyFile(sourcePath, targetPath)
@@ -1,7 +1,29 @@
1
+ // Foundation Configuration
2
+ // Schema: schemas/foundation.schema.json
3
+ //
4
+ // ─── CSS Custom Properties ──────────────────────────────────────────────────────
5
+ // Export `vars` to declare CSS custom properties that sites can override in theme.yml:
6
+ //
7
+ // export const vars = {
8
+ // 'header-height': { default: '4rem', description: 'Fixed header height' },
9
+ // 'max-content-width': { default: '80rem', description: 'Max content width' },
10
+ // }
11
+ //
12
+ // Sites override in theme.yml: vars: { header-height: 5rem }
13
+ // Components use: height: var(--header-height)
14
+
1
15
  {{#if isExtension}}
2
16
  export default {
3
17
  extension: true,
4
18
  }
5
19
  {{else}}
6
- export default {}
20
+ export default {
21
+ // ─── Layout ─────────────────────────────────────────────────────────────────
22
+ // Create layouts in src/layouts/MyLayout/index.jsx (auto-discovered).
23
+ // defaultLayout: 'MyLayout',
24
+
25
+ // ─── Props ──────────────────────────────────────────────────────────────────
26
+ // Foundation-wide data accessible via website.foundationProps in components.
27
+ // props: {},
28
+ }
7
29
  {{/if}}
@@ -1,3 +1,6 @@
1
+ # Site Configuration
2
+ # Full reference: uniweb docs site | Schema: schemas/site.schema.json
3
+
1
4
  name: {{projectName}}
2
5
 
3
6
  {{#if foundationRef}}
@@ -6,5 +9,51 @@ foundation: {{foundationRef}}
6
9
  {{/if}}
7
10
  index: home
8
11
 
12
+ # ─── Page Ordering ─────────────────────────────────────────────────────────────
13
+ # Control which pages appear in navigation and in what order.
14
+ # pages: [home, about, ...] # home first, about second, rest auto-discovered
15
+ # pages: [home, ..., contact] # home first, contact last, rest in middle
16
+ # pages: [home, about] # Strict: only these pages in nav (others still built)
17
+ # Or just set the homepage and auto-discover the rest:
18
+ # index: home
19
+
20
+ # ─── Content Paths ─────────────────────────────────────────────────────────────
21
+ # Mount external content directories into your site's page tree.
22
+ # Paths resolve relative to this file.
23
+ #
24
+ # paths:
25
+ # pages/docs: ../../../docs # Mount docs repo at /docs route
26
+ # pages/blog: ../../blog-content # Mount blog content at /blog route
27
+ # layout: ./custom-layout # Custom layout directory
28
+ # collections: ./data # Collections directory
29
+
30
+ # ─── Data Sources ─────────────────────────────────────────────────────────────
31
+ # Define collections (local markdown folders) or fetch remote/local JSON data.
32
+ # Pages and sections reference sources by name via `data: source-name`.
33
+ #
34
+ # collections:
35
+ # articles:
36
+ # path: collections/articles # Folder of .md entity files
37
+ # sort: date desc # Sort by frontmatter field
38
+ #
39
+ # fetch:
40
+ # - url: https://api.example.com/team # Remote JSON
41
+ # schema: team # Access as content.data.team
42
+ # - path: /data/config.json # Local file from public/
43
+
44
+ # ─── Extensions ────────────────────────────────────────────────────────────────
45
+ # Load additional foundations (section types) via URL.
46
+ # extensions:
47
+ # - https://cdn.example.com/effects/foundation.js
48
+
49
+ # ─── Layout ────────────────────────────────────────────────────────────────────
50
+ # Override the foundation's default layout per-site.
51
+ # layout: DocsLayout
52
+ # Or per-page in page.yml: layout: { name: MarketingLayout, hide: [left] }
53
+
54
+ # ─── Base Path ─────────────────────────────────────────────────────────────────
55
+ # For subdirectory deployments (e.g., https://example.com/docs/).
56
+ # base: /docs/
57
+
9
58
  build:
10
59
  prerender: true