uniweb 0.14.14 → 0.14.16
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 +55 -0
- package/src/commands/doctor.js +103 -0
- package/src/framework-index.json +19 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.16",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/runtime": "0.9.
|
|
44
|
+
"@uniweb/core": "0.8.2",
|
|
45
|
+
"@uniweb/kit": "0.10.14",
|
|
46
|
+
"@uniweb/runtime": "0.9.4"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/build": "0.16.
|
|
49
|
+
"@uniweb/build": "0.16.10",
|
|
50
50
|
"@uniweb/content-reader": "1.2.2",
|
|
51
51
|
"@uniweb/semantic-parser": "1.2.1"
|
|
52
52
|
},
|
package/partials/agents.md
CHANGED
|
@@ -883,6 +883,7 @@ Nothing to install — the import brings the plugin with it. **Skip the import a
|
|
|
883
883
|
**Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`
|
|
884
884
|
**Theming data:** `useThemeData()`, `useColorContext(block)`
|
|
885
885
|
**Data fetching:** `useFetched`, `useCacheEntry`, `useEntityDetail`
|
|
886
|
+
**Forms:** `useFormSubmit`, `submitForm`, `resolveSubmitTarget` — see *Forms* below
|
|
886
887
|
**Utilities:** `cn()`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`, `getLocaleLabel(locale)`
|
|
887
888
|
**Other styled:** `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
|
|
888
889
|
|
|
@@ -1741,6 +1742,60 @@ const { results, isLoading, query } = useSearch(website) // `query` is a funct
|
|
|
1741
1742
|
|
|
1742
1743
|
Full reference: `authoring/search.md`.
|
|
1743
1744
|
|
|
1745
|
+
### Forms (`submit:`)
|
|
1746
|
+
|
|
1747
|
+
Drawing a form is a foundation's job; delivering what a visitor typed needs a
|
|
1748
|
+
server. Where that server is comes from the **site or its host** — never from a
|
|
1749
|
+
section type, same arrangement as `fetcher:` and `search:`.
|
|
1750
|
+
|
|
1751
|
+
A form gets its destination from the first of these that applies:
|
|
1752
|
+
|
|
1753
|
+
1. **`submit:` in `site.yml`** — an endpoint you name yourself.
|
|
1754
|
+
2. **One the host supplies.** A site published to Uniweb Cloud gets submission
|
|
1755
|
+
handling from the platform, so it normally needs **no `submit:` at all**.
|
|
1756
|
+
3. **Neither** — there is no destination, and the form says so instead of
|
|
1757
|
+
guessing at one.
|
|
1758
|
+
|
|
1759
|
+
```yaml
|
|
1760
|
+
# site.yml — only when YOU are providing the endpoint. Publishing to Uniweb
|
|
1761
|
+
# Cloud needs nothing here; `uniweb export` and most `deploy --host` targets do.
|
|
1762
|
+
submit: /forms # base-relative, resolved like search.endpoint
|
|
1763
|
+
submit: https://forms.example.com/intake # or another origin
|
|
1764
|
+
```
|
|
1765
|
+
|
|
1766
|
+
```jsx
|
|
1767
|
+
import { useFormSubmit } from '@uniweb/kit'
|
|
1768
|
+
|
|
1769
|
+
const { submit, status, error, canSubmit, unavailableReason } = useFormSubmit({
|
|
1770
|
+
block, // supplies section + page context
|
|
1771
|
+
context: { formId: 'contact' },
|
|
1772
|
+
summary: (v) => ({ title: v.name, subtitle: v.email }),
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
<button type="submit" disabled={!canSubmit || status === 'submitting'}>Send</button>
|
|
1776
|
+
{!canSubmit && <p role="status">{unavailableReason}</p>}
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
> **The framework never invents an endpoint — but a host may supply one.** Don't
|
|
1780
|
+
> reach for `submit:` reflexively: on Uniweb Cloud it is redundant, and setting
|
|
1781
|
+
> it there overrides what the platform provides. Reach for it when you are the
|
|
1782
|
+
> one hosting.
|
|
1783
|
+
>
|
|
1784
|
+
> `canSubmit` is false only when neither a declaration nor a host supplies a
|
|
1785
|
+
> destination. **Check it when you render, not only on the button press**, so the
|
|
1786
|
+
> control is visibly disabled before anyone fills the form in, and show
|
|
1787
|
+
> `unavailableReason` — it explains which case you are in. A read that 404s
|
|
1788
|
+
> degrades to `[]` and the page still renders; a write that 404s loses what a
|
|
1789
|
+
> person typed, so it gets no silent fallback.
|
|
1790
|
+
|
|
1791
|
+
`status` runs `idle → submitting → success | error`; a non-2xx becomes a thrown
|
|
1792
|
+
`Error` carrying the endpoint's own `error` message when it sends one. For file
|
|
1793
|
+
fields, declare `fileSlots` and the endpoint replies with `uploadUrls` — the
|
|
1794
|
+
bytes never ride inside the JSON. `submitForm()` / `resolveSubmitTarget(website)`
|
|
1795
|
+
are the same behaviour without React.
|
|
1796
|
+
|
|
1797
|
+
Full reference: `development/receiving-form-submissions.md`.
|
|
1798
|
+
|
|
1744
1799
|
<!-- template:loom -->
|
|
1745
1800
|
### Content handlers
|
|
1746
1801
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -204,6 +204,106 @@ function sourceMatches(dir, pattern) {
|
|
|
204
204
|
return roots.some((r) => walk(join(dir, r)))
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// A fenced data block tagged `form`, in any of the serialization formats the
|
|
208
|
+
// parser accepts for one. Matched on the info string only — the body is the
|
|
209
|
+
// author's business.
|
|
210
|
+
//
|
|
211
|
+
// No whitespace is allowed around the colon, because the parser allows none:
|
|
212
|
+
// `processCodeInfo` in @uniweb/content-reader does a bare `info.split(':')` and
|
|
213
|
+
// trims neither half, so ```` ```yaml: form ```` yields the tag `" form"` and
|
|
214
|
+
// lands at `content.data[" form"]`. Matching it here would warn about a block
|
|
215
|
+
// that is not a form.
|
|
216
|
+
const FORM_BLOCK = /^\s*`{3,}\s*(?:yaml|yml|json):form\b/m
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Find the content files declaring a form, so we can tell whether having no
|
|
220
|
+
* submission destination matters for this site.
|
|
221
|
+
*
|
|
222
|
+
* Scans the page tree and layout only. A form is authored where it renders, and
|
|
223
|
+
* widening this to collections would trade a slower check for cases that do not
|
|
224
|
+
* occur.
|
|
225
|
+
*/
|
|
226
|
+
export function findFormContent(sitePath, siteYml) {
|
|
227
|
+
const roots = [
|
|
228
|
+
siteYml?.paths?.pages ? join(sitePath, siteYml.paths.pages) : join(sitePath, 'pages'),
|
|
229
|
+
join(sitePath, 'layout')
|
|
230
|
+
]
|
|
231
|
+
const found = []
|
|
232
|
+
|
|
233
|
+
const walk = (dir) => {
|
|
234
|
+
let entries
|
|
235
|
+
try {
|
|
236
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
237
|
+
} catch {
|
|
238
|
+
return // absent or unreadable — not this check's problem to report
|
|
239
|
+
}
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
const p = join(dir, entry.name)
|
|
242
|
+
if (entry.isDirectory()) {
|
|
243
|
+
walk(p)
|
|
244
|
+
} else if (entry.name.endsWith('.md')) {
|
|
245
|
+
try {
|
|
246
|
+
if (FORM_BLOCK.test(readFileSync(p, 'utf8'))) found.push(relative(sitePath, p))
|
|
247
|
+
} catch {
|
|
248
|
+
// unreadable file — skip
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const root of roots) walk(root)
|
|
255
|
+
return found
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A site whose content declares a form needs somewhere to send it.
|
|
260
|
+
*
|
|
261
|
+
* Two things can supply that: `submit:` in site.yml, or the host at serve time.
|
|
262
|
+
* Doctor can only see the first — so it warns only when *nothing* could
|
|
263
|
+
* plausibly supply one: no declaration, and no deploy target that would put a
|
|
264
|
+
* host in the picture. On a site bound to a host, having no `submit:` is the
|
|
265
|
+
* correct configuration, and warning there would nag exactly the people who got
|
|
266
|
+
* it right.
|
|
267
|
+
*
|
|
268
|
+
* The consequence of being wrong in the other direction is what justifies the
|
|
269
|
+
* check at all: a form with no destination renders disabled, which is visible
|
|
270
|
+
* on the page but easy to ship without noticing.
|
|
271
|
+
*/
|
|
272
|
+
export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issues }) {
|
|
273
|
+
if (siteYml?.submit) return
|
|
274
|
+
|
|
275
|
+
const forms = findFormContent(sitePath, siteYml)
|
|
276
|
+
if (forms.length === 0) return
|
|
277
|
+
|
|
278
|
+
// A configured deploy target means a host is in the picture and may supply a
|
|
279
|
+
// destination we cannot see from here.
|
|
280
|
+
let deployYml = null
|
|
281
|
+
try {
|
|
282
|
+
deployYml = await loadDeployYml(sitePath)
|
|
283
|
+
} catch {
|
|
284
|
+
// malformed deploy.yml is reported by its own check; treat as absent here
|
|
285
|
+
}
|
|
286
|
+
if (deployYml?.targets && Object.keys(deployYml.targets).length > 0) return
|
|
287
|
+
|
|
288
|
+
const id = 'form-without-submit-target'
|
|
289
|
+
const n = forms.length
|
|
290
|
+
issues.push({
|
|
291
|
+
id,
|
|
292
|
+
type: 'warning',
|
|
293
|
+
site: siteName,
|
|
294
|
+
message: `${n} content file${n === 1 ? '' : 's'} declare${n === 1 ? 's' : ''} a form, but no submission destination is configured`
|
|
295
|
+
})
|
|
296
|
+
warn(`[${id}] ${n} content file${n === 1 ? '' : 's'} declare${n === 1 ? 's' : ''} a form with nowhere to send it`)
|
|
297
|
+
for (const f of forms.slice(0, 5)) log(` • ${f}`)
|
|
298
|
+
if (n > 5) log(` ${colors.dim}…and ${n - 5} more${colors.reset}`)
|
|
299
|
+
log(
|
|
300
|
+
` Set ${colors.green}submit${colors.reset} in site.yml if you are providing the endpoint.`
|
|
301
|
+
)
|
|
302
|
+
log(
|
|
303
|
+
` ${colors.dim}A host that handles submissions supplies one itself — this check is skipped once a deploy target is configured.${colors.reset}`
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
207
307
|
function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
|
|
208
308
|
const dataDir = join(sitePath, 'public', DATA_DIR)
|
|
209
309
|
const declared = new Set(Object.keys(siteYml.collections || {}))
|
|
@@ -555,6 +655,9 @@ export async function doctor(args = []) {
|
|
|
555
655
|
// than dist/, so what lands there persists and gets deployed. A collection
|
|
556
656
|
// removed from site.yml leaves its compiled records behind — visible to
|
|
557
657
|
// anyone who knows the URL, listed by nothing.
|
|
658
|
+
// Forms need a destination, and having none is only visible on the page.
|
|
659
|
+
await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
|
|
660
|
+
|
|
558
661
|
checkGeneratedDataDir({
|
|
559
662
|
sitePath,
|
|
560
663
|
siteName,
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-
|
|
3
|
+
"generatedAt": "2026-08-01T11:54:04.124Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.16.
|
|
6
|
+
"version": "0.16.10",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"deps": []
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/core": {
|
|
30
|
-
"version": "0.8.
|
|
30
|
+
"version": "0.8.2",
|
|
31
31
|
"path": "framework/core",
|
|
32
32
|
"deps": [
|
|
33
33
|
"@uniweb/semantic-parser",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"deps": []
|
|
46
46
|
},
|
|
47
47
|
"@uniweb/kit": {
|
|
48
|
-
"version": "0.10.
|
|
48
|
+
"version": "0.10.14",
|
|
49
49
|
"path": "framework/kit",
|
|
50
50
|
"deps": [
|
|
51
51
|
"@uniweb/core",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"deps": []
|
|
65
65
|
},
|
|
66
66
|
"@uniweb/projections": {
|
|
67
|
-
"version": "0.2.
|
|
67
|
+
"version": "0.2.5",
|
|
68
68
|
"path": "framework/projections",
|
|
69
69
|
"deps": [
|
|
70
70
|
"@uniweb/content-writer",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
]
|
|
73
73
|
},
|
|
74
74
|
"@uniweb/runtime": {
|
|
75
|
-
"version": "0.9.
|
|
75
|
+
"version": "0.9.4",
|
|
76
76
|
"path": "framework/runtime",
|
|
77
77
|
"deps": [
|
|
78
78
|
"@uniweb/core",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"deps": []
|
|
101
101
|
},
|
|
102
102
|
"@uniweb/templates": {
|
|
103
|
-
"version": "0.9.
|
|
103
|
+
"version": "0.9.4",
|
|
104
104
|
"path": "framework/templates",
|
|
105
105
|
"deps": []
|
|
106
106
|
},
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"deps": []
|
|
111
111
|
},
|
|
112
112
|
"@uniweb/unipress": {
|
|
113
|
-
"version": "0.6.
|
|
113
|
+
"version": "0.6.8",
|
|
114
114
|
"path": "framework/unipress",
|
|
115
115
|
"deps": [
|
|
116
116
|
"@uniweb/build",
|
|
@@ -278,6 +278,17 @@
|
|
|
278
278
|
"ai-generated",
|
|
279
279
|
"starter"
|
|
280
280
|
]
|
|
281
|
+
},
|
|
282
|
+
"services": {
|
|
283
|
+
"name": "Services",
|
|
284
|
+
"description": "Local-services site built around an author-designed quote request form",
|
|
285
|
+
"tags": [
|
|
286
|
+
"services",
|
|
287
|
+
"local-business",
|
|
288
|
+
"forms",
|
|
289
|
+
"quote",
|
|
290
|
+
"trades"
|
|
291
|
+
]
|
|
281
292
|
}
|
|
282
293
|
}
|
|
283
294
|
}
|