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 +5 -5
- package/partials/agents.md +80 -15
- package/partials/search-docs.hbs +1 -1
- package/src/commands/add.js +101 -20
- package/src/commands/docs.js +7 -0
- package/src/index.js +28 -1
- package/src/utils/interactive.js +53 -0
- package/src/utils/names.js +91 -0
- package/templates/foundation/src/styles.css +1 -0
- package/templates/site/theme.yml +99 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/runtime": "0.6.
|
|
47
|
-
"@uniweb/
|
|
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
|
}
|
package/partials/agents.md
CHANGED
|
@@ -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: [], //
|
|
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: ``. 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
642
|
+
import { Visual } from '@uniweb/kit'
|
|
579
643
|
|
|
580
|
-
// Visual renders the first
|
|
581
|
-
function SplitContent({ content, block
|
|
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
|
|
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
|
|
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** — `` replaces prop-drilling of visual components into containers
|
|
734
|
-
- **Visual component** — `<Visual>` renders
|
|
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
|
package/partials/search-docs.hbs
CHANGED
|
@@ -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
|
|
17
|
+
import { useSearch } from '@uniweb/kit'
|
|
18
18
|
|
|
19
19
|
function SearchComponent({ website }) {
|
|
20
20
|
const { query, results, isLoading, clear } = useSearch(website)
|
package/src/commands/add.js
CHANGED
|
@@ -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(
|
|
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:
|
|
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 '${
|
|
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',
|
package/src/commands/docs.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/templates/site/theme.yml
CHANGED
|
@@ -1 +1,99 @@
|
|
|
1
|
-
|
|
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
|