uniweb 0.8.2 → 0.8.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,9 +41,9 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/build": "0.8.2",
45
- "@uniweb/kit": "0.7.2",
46
- "@uniweb/runtime": "0.6.3",
47
- "@uniweb/core": "0.5.3"
44
+ "@uniweb/build": "0.8.4",
45
+ "@uniweb/core": "0.5.4",
46
+ "@uniweb/runtime": "0.6.4",
47
+ "@uniweb/kit": "0.7.3"
48
48
  }
49
49
  }
@@ -100,7 +100,7 @@ content = {
100
100
  icons: [], // { library, name, role }
101
101
  videos: [], // Video embeds
102
102
  insets: [], // Inline @Component references — { refId }
103
- lists: [], // Bullet/ordered lists
103
+ lists: [], // [[{ paragraphs, links, lists, ... }]] — each list item is an object, not a string
104
104
  quotes: [], // Blockquotes
105
105
  data: {}, // From tagged code blocks (```yaml:tagname)
106
106
  headings: [], // Overflow headings after subtitle2
@@ -123,6 +123,35 @@ Lightning quick. ← items[0].paragraphs[0]
123
123
  Enterprise-grade. ← items[1].paragraphs[0]
124
124
  ```
125
125
 
126
+ **Lists** contain bullet or ordered list items. Each list item is an object with the same content shape — not a plain string:
127
+
128
+ ```markdown
129
+ # Features ← title
130
+
131
+ - Fast builds ← lists[0][0].paragraphs[0]
132
+ - **Hot** reload ← lists[0][1].paragraphs[0] (HTML: "<strong>Hot</strong> reload")
133
+ ```
134
+
135
+ Items can contain lists:
136
+
137
+ ```markdown
138
+ ### Starter ← items[0].title
139
+ $9/month ← items[0].paragraphs[0]
140
+
141
+ - Feature A ← items[0].lists[0][0].paragraphs[0]
142
+ - Feature B ← items[0].lists[0][1].paragraphs[0]
143
+ ```
144
+
145
+ Render list item text with kit components (see [kit section](#uniwebkit) below):
146
+
147
+ ```jsx
148
+ import { Span } from '@uniweb/kit'
149
+
150
+ content.lists[0]?.map((listItem, i) => (
151
+ <li key={i}><Span text={listItem.paragraphs[0]} /></li>
152
+ ))
153
+ ```
154
+
126
155
  ### Icons
127
156
 
128
157
  Use image syntax with library prefix: `![](lu-house)`. Supported libraries: `lu` (Lucide), `hi2` (Heroicons), `fi` (Feather), `pi` (Phosphor), `tb` (Tabler), `bs` (Bootstrap), `md` (Material), `fa6` (Font Awesome 6), and others. Browse at [react-icons.github.io/react-icons](https://react-icons.github.io/react-icons/).
@@ -270,16 +299,15 @@ nest:
270
299
  - Children are ordered by their position in the `nest:` array
271
300
  - Orphaned `@` files (no parent in `nest:`) appear at top-level with a warning
272
301
 
273
- Components receive children via `block.childBlocks`:
302
+ Components receive children via `block.childBlocks`. Use `ChildBlocks` from kit to render them — the runtime handles component resolution:
274
303
 
275
304
  ```jsx
305
+ import { ChildBlocks } from '@uniweb/kit'
306
+
276
307
  export default function Grid({ block, params }) {
277
308
  return (
278
309
  <div className={`grid grid-cols-${params.columns || 2} gap-6`}>
279
- {block.childBlocks.map(child => {
280
- const Comp = child.initComponent()
281
- return Comp ? <Comp key={child.id} block={child} /> : null
282
- })}
310
+ <ChildBlocks from={block} />
283
311
  </div>
284
312
  )
285
313
  }
@@ -520,9 +548,45 @@ All defaults belong in `meta.js`, not inline in component code.
520
548
 
521
549
  ### @uniweb/kit
522
550
 
523
- **Primitives** (`@uniweb/kit`): `H1`–`H6`, `P`, `Span`, `Text`, `Link`, `Image`, `Icon`, `Media`, `Asset`, `SocialIcon`, `FileLogo`, `cn()`
551
+ Content fields (`title`, `pretitle`, `paragraphs[]`, list item text) are **HTML strings** they contain markup like `<strong>`, `<em>`, `<a>` from the author's markdown. The kit provides components to render them correctly.
552
+
553
+ **Rendering text** (`@uniweb/kit`):
554
+
555
+ ```jsx
556
+ import { H2, P, Span } from '@uniweb/kit'
557
+
558
+ <H2 text={content.title} className="text-heading text-3xl font-bold" />
559
+ <P text={content.paragraphs[0]} className="text-body" />
560
+ <P text={content.paragraphs} /> // array → each string becomes its own <p>
561
+ <Span text={listItem.paragraphs[0]} className="text-subtle" />
562
+ ```
563
+
564
+ `H1`–`H6`, `P`, `Span`, `Div` are all wrappers around `Text` with a preset tag:
565
+
566
+ ```jsx
567
+ <Text text={content.title} as="h2" className="..." /> // explicit tag
568
+ ```
569
+
570
+ Don't render content strings with `{content.paragraphs[0]}` in JSX — that shows HTML tags as visible text. Use `<P>`, `<H2>`, `<Span>`, etc. for content strings.
571
+
572
+ **Rendering full content** (`@uniweb/kit`):
524
573
 
525
- **Styled** (`@uniweb/kit/styled`): `Section`, `Render`, `Visual`, `SidebarLayout`, `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
574
+ ```jsx
575
+ import { Section, Render } from '@uniweb/kit'
576
+
577
+ <Render content={block.parsedContent} block={block} /> // ProseMirror nodes → React
578
+ <Section block={block} width="lg" padding="md" /> // Render + prose styling + layout
579
+ ```
580
+
581
+ `Render` processes ProseMirror nodes into React elements — paragraphs, headings, images, code blocks, lists, tables, alerts, and insets. `Section` wraps `Render` with prose typography and layout options. Use these when rendering a block's complete content. Use `P`, `H2`, etc. when you extract specific fields and arrange them with custom layout.
582
+
583
+ **Rendering visuals** (`@uniweb/kit`):
584
+
585
+ `<Visual>` renders the first non-empty candidate from the props you pass (inset, video, image). See Insets section below.
586
+
587
+ **Other primitives** (`@uniweb/kit`): `Link`, `Image`, `Icon`, `Media`, `Asset`, `SafeHtml`, `SocialIcon`, `FileLogo`, `cn()`
588
+
589
+ **Other styled** (`@uniweb/kit`): `SidebarLayout`, `Prose`, `Article`, `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
526
590
 
527
591
  **Hooks:**
528
592
  - `useScrolled(threshold)` → boolean for scroll-based header styling
@@ -575,25 +639,26 @@ website.getLocaleUrl('es')
575
639
  Components access inline `@` references via `block.insets` (separate from `block.childBlocks`):
576
640
 
577
641
  ```jsx
578
- import { Visual } from '@uniweb/kit/styled'
642
+ import { Visual } from '@uniweb/kit'
579
643
 
580
- // Visual renders the first visual: inset > video > image
581
- function SplitContent({ content, block, params }) {
644
+ // Visual renders the first non-empty candidate: inset > video > image
645
+ function SplitContent({ content, block }) {
582
646
  return (
583
647
  <div className="flex gap-12">
584
648
  <div className="flex-1">
585
649
  <h2 className="text-heading">{content.title}</h2>
586
650
  </div>
587
- <Visual content={content} block={block} className="flex-1 rounded-lg" />
651
+ <Visual inset={block.insets[0]} video={content.videos[0]} image={content.imgs[0]} className="flex-1 rounded-lg" />
588
652
  </div>
589
653
  )
590
654
  }
591
655
  ```
592
656
 
657
+ - `<Visual>` — renders first non-empty candidate from the props you pass (`inset`, `video`, `image`)
658
+ - `<Render>` / `<Section>` — automatically handles `@Component` references placed in content flow
593
659
  - `block.insets` — array of Block instances from `@` references
594
660
  - `block.getInset(refId)` — lookup by refId (used by sequential renderers)
595
661
  - `content.insets` — flat array of `{ refId }` entries (parallel to `content.imgs`)
596
- - `<Visual>` — renders first inset > video > image from content (from `@uniweb/kit/styled`)
597
662
 
598
663
  Inset components declare `inset: true` in meta.js. Use `hidden: true` for inset-only components:
599
664
 
@@ -624,7 +689,7 @@ function SplitContent({ content, block, params }) {
624
689
  <h2 className="text-heading text-3xl font-bold">{content.title}</h2>
625
690
  <p className="text-body mt-4">{content.paragraphs[0]}</p>
626
691
  </div>
627
- <Visual content={content} block={block} className="flex-1 rounded-2xl" />
692
+ <Visual inset={block.insets[0]} video={content.videos[0]} image={content.imgs[0]} className="flex-1 rounded-2xl" />
628
693
  </div>
629
694
  )
630
695
  }
