uniweb 0.12.56 → 0.12.58

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.12.56",
3
+ "version": "0.12.58",
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/core": "0.7.22",
45
- "@uniweb/kit": "0.9.29",
46
- "@uniweb/runtime": "0.8.28"
44
+ "@uniweb/core": "0.7.24",
45
+ "@uniweb/runtime": "0.8.30",
46
+ "@uniweb/kit": "0.9.31"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.36",
49
+ "@uniweb/build": "0.14.38",
50
50
  "@uniweb/content-reader": "1.1.14",
51
51
  "@uniweb/semantic-parser": "1.1.18"
52
52
  },
@@ -217,7 +217,7 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
217
217
  | `namespace` | `uniweb register` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
218
218
  | `runtimePolicy` | `dist/runtime-pin.json` (foundation build) | `"auto-minor"` | Controls how sites using this foundation receive runtime updates. Three values: `"exact"`, `"auto-patch"`, `"auto-minor"`. See "Foundation runtime policy" below. |
219
219
 
220
- **How a foundation reaches the catalog.** Foundations on Uniweb hosting always live in the catalog as `@org/name@version`. Two ways to get one there, both from the same `dist/foundation.js` artifact:
220
+ **How a foundation reaches the catalog.** Foundations on Uniweb hosting always live in the catalog as `@org/name@version`. Two ways to get one there, both from the same `dist/entry.js` artifact:
221
221
 
222
222
  - **Brought along by `uniweb publish`.** When a foundation powers a single site, don't run `uniweb register` yourself. Run `uniweb publish` from the site directory — it releases the site's local foundation to the catalog under your `@org` (when its code changed) and then goes live, in one step. No separate register ceremony.
223
223
  - **Registered deliberately with `uniweb register --scope @org`.** When the foundation is a product meant for multiple sites — listed in the catalog, consumable by other developers' sites — register it on its own schedule. Consuming sites then pin a versioned ref (`foundation: '@org/name@1.2.3'`).
@@ -845,6 +845,8 @@ seo:
845
845
 
846
846
  **Localized URLs:** On a multilingual site (`languages:` in site.yml), a page's `slug: { <lang>: <segment> }` gives it a native URL segment per language (`/about` → `/fr/a-propos`); the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap.
847
847
 
848
+ **Draft languages:** `publishLanguages: [en, fr]` in site.yml lists which declared languages a published build ships — unlisted ones stay dev-previewable drafts. Absent field = publish all declared; the default language must be listed.
849
+
848
850
  **Content-less containers:** Folders with `page.yml` but no markdown are structural groups. They appear in `getPageHierarchy()` with `hasContent: false` and their own title/label. When visited directly, the runtime auto-redirects to the first descendant with content. This supports hierarchical navigation (courses → modules → lessons) at any depth.
849
851
 
850
852
  ### Lists as Navigation Menus
@@ -976,6 +978,10 @@ contexts:
976
978
  light:
977
979
  section: '#fafaf9' # Override individual tokens per context
978
980
 
981
+ appearance:
982
+ default: light # 'light' | 'dark' | 'system'; see Light/dark appearance below
983
+ allowToggle: true # offer a visitor light/dark switch
984
+
979
985
  fonts:
980
986
  import:
981
987
  - url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
@@ -1015,6 +1021,60 @@ contexts:
1015
1021
 
1016
1022
  > **Contrast warning:** Bright brand colors (orange, yellow, light green) at shade 500 may not meet WCAG contrast (4.5:1) with white foreground text. Test buttons for readability — if contrast is insufficient, keep the default shade 600 mapping.
1017
1023
 
