uniweb 0.14.14 → 0.14.15

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.14.14",
3
+ "version": "0.14.15",
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/kit": "0.10.12",
45
- "@uniweb/core": "0.8.1",
46
- "@uniweb/runtime": "0.9.3"
44
+ "@uniweb/kit": "0.10.13",
45
+ "@uniweb/runtime": "0.9.3",
46
+ "@uniweb/core": "0.8.1"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.16.8",
49
+ "@uniweb/build": "0.16.9",
50
50
  "@uniweb/content-reader": "1.2.2",
51
51
  "@uniweb/semantic-parser": "1.2.1"
52
52
  },
@@ -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
 
@@ -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,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-31T23:11:07.347Z",
3
+ "generatedAt": "2026-08-01T11:04:49.789Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.16.8",
6
+ "version": "0.16.9",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -45,7 +45,7 @@
45
45
  "deps": []
46
46
  },
47
47
  "@uniweb/kit": {
48
- "version": "0.10.12",
48
+ "version": "0.10.13",
49
49
  "path": "framework/kit",
50
50
  "deps": [
51
51
  "@uniweb/core",
@@ -110,7 +110,7 @@
110
110
  "deps": []
111
111
  },
112
112
  "@uniweb/unipress": {
113
- "version": "0.6.6",
113
+ "version": "0.6.7",
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
  }