@@ -731,7 +796,7 @@ Uniweb section types do more with less because the framework handles concerns th
731
796
  - **Dispatcher pattern** — one section type with a `variant` param replaces multiple near-duplicate components (`HeroHomepage` + `HeroPricing` → `Hero` with `variant: homepage | pricing`)
732
797
  - **Section nesting** — `@`-prefixed child files replace wrapper components that exist only to arrange children
733
798
  - **Insets** — `![](@ComponentName)` replaces prop-drilling of visual components into containers
734
- - **Visual component** — `<Visual>` renders image/video/inset from content, replacing manual media handling
799
+ - **Visual component** — `<Visual>` renders the first non-empty visual from explicit candidates (inset, video, image), replacing manual media handling
735
800
  - **Semantic theming** — the runtime orchestrates context classes and token resolution, replacing per-component dark mode logic
736
801
  - **Engine backgrounds** — the runtime renders section backgrounds from frontmatter, replacing background-handling code in every section
737
802
  - **Rich params** — `meta.js` params with types, defaults, and presets replace config objects and conditional logic
@@ -14,7 +14,7 @@ pnpm add fuse.js
14
14
  Then use the search helpers from `@uniweb/kit`:
15
15
 
16
16
  ```jsx
17
- import { useSearch } from '@uniweb/kit/search'
17
+ import { useSearch } from '@uniweb/kit'
18
18
 
19
19
  function SearchComponent({ website }) {
20
20
  const { query, results, isLoading, clear } = useSearch(website)
@@ -22,8 +22,10 @@ import {
22
22
  discoverSites,
23
23
  updateRootScripts,
24
24
  } from '../utils/config.js'
25
+ import { validatePackageName, getExistingPackageNames, resolveUniqueName } from '../utils/names.js'
25
26
  import { findWorkspaceRoot } from '../utils/workspace.js'
26
27
  import { detectPackageManager, filterCmd, installCmd } from '../utils/pm.js'
28
+ import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from '../utils/interactive.js'
27
29
  import { resolveTemplate } from '../templates/index.js'
28
30
  import { validateTemplate } from '../templates/validator.js'
29
31
  import { getVersionsForTemplates } from '../versions.js'
@@ -90,13 +92,17 @@ function parseArgs(args) {
90
92
  /**
91
93
  * Main add command handler
92
94
  */
93
- export async function add(args) {
95
+ export async function add(rawArgs) {
96
+ const nonInteractive = isNonInteractive(rawArgs)
97
+ const args = stripNonInteractiveFlag(rawArgs)
98
+
94
99
  if (args[0] === '--help' || args[0] === '-h') {
95
100
  showAddHelp()
96
101
  return
97
102
  }
98
103
 
99
104
  const pm = detectPackageManager()
105
+ const prefix = getCliPrefix()
100
106
 
101
107
  // Find workspace root
102
108
  const rootDir = findWorkspaceRoot()
@@ -109,6 +115,18 @@ export async function add(args) {
109
115
  // Interactive subcommand chooser when no args given
110
116
  let parsed
111
117
  if (!args.length || (args[0] && args[0].startsWith('--'))) {
118
+ if (nonInteractive) {
119
+ error(`Missing subcommand.\n`)
120
+ log(formatOptions([
121
+ { label: 'foundation', description: 'Component library' },
122
+ { label: 'site', description: 'Content site' },
123
+ { label: 'extension', description: 'Additional component package' },
124
+ ]))
125
+ log('')
126
+ log(`Usage: ${prefix} add <foundation|site|extension> [name]`)
127
+ process.exit(1)
128
+ }
129
+
112
130
  const response = await prompts({
113
131
  type: 'select',
114
132
  name: 'subcommand',
@@ -157,9 +175,25 @@ export async function add(args) {
157
175
  */
158
176
  async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
159
177
  let name = opts.name
178
+ const existingNames = await getExistingPackageNames(rootDir)
179
+
180
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
181
+ if (name) {
182
+ const valid = validatePackageName(name)
183
+ if (valid !== true) {
184
+ error(valid)
185
+ process.exit(1)
186
+ }
187
+ }
160
188
 
161
189
  // Interactive name prompt when name not provided and no --path
162
190
  if (!name && !opts.path) {
191
+ if (isNonInteractive(process.argv)) {
192
+ error(`Missing foundation name.\n`)
193
+ log(`Usage: ${getCliPrefix()} add foundation <name>`)
194
+ process.exit(1)
195
+ }
196
+
163
197
  const foundations = await discoverFoundations(rootDir)
164
198
  const hasDefault = foundations.length === 0 && !existsSync(join(rootDir, 'foundation'))
165
199
  const response = await prompts({
@@ -167,11 +201,7 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
167
201
  name: 'name',
168
202
  message: 'Foundation name:',
169
203
  initial: hasDefault ? 'foundation' : undefined,
170
- validate: (value) => {
171
- if (!value) return 'Name is required'
172
- if (!/^[a-z0-9-]+$/.test(value)) return 'Use lowercase letters, numbers, and hyphens'
173
- return true
174
- },
204
+ validate: (value) => validatePackageName(value),
175
205
  }, {
176
206
  onCancel: () => {
177
207
  log('\nCancelled.')
@@ -193,9 +223,15 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
193
223
  process.exit(1)
194
224
  }
195
225
 
226
+ // Compute package name — auto-suffix if it collides with an existing package
227
+ let packageName = name || opts.project || 'foundation'
228
+ if (existingNames.has(packageName)) {
229
+ packageName = resolveUniqueName(packageName, '-foundation', existingNames)
230
+ }
231
+
196
232
  // Scaffold
197
233
  await scaffoldFoundation(fullPath, {
198
- name: name || opts.project || 'foundation',
234
+ name: packageName,
199
235
  projectName,
200
236
  isExtension: false,
201
237
  }, {
@@ -215,7 +251,7 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
215
251
  const sites = await discoverSites(rootDir)
216
252
  await updateRootScripts(rootDir, sites, pm)
217
253
 
218
- success(`Created foundation '${name || 'foundation'}' at ${target}/`)
254
+ success(`Created foundation '${packageName}' at ${target}/`)
219
255
  log('')
220
256
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
221
257
  }
@@ -225,9 +261,25 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
225
261
  */
226
262
  async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
227
263
  let name = opts.name
264
+ const existingNames = await getExistingPackageNames(rootDir)
265
+
266
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
267
+ if (name) {
268
+ const valid = validatePackageName(name)
269
+ if (valid !== true) {
270
+ error(valid)
271
+ process.exit(1)
272
+ }
273
+ }
228
274
 
229
275
  // Interactive name prompt when name not provided and no --path
230
276
  if (!name && !opts.path) {
277
+ if (isNonInteractive(process.argv)) {
278
+ error(`Missing site name.\n`)
279
+ log(`Usage: ${getCliPrefix()} add site <name>`)
280
+ process.exit(1)
281
+ }
282
+
231
283
  const existingSites = await discoverSites(rootDir)
232
284
  const hasDefault = existingSites.length === 0 && !existsSync(join(rootDir, 'site'))
233
285
  const response = await prompts({
@@ -235,11 +287,7 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
235
287
  name: 'name',
236
288
  message: 'Site name:',
237
289
  initial: hasDefault ? 'site' : undefined,
238
- validate: (value) => {
239
- if (!value) return 'Name is required'
240
- if (!/^[a-z0-9-]+$/.test(value)) return 'Use lowercase letters, numbers, and hyphens'
241
- return true
242
- },
290
+ validate: (value) => validatePackageName(value),
243
291
  }, {
244
292
  onCancel: () => {
245
293
  log('\nCancelled.')
@@ -271,6 +319,11 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
271
319
  siteName = name || 'site'
272
320
  }
273
321
 
322
+ // Auto-suffix package name if it collides with an existing package
323
+ if (existingNames.has(siteName)) {
324
+ siteName = resolveUniqueName(siteName, '-site', existingNames)
325
+ }
326
+
274
327
  if (foundation) {
275
328
  // Compute relative path from site to foundation
276
329
  const foundationPath = computeFoundationPath(target, foundation.path)
@@ -334,18 +387,30 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
334
387
  */
335
388
  async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
336
389
  let name = opts.name
390
+ const existingNames = await getExistingPackageNames(rootDir)
391
+
392
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
393
+ if (name) {
394
+ const valid = validatePackageName(name)
395
+ if (valid !== true) {
396
+ error(valid)
397
+ process.exit(1)
398
+ }
399
+ }
337
400
 
338
401
  // Interactive name prompt when name not provided
339
402
  if (!name) {
403
+ if (isNonInteractive(process.argv)) {
404
+ error(`Missing extension name.\n`)
405
+ log(`Usage: ${getCliPrefix()} add extension <name>`)
406
+ process.exit(1)
407
+ }
408
+
340
409
  const response = await prompts({
341
410
  type: 'text',
342
411
  name: 'name',
343
412
  message: 'Extension name:',
344
- validate: (value) => {
345
- if (!value) return 'Name is required'
346
- if (!/^[a-z0-9-]+$/.test(value)) return 'Use lowercase letters, numbers, and hyphens'
347
- return true
348
- },
413
+ validate: (value) => validatePackageName(value),
349
414
  }, {
350
415
  onCancel: () => {
351
416
  log('\nCancelled.')
@@ -355,6 +420,11 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
355
420
  name = response.name
356
421
  }
357
422
 
423
+ // Auto-suffix package name if it collides with an existing package
424
+ const extensionPackageName = existingNames.has(name)
425
+ ? resolveUniqueName(name, '-ext', existingNames)
426
+ : name
427
+
358
428
  // Determine target
359
429
  let target
360
430
  if (opts.path) {
@@ -372,7 +442,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
372
442
 
373
443
  // Scaffold foundation with extension flag
374
444
  await scaffoldFoundation(fullPath, {
375
- name,
445
+ name: extensionPackageName,
376
446
  projectName,
377
447
  isExtension: true,
378
448
  }, {
@@ -494,7 +564,18 @@ async function resolveFoundation(rootDir, foundationFlag) {
494
564
  return foundations[0]
495
565
  }
496
566
 
497
- // Multiple foundations — prompt
567
+ // Multiple foundations — prompt (or fail in non-interactive mode)
568
+ if (isNonInteractive(process.argv)) {
569
+ error(`Multiple foundations found. Specify which to use:\n`)
570
+ log(formatOptions(foundations.map(f => ({
571
+ label: f.name,
572
+ description: f.path,
573
+ }))))
574
+ log('')
575
+ log(`Usage: ${getCliPrefix()} add site <name> --foundation <name>`)
576
+ process.exit(1)
577
+ }
578
+
498
579
  const response = await prompts({
499
580
  type: 'select',
500
581
  name: 'foundation',
@@ -29,6 +29,7 @@ import {
29
29
  findFoundations,
30
30
  promptSelect,
31
31
  } from '../utils/workspace.js'
32
+ import { isNonInteractive, getCliPrefix, formatOptions } from '../utils/interactive.js'
32
33
 
33
34
  // Colors for terminal output
34
35
  const colors = {
@@ -394,6 +395,12 @@ async function generateComponentDocs(args) {
394
395
  if (foundations.length === 1) {
395
396
  targetFoundation = foundations[0]
396
397
  info(`Found foundation: ${targetFoundation}`)
398
+ } else if (isNonInteractive(process.argv)) {
399
+ error(`Multiple foundations found. Specify which to target:\n`)
400
+ log(formatOptions(foundations.map(f => ({ label: f, description: '' }))))
401
+ log('')
402
+ log(`Usage: ${getCliPrefix()} docs --target <path>`)
403
+ process.exit(1)
397
404
  } else {
398
405
  log(`${colors.dim}Multiple foundations found in workspace.${colors.reset}\n`)
399
406
  targetFoundation = await promptSelect('Select foundation:', foundations)
package/src/index.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  import { validateTemplate } from './templates/validator.js'
30
30
  import { scaffoldWorkspace, scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from './utils/scaffold.js'
31
31
  import { detectPackageManager, filterCmd, installCmd, runCmd } from './utils/pm.js'
32
+ import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from './utils/interactive.js'
32
33
 
33
34
  // Colors for terminal output
34
35
  const colors = {
@@ -288,7 +289,9 @@ function computeFoundationFilePath(sitePath, foundationPath) {
288
289
  }
289
290
 
290
291
  async function main() {
291
- const args = process.argv.slice(2)
292
+ const rawArgs = process.argv.slice(2)
293
+ const nonInteractive = isNonInteractive(rawArgs)
294
+ const args = stripNonInteractiveFlag(rawArgs)
292
295
  const command = args[0]
293
296
  const pm = detectPackageManager()
294
297
 
@@ -369,6 +372,26 @@ async function main() {
369
372
  projectName = null
370
373
  }
371
374
 
375
+ const prefix = getCliPrefix()
376
+
377
+ // Non-interactive: fail with actionable message instead of prompting
378
+ if (nonInteractive && !projectName) {
379
+ error(`Missing project name.\n`)
380
+ log(`Usage: ${prefix} create <project-name> [--template <name>]`)
381
+ process.exit(1)
382
+ }
383
+
384
+ if (nonInteractive && !templateType) {
385
+ error(`Missing --template flag. Available templates:\n`)
386
+ log(formatOptions(TEMPLATE_CHOICES.map(c => ({
387
+ label: c.value,
388
+ description: c.description,
389
+ }))))
390
+ log('')
391
+ log(`Usage: ${prefix} create ${projectName || '<project-name>'} --template <name>`)
392
+ process.exit(1)
393
+ }
394
+
372
395
  // Interactive prompts
373
396
  const response = await prompts([
374
397
  {
@@ -539,6 +562,10 @@ ${colors.bright}Add Subcommands:${colors.reset}
539
562
  add site [name] Add a site (--from, --foundation, --path, --project)
540
563
  add extension <name> Add an extension (--from, --site, --path)
541
564
 
565
+ ${colors.bright}Global Options:${colors.reset}
566
+ --non-interactive Fail with usage info instead of prompting
567
+ Auto-detected when CI=true or no TTY (pipes, agents)
568
+
542
569
  ${colors.bright}Build Options:${colors.reset}
543
570
  --target <type> Build target (foundation, site) - auto-detected if not specified
544
571
  --prerender Force pre-rendering (overrides site.yml)
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Non-Interactive Mode Detection
3
+ *
4
+ * Detects when the CLI is running without a TTY (AI agents, CI, piped input)
5
+ * and provides helpers for printing actionable error messages.
6
+ */
7
+
8
+ /**
9
+ * Detect if the CLI is running in non-interactive mode.
10
+ * True when --non-interactive flag, CI env var, or no TTY.
11
+ * @param {string[]} args - Command line arguments
12
+ * @returns {boolean}
13
+ */
14
+ export function isNonInteractive(args) {
15
+ if (args.includes('--non-interactive')) return true
16
+ if (process.env.CI) return true
17
+ if (!process.stdin.isTTY) return true
18
+ return false
19
+ }
20
+
21
+ /**
22
+ * Get the CLI invocation prefix to use in suggested commands.
23
+ * Mirrors however the user actually ran the CLI.
24
+ * @returns {string}
25
+ */
26
+ export function getCliPrefix() {
27
+ const ua = process.env.npm_config_user_agent || ''
28
+ if (ua.startsWith('pnpm/')) return 'pnpm uniweb'
29
+ if (ua.startsWith('npm/')) return 'npx uniweb'
30
+ return 'uniweb'
31
+ }
32
+
33
+ /**
34
+ * Strip --non-interactive from an args array so it doesn't interfere
35
+ * with positional argument parsing.
36
+ * @param {string[]} args
37
+ * @returns {string[]}
38
+ */
39
+ export function stripNonInteractiveFlag(args) {
40
+ return args.filter(a => a !== '--non-interactive')
41
+ }
42
+
43
+ /**
44
+ * Format a list of options with aligned descriptions for terminal output.
45
+ * @param {{ label: string, description: string }[]} options
46
+ * @returns {string}
47
+ */
48
+ export function formatOptions(options) {
49
+ const maxLen = Math.max(...options.map(o => o.label.length))
50
+ return options
51
+ .map(o => ` ${o.label.padEnd(maxLen + 3)}${o.description}`)
52
+ .join('\n')
53
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Package Name Validation
3
+ *
4
+ * Validates package names for the `add` command — rejects reserved names
5
+ * and detects collisions with existing workspace packages.
6
+ */
7
+
8
+ import { discoverFoundations, discoverSites, readWorkspaceConfig, resolveGlob } from './config.js'
9
+ import { existsSync } from 'node:fs'
10
+ import { readFile } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+
13
+ /**
14
+ * Names that must not be used as package names.
15
+ * - JS module keywords: default, undefined, null, true, false
16
+ * - Node/filesystem: node_modules, package
17
+ * - Common directory names that would cause confusion: src, dist, build
18
+ */
19
+ const RESERVED_NAMES = new Set([
20
+ 'default', 'undefined', 'null', 'true', 'false',
21
+ 'node_modules', 'package',
22
+ 'src', 'dist', 'build',
23
+ ])
24
+
25
+ /**
26
+ * Validate a package name.
27
+ * @param {string} name
28
+ * @param {Set<string>} [existingNames] - Names already in the workspace
29
+ * @returns {string|true} true if valid, or an error message string
30
+ */
31
+ export function validatePackageName(name, existingNames) {
32
+ if (!name) return 'Name is required'
33
+ if (!/^[a-z0-9-]+$/.test(name)) return 'Use lowercase letters, numbers, and hyphens'
34
+ if (RESERVED_NAMES.has(name)) return `"${name}" is a reserved name — choose a different one`
35
+ if (existingNames?.has(name)) return `"${name}" already exists in this workspace`
36
+ return true
37
+ }
38
+
39
+ /**
40
+ * Discover all package names in the workspace (foundations + sites + extensions).
41
+ * @param {string} rootDir - Workspace root directory
42
+ * @returns {Promise<Set<string>>}
43
+ */
44
+ export async function getExistingPackageNames(rootDir) {
45
+ const names = new Set()
46
+
47
+ // Foundations and sites via existing discovery
48
+ const foundations = await discoverFoundations(rootDir)
49
+ const sites = await discoverSites(rootDir)
50
+
51
+ for (const f of foundations) names.add(f.name)
52
+ for (const s of sites) names.add(s.name)
53
+
54
+ // Extensions (foundations with @uniweb/runtime absent — already captured above)
55
+ // Also scan extensions/* if it exists
56
+ const extensionsDir = join(rootDir, 'extensions')
57
+ if (existsSync(extensionsDir)) {
58
+ const dirs = await resolveGlob(rootDir, 'extensions/*')
59
+ for (const dir of dirs) {
60
+ const pkgPath = join(rootDir, dir, 'package.json')
61
+ if (!existsSync(pkgPath)) continue
62
+ try {
63
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
64
+ if (pkg.name) names.add(pkg.name)
65
+ } catch {
66
+ // skip
67
+ }
68
+ }
69
+ }
70
+
71
+ return names
72
+ }
73
+
74
+ /**
75
+ * Resolve a unique name by appending a suffix if there's a collision.
76
+ * @param {string} name - Proposed name
77
+ * @param {string} suffix - Suffix to append (e.g., '-site', '-foundation')
78
+ * @param {Set<string>} existingNames
79
+ * @returns {string} The resolved unique name
80
+ */
81
+ export function resolveUniqueName(name, suffix, existingNames) {
82
+ if (!existingNames.has(name)) return name
83
+ const suffixed = `${name}${suffix}`
84
+ if (!existingNames.has(suffixed)) return suffixed
85
+ // Unlikely: both name and name-suffix taken — append number
86
+ for (let i = 2; i < 100; i++) {
87
+ const numbered = `${name}${suffix}-${i}`
88
+ if (!existingNames.has(numbered)) return numbered
89
+ }
90
+ return suffixed // give up
91
+ }
@@ -2,4 +2,5 @@
2
2
  @import "@uniweb/kit/theme-tokens.css";
3
3
 
4
4
  @source "./sections/**/*.{js,jsx}";
5
+ @source "./layouts/**/*.{js,jsx}";
5
6
  @source "../node_modules/@uniweb/kit/src/**/*.{js,jsx}";
@@ -1 +1,99 @@
1
- primary: '#3b82f6'
1
+ # Theme Configuration
2
+ # Controls colors, typography, and visual style for the site.
3
+ # Only set what you want to change — everything has sensible defaults.
4
+
5
+ # ─── Colors ────────────────────────────────────────────────────────────────────
6
+ # Palette colors generate shades (50–950) used by semantic tokens.
7
+ # Values: any CSS color (hex, rgb, hsl, oklch).
8
+
9
+ colors:
10
+ primary: '#3b82f6' # Brand color — buttons, links, focus rings
11
+ # secondary: '#64748b' # Secondary actions
12
+ # accent: '#8b5cf6' # Highlights, decorative elements
13
+ # neutral: stone # Gray family — stone, zinc, gray, slate, neutral
14
+
15
+ # ─── Fonts ─────────────────────────────────────────────────────────────────────
16
+ # Font families for text, headings, and code blocks.
17
+ # Default: system-ui stack. Uncomment to use custom fonts.
18
+
19
+ # fonts:
20
+ # heading: '"Inter", system-ui, sans-serif'
21
+ # body: '"Inter", system-ui, sans-serif'
22
+ # mono: '"JetBrains Mono", ui-monospace, monospace'
23
+ # import:
24
+ # - url: https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap
25
+ # - url: https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap
26
+
27
+ # ─── Page Background ──────────────────────────────────────────────────────────
28
+ # Background applied to the page body. Any CSS background value.
29
+
30
+ # background: '#fafaf9'
31
+ # background: 'linear-gradient(to bottom, #fafaf9, white)'
32
+
33
+ # ─── Appearance ────────────────────────────────────────────────────────────────
34
+ # Color scheme support. Simple value: light, dark, or system.
35
+ # Or use the expanded form for more control.
36
+
37
+ # appearance: light
38
+ # appearance:
39
+ # default: light
40
+ # schemes: [light, dark]
41
+ # allowToggle: true
42
+ # respectSystemPreference: true
43
+
44
+ # ─── Context Overrides ─────────────────────────────────────────────────────────
45
+ # Sections declare a context (light, medium, dark) in frontmatter.
46
+ # Each context maps semantic tokens to palette values.
47
+ # Override individual tokens here — unlisted tokens keep their defaults.
48
+ #
49
+ # Available tokens:
50
+ # Surfaces: section, card, muted
51
+ # Text: body, heading, subtle
52
+ # Border: border, ring
53
+ # Links: link, link-hover
54
+ # Actions: primary, primary-foreground, primary-hover
55
+ # secondary, secondary-foreground, secondary-hover
56
+ # Status: success, warning, error, info (+ -subtle variants)
57
+
58
+ # contexts:
59
+ # light:
60
+ # section: white
61
+ # medium:
62
+ # section: 'var(--neutral-100)'
63
+ # dark:
64
+ # heading: white
65
+
66
+ # ─── Inline Text Styles ───────────────────────────────────────────────────────
67
+ # Named styles for inline text in markdown: [text]{emphasis}
68
+ # Each name maps to CSS properties.
69
+ # Defaults: emphasis (colored + bold), muted (subtle).
70
+
71
+ # inline:
72
+ # emphasis:
73
+ # color: 'var(--link)'
74
+ # font-weight: '600'
75
+ # muted:
76
+ # color: 'var(--subtle)'
77
+
78
+ # ─── Code Blocks ───────────────────────────────────────────────────────────────
79
+ # Syntax highlighting colors for code blocks (uses Shiki).
80
+
81
+ # code:
82
+ # background: '#1e1e2e'
83
+ # foreground: '#cdd6f4'
84
+ # keyword: '#cba6f7'
85
+ # string: '#a6e3a1'
86
+ # number: '#fab387'
87
+ # comment: '#6c7086'
88
+ # function: '#89b4fa'
89
+ # variable: '#f5e0dc'
90
+ # type: '#f9e2af'
91
+ # property: '#94e2d5'
92
+ # tag: '#89b4fa'
93
+
94
+ # ─── Foundation Variables ──────────────────────────────────────────────────────
95
+ # Override variables declared by the foundation (e.g., header-height, max-width).
96
+ # Available variables depend on the foundation — check its foundation.js.
97
+
98
+ # vars:
99
+ # header-height: 5rem