1024
+ ### Light/dark appearance (scheme vs. context)
1025
+
1026
+ Uniweb splits what other frameworks fuse into one "dark mode" into two independent ideas:
1027
+
1028
+ - **Section context** — the `theme:` field on a section (`light` / `medium` / `dark`). It sets the color tokens for *that one section*, so a single page can move through light, dark, and colored sections. Per-section, author-controlled, covered above.
1029
+ - **Site scheme** — the global light/dark preference, configured under `appearance:` in `theme.yml`. This is the site-wide toggle a visitor flips (or that follows their OS).
1030
+
1031
+ They compose rather than fight: a section with **no `theme:`** inherits the site scheme, so it follows the toggle; a section that **pins** `theme: dark` stays dark in either scheme. A dark-scheme site can still carry one bright white CTA; a light-scheme site can carry one dramatic dark hero. You never write `isDark ? … : …` — components built on semantic tokens (`text-heading`, `bg-card`, `border`, …) adapt to whichever scheme and context resolve around them.
1032
+
1033
+ Configure the scheme in `theme.yml`:
1034
+
1035
+ ```yaml
1036
+ appearance:
1037
+ default: light # 'light' | 'dark' | 'system' (start by following the OS)
1038
+ allowToggle: true # offer a visitor switch (also generates the dark tokens)
1039
+ respectSystemPreference: true # first visit follows the OS; set false to pin `default`
1040
+ schemes: [light, dark] # optional — declares the available set
1041
+ ```
1042
+
1043
+ Shorthand strings work too: `appearance: light` / `appearance: dark` (fixed, no toggle), `appearance: system` (follow the OS).
1044
+
1045
+ A site **has dark mode** whenever it offers a toggle, defaults to `dark`/`system`, or lists `dark` in `schemes:`. Any such site follows the visitor's OS on first visit (unless `respectSystemPreference: false`) and remembers a manual choice. Precedence: a stored choice wins over the OS, which wins over `default:`.
1046
+
1047
+ Common intents:
1048
+
1049
+ | Goal | `theme.yml` |
1050
+ |---|---|
1051
+ | Always light, no switch | `appearance: light` |
1052
+ | Always dark, no switch | `appearance: dark` |
1053
+ | Toggle, follow the OS on first visit | `appearance: { default: system, allowToggle: true }` |
1054
+ | Toggle, but always start light | `appearance: { default: light, allowToggle: true, respectSystemPreference: false }` |
1055
+
1056
+ Note that `default: light` is a *fallback*, not a guarantee: a dark OS still wins on first visit unless you set `respectSystemPreference: false` (the last row). `default: system` is just the honest way to say "there is no fixed default — use the OS."
1057
+
1058
+ **Rendering a toggle (foundation side).** The runtime resolves the scheme and applies it to `<html>` (`scheme-dark` / `scheme-light`) *before the page paints* — you never touch `localStorage` or `document` yourself, and there's no flash of the wrong scheme. A section type only renders the button:
1059
+
1060
+ ```jsx
1061
+ import { useAppearance } from '@uniweb/kit'
1062
+
1063
+ function SchemeToggle() {
1064
+ const { scheme, toggle, canToggle } = useAppearance()
1065
+ if (!canToggle) return null // hidden unless the site enables toggling
1066
+ return <button onClick={toggle}>{scheme === 'dark' ? 'Light' : 'Dark'}</button>
1067
+ }
1068
+ ```
1069
+
1070
+ **Tailwind `dark:` variant.** If your foundation uses Tailwind's `dark:` utilities, bind them to the site scheme (not the OS media query) so they track the toggle. In `styles.css`:
1071
+
1072
+ ```css
1073
+ @custom-variant dark (&:where(.scheme-dark, .scheme-dark *));
1074
+ ```
1075
+
1076
+ Without this, `dark:` defaults to `@media (prefers-color-scheme: dark)` and ignores the site's own light/dark setting. Prefer semantic tokens over `dark:` where you can — they adapt to per-section context, which a global `dark:` cannot.
1077
+
1018
1078
  ### Fonts
1019
1079
 
1020
1080
  Font families are a **site** setting, configured in `theme.yml` under `fonts:`. A foundation never installs a font package (no `@fontsource/*`) and never hardcodes a family in its code — if you reach for either, you're off the paved path. The framework provides three roles and wires each onto the standard elements for you:
