uniweb 0.8.2 → 0.8.3

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.3",
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/runtime": "0.6.4",
45
+ "@uniweb/build": "0.8.3",
46
+ "@uniweb/core": "0.5.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,6 +22,7 @@ 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'
27
28
  import { resolveTemplate } from '../templates/index.js'
@@ -157,6 +158,16 @@ export async function add(args) {
157
158
  */
158
159
  async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
159
160
  let name = opts.name
161
+ const existingNames = await getExistingPackageNames(rootDir)
162
+
163
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
164
+ if (name) {
165
+ const valid = validatePackageName(name)
166
+ if (valid !== true) {
167
+ error(valid)
168
+ process.exit(1)
169
+ }
170
+ }
160
171
 
161
172
  // Interactive name prompt when name not provided and no --path
162
173
  if (!name && !opts.path) {
@@ -167,11 +178,7 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
167
178
  name: 'name',
168
179
  message: 'Foundation name:',
169
180
  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
- },
181
+ validate: (value) => validatePackageName(value),
175
182
  }, {
176
183
  onCancel: () => {
177
184
  log('\nCancelled.')
@@ -193,9 +200,15 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
193
200
  process.exit(1)
194
201
  }
195
202
 
203
+ // Compute package name — auto-suffix if it collides with an existing package
204
+ let packageName = name || opts.project || 'foundation'
205
+ if (existingNames.has(packageName)) {
206
+ packageName = resolveUniqueName(packageName, '-foundation', existingNames)
207
+ }
208
+
196
209
  // Scaffold
197
210
  await scaffoldFoundation(fullPath, {
198
- name: name || opts.project || 'foundation',
211
+ name: packageName,
199
212
  projectName,
200
213
  isExtension: false,
201
214
  }, {
@@ -215,7 +228,7 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
215
228
  const sites = await discoverSites(rootDir)
216
229
  await updateRootScripts(rootDir, sites, pm)
217
230
 
218
- success(`Created foundation '${name || 'foundation'}' at ${target}/`)
231
+ success(`Created foundation '${packageName}' at ${target}/`)
219
232
  log('')
220
233
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
221
234
  }
@@ -225,6 +238,16 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
225
238
  */
226
239
  async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
227
240
  let name = opts.name
241
+ const existingNames = await getExistingPackageNames(rootDir)
242
+
243
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
244
+ if (name) {
245
+ const valid = validatePackageName(name)
246
+ if (valid !== true) {
247
+ error(valid)
248
+ process.exit(1)
249
+ }
250
+ }
228
251
 
229
252
  // Interactive name prompt when name not provided and no --path
230
253
  if (!name && !opts.path) {
@@ -235,11 +258,7 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
235
258
  name: 'name',
236
259
  message: 'Site name:',
237
260
  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
- },
261
+ validate: (value) => validatePackageName(value),
243
262
  }, {
244
263
  onCancel: () => {
245
264
  log('\nCancelled.')
@@ -271,6 +290,11 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
271
290
  siteName = name || 'site'
272
291
  }
273
292
 
293
+ // Auto-suffix package name if it collides with an existing package
294
+ if (existingNames.has(siteName)) {
295
+ siteName = resolveUniqueName(siteName, '-site', existingNames)
296
+ }
297
+
274
298
  if (foundation) {
275
299
  // Compute relative path from site to foundation
276
300
  const foundationPath = computeFoundationPath(target, foundation.path)
@@ -334,6 +358,16 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
334
358
  */
335
359
  async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
336
360
  let name = opts.name
361
+ const existingNames = await getExistingPackageNames(rootDir)
362
+
363
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
364
+ if (name) {
365
+ const valid = validatePackageName(name)
366
+ if (valid !== true) {
367
+ error(valid)
368
+ process.exit(1)
369
+ }
370
+ }
337
371
 
338
372
  // Interactive name prompt when name not provided
339
373
  if (!name) {
@@ -341,11 +375,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
341
375
  type: 'text',
342
376
  name: 'name',
343
377
  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
- },
378
+ validate: (value) => validatePackageName(value),
349
379
  }, {
350
380
  onCancel: () => {
351
381
  log('\nCancelled.')
@@ -355,6 +385,11 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
355
385
  name = response.name
356
386
  }
357
387
 
388
+ // Auto-suffix package name if it collides with an existing package
389
+ const extensionPackageName = existingNames.has(name)
390
+ ? resolveUniqueName(name, '-ext', existingNames)
391
+ : name
392
+
358
393
  // Determine target
359
394
  let target
360
395
  if (opts.path) {
@@ -372,7 +407,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
372
407
 
373
408
  // Scaffold foundation with extension flag
374
409
  await scaffoldFoundation(fullPath, {
375
- name,
410
+ name: extensionPackageName,
376
411
  projectName,
377
412
  isExtension: true,
378
413
  }, {
@@ -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}";