@@ -1201,7 +1261,7 @@ export default {
1201
1261
  export default { '@acme': '../shared/acme-schemas', '@brand': process.env.BRAND_SCHEMAS }
1202
1262
  ```
1203
1263
 
1204
- Plain JS, so paths can be relative, absolute, or read from an env var. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; an empty value (e.g. an unset env var) falls back to the package convention.
1264
+ Plain JS, so paths can be relative, absolute, or read from an env var. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; an empty value (e.g. an unset env var) falls back to the package convention. A routed scope has no package fallback for a *missing* schema (it errors rather than silently load a different definition). To override a single schema — keeping the rest of the scope routed to the shared folder — add a per-schema key pointing at a file: `'@acme/person': './schemas/acme-person.yml'` (most-specific wins: file › directory › package).
1205
1265
 
1206
1266
  **Validate your data.** `uniweb validate` checks your file-based data against these declared schemas — missing required fields, type/enum/format mismatches, nested fields — before you ship. Warns by default, `--strict` for a non-zero CI exit. It's distinct from `uniweb doctor` (which checks project structure): `validate` checks your *data* against the schemas you *declared*. Remote (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred — validate those against live data.
1207
1267
 
@@ -1340,6 +1400,8 @@ import { Visual } from '@uniweb/kit'
1340
1400
 
1341
1401
  **Utilities:** `cn()` (Tailwind class merge — `cn('px-4', condition && 'bg-primary')` resolves conflicts), `Link`, `Image`, `Asset`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`
1342
1402
 
1403
+ > **`cn()` gotcha — a later `text-<size>` silently drops an earlier `leading-*`.** Tailwind's size classes set line height too, so `cn()` treats the size as replacing the leading: `cn('leading-[1.1] text-4xl')` → `text-4xl`. Put the size first, or fold the leading into it (`text-[clamp(2rem,5vw,4rem)]/[1.1]`). Most likely to bite when the size comes from a lookup and the leading sits in a shared base string.
1404
+
1343
1405
  **Other styled:** `SidebarLayout`, `Prose`, `Article`, `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
1344
1406
 
1345
1407
  ### Hook Signatures
@@ -1354,7 +1416,7 @@ useThemeData() → Theme // programmatic color
1354
1416
  useColorContext(block) → 'light' | 'medium' | 'dark' // current section context
1355
1417
  ```
1356
1418
 
1357
- `isActive` and `isActiveOrAncestor` accept a Page object or a route string. `useAppearance` reads `appearance:` from `theme.yml` — `scheme` is `'light'`|`'dark'`, `canToggle` reflects `allowToggle` config. Stores preference in localStorage, respects system preference.
1419
+ `isActive` and `isActiveOrAncestor` accept a Page object or a route string. `useAppearance` reflects the site scheme from `appearance:` in `theme.yml` — `scheme` is `'light'`|`'dark'`, `canToggle` reflects `allowToggle`. The runtime applies the scheme to `<html>` before paint; the hook reads it, `toggle`/`setScheme` flip it and persist the choice to localStorage. First visit follows the OS when the site has dark mode. Full model: *Light/dark appearance* above.
1358
1420
 
1359
1421
  ### Icon Component
1360
1422
 
@@ -1622,7 +1684,7 @@ import LessonHeader from '../../components/LessonHeader'
1622
1684
 
1623
1685
  Within the same directory (e.g., one component importing a sibling), use normal relative imports (`./AIFeedbackCard`).
1624
1686
 
1625
- **Foundation entry shape (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — e.g. `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers`. Optionally a named `vars` export for theme-variable metadata (see *Foundation variables*). Everything else (section types, layouts) is auto-discovered from `sections/` and `layouts/` and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in the produced `dist/foundation.js`; you don't write that wrapper yourself, and most foundation code never sees it. The one place it matters: when you import your **own** `main.js` from a foundation component (e.g., a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1687
+ **Foundation entry shape (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — e.g. `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers`. Optionally a named `vars` export for theme-variable metadata (see *Foundation variables*). Everything else (section types, layouts) is auto-discovered from `sections/` and `layouts/` and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in the produced `dist/entry.js`; you don't write that wrapper yourself, and most foundation code never sees it. The one place it matters: when you import your **own** `main.js` from a foundation component (e.g., a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1626
1688
 
1627
1689
  ### Website and Page APIs
1628
1690
 
@@ -60,6 +60,7 @@ import {
60
60
  classifyPackage,
61
61
  isExtensionPackage,
62
62
  } from '@uniweb/build'
63
+ import { resolveDefaultLocale, normalizeLanguageList } from '@uniweb/core/locale-config'
63
64
  import { readSiteConfig } from '@uniweb/build/site'
64
65
  import { readWorkspaceConfig, resolveGlob } from '../utils/config.js'
65
66
 
@@ -373,12 +374,26 @@ async function loadI18nConfig(projectDir, siteConfig = null) {
373
374
 
374
375
  // Resolve locales (undefined/'*' → all available, array → specific)
375
376
  const { resolveLocales } = await import('@uniweb/build/i18n')
376
- const locales = await resolveLocales(config.languages, localesPath)
377
+ let locales = await resolveLocales(config.languages, localesPath)
378
+
379
+ // Publish filter: build outputs are visitor artifacts. With an explicit
380
+ // `publishLanguages:`, only listed locales get dist/{locale}/ content,
381
+ // per-locale HTML, hreflang, and translated collections. Draft locales
382
+ // stay previewable in dev, which doesn't run this path. Intersecting the
383
+ // DISK-resolved list here also covers wildcard (`languages: '*'`) sites.
384
+ if (config.publishLanguages != null) {
385
+ const publishSet = new Set(normalizeLanguageList(config.publishLanguages))
386
+ const dropped = locales.filter((l) => !publishSet.has(l))
387
+ locales = locales.filter((l) => publishSet.has(l))
388
+ if (dropped.length > 0) {
389
+ info(`Skipping non-publishable locale(s): ${dropped.join(', ')} (not in publishLanguages)`)
390
+ }
391
+ }
377
392
 
378
393
  if (locales.length === 0) return null
379
394
 
380
395
  return {
381
- defaultLocale: config.defaultLanguage || 'en',
396
+ defaultLocale: resolveDefaultLocale(config),
382
397
  locales,
383
398
  localesDir,
384
399
  }
@@ -19,6 +19,7 @@ import { resolve, join, dirname, basename, relative } from 'path'
19
19
  import { existsSync } from 'fs'
20
20
  import { readFile, writeFile, mkdir, readdir, unlink, rename } from 'fs/promises'
21
21
  import yaml from 'js-yaml'
22
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
22
23
  import {
23
24
  isWorkspaceRoot,
24
25
  findSites,
@@ -216,7 +217,7 @@ async function loadSiteConfig(siteRoot) {
216
217
  const locales = await resolveLocales(config.languages, localesPath)
217
218
 
218
219
  return {
219
- defaultLocale: config.defaultLanguage || 'en',
220
+ defaultLocale: resolveDefaultLocale(config),
220
221
  locales,
221
222
  localesDir,
222
223
  ...config.i18n,
@@ -48,6 +48,7 @@ import {
48
48
  rewriteBallAssets,
49
49
  } from '@uniweb/build/site'
50
50
  import { emitSyncPackages } from '@uniweb/build/uwx'
51
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
51
52
 
52
53
  import { BackendClient } from '../backend/client.js'
53
54
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
@@ -117,7 +118,9 @@ function languagesFromContent(siteContent) {
117
118
 
118
119
  // Languages from site.yml — used only for the dry-run summary (no build yet).
119
120
  function languagesFromSiteYml(siteYml) {
120
- const def = siteYml.defaultLanguage || siteYml.lang || 'en'
121
+ // Legacy `lang:` still honored between defaultLanguage and the shared
122
+ // `defaultLanguage || languages[0] || 'en'` rule.
123
+ const def = siteYml.defaultLanguage || siteYml.lang || resolveDefaultLocale(siteYml)
121
124
  const locales = siteYml.i18n?.locales || siteYml.languages
122
125
  if (!Array.isArray(locales) || locales.length === 0) return null
123
126
  const norm = locales.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
@@ -454,6 +454,17 @@ async function runRegister(args = []) {
454
454
  return { exitCode: 1 }
455
455
  }
456
456
  }
457
+ // Registering ≠ delivering the file. A registered schema is a content type
458
+ // authors use in the app; a FOUNDATION build still resolves '@scope/name' from
459
+ // disk (a package or a routed folder). This is the moment that belief forms —
460
+ // you just registered it — so correct it here, not in a doc nobody's reading.
461
+ if (!jsonMode && defined.length > 0 && scope) {
462
+ log('')
463
+ info('Registered schemas are content types authors can use in the Uniweb app.')
464
+ log(` ${colors.dim}Delivery is separate: a foundation that binds ${scope}/<name> resolves it from${colors.reset}`)
465
+ log(` ${colors.dim}disk — an ${scope}/schemas package, or a folder routed in schemas.config.js.${colors.reset}`)
466
+ }
467
+
457
468
  if (jsonMode) {
458
469
  // Join my authoritative submitted names with the backend's minted ids. Each
459
470
  // response entry is `{ registered: { name, version, payload_model_uuid, … },
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-23T14:06:14.245Z",
3
+ "generatedAt": "2026-07-24T20:04:13.283Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.36",
6
+ "version": "0.14.38",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -25,7 +25,7 @@
25
25
  "deps": []
26
26
  },
27
27
  "@uniweb/core": {
28
- "version": "0.7.22",
28
+ "version": "0.7.24",
29
29
  "path": "framework/core",
30
30
  "deps": [
31
31
  "@uniweb/semantic-parser",
@@ -43,7 +43,7 @@
43
43
  "deps": []
44
44
  },
45
45
  "@uniweb/kit": {
46
- "version": "0.9.29",
46
+ "version": "0.9.31",
47
47
  "path": "framework/kit",
48
48
  "deps": [
49
49
  "@uniweb/core",
@@ -61,7 +61,7 @@
61
61
  "deps": []
62
62
  },
63
63
  "@uniweb/runtime": {
64
- "version": "0.8.28",
64
+ "version": "0.8.30",
65
65
  "path": "framework/runtime",
66
66
  "deps": [
67
67
  "@uniweb/core",
@@ -94,12 +94,12 @@
94
94
  "deps": []
95
95
  },
96
96
  "@uniweb/theming": {
97
- "version": "0.1.9",
97
+ "version": "0.1.10",
98
98
  "path": "framework/theming",
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.43",
102
+ "version": "0.4.45",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",