uniweb 1.21.17 → 1.21.18

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": "1.21.17",
3
+ "version": "1.21.18",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.3.2",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.5.21",
44
- "@uniweb/core": "~1.4.11",
44
+ "@uniweb/content-writer": "~1.3.1",
45
45
  "@uniweb/semantic-parser": "~1.13.0",
46
- "@uniweb/schemas": "~1.6.5",
47
- "@uniweb/content-writer": "~1.3.1"
46
+ "@uniweb/core": "~1.4.12",
47
+ "@uniweb/schemas": "~1.6.5"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.11",
51
- "@uniweb/semantic-parser": "~1.13.0",
52
- "@uniweb/content-reader": "~1.5.7"
50
+ "@uniweb/build": "~1.10.12",
51
+ "@uniweb/content-reader": "~1.5.7",
52
+ "@uniweb/semantic-parser": "~1.13.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -729,7 +729,9 @@ end the list with `...`.**
729
729
 
730
730
  **A local folder for a mounted route should use `folder.yml`, not `page.yml`.** The filename is how you say what the folder holds — `page.yml` for a page built from sections, `folder.yml` for a folder of pages — and for a mount that answer also decides how the mounted tree is read. A `page.yml` stub over a mounted folder of pages says "sections", and top-level markdown in the mounted repo collapses into one page instead of becoming pages of its own. Keep `layout:`, `title:` and SEO in that stub; the mounted repo's own `folder.yml` supplies its ordering and title where the stub is silent.
731
731
 
732
- **SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). Each page's static `<head>` carries its own tags — Open Graph, Twitter Card, canonical, robots, and hreflang on a multilingual site — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level; the site's `description` is the home page's alone. These tags are the framework's: a `head.html` tag of the same name is replaced.
732
+ **SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). Each page's static `<head>` carries its own tags — Open Graph, Twitter Card, canonical, robots, and hreflang on a multilingual site — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level — a 1200×630 JPEG or PNG, since link previews draw 1.91:1 and most draw no SVG; the site's `description` is the home page's alone, and each page's own reads best under about 160 characters. These tags are the framework's: a `head.html` tag of the same name is replaced. `uniweb doctor` checks what a link shows: the image is there, in a format previews draw and close to 1200×630, and each page has a description of a length search results show whole.
733
+
734
+ **The tab icon:** `favicon: /favicon.svg` in `site.yml`, a file in `public/`. Name it even when the file is `public/favicon.svg`: a static build finds `public/favicon.{svg,ico,png}` on its own, but a site published with `uniweb publish` links only the favicon `site.yml` names (`uniweb doctor --fix favicon-unnamed` writes the line).
733
735
 
734
736
  ```yaml
735
737
  # site.yml
@@ -745,7 +747,7 @@ seo:
745
747
  canonical: https://acme.com/team # only to name ANOTHER URL — a page is its own canonical by default
746
748
  ```
747
749
 
748
- **Where the site lives — every absolute URL is made on it:** a page's canonical and `og:url`, its social image, its sitemap entry, the links in `llms.txt`. It is the site's origin plus its base path, and whoever serves the site states it: the GitHub Pages workflow `uniweb add ci --host=github-pages` writes asks GitHub; Netlify and Vercel state their production URL during the build; on any other static host, set `seo.origin` — a scheme and a host, `https://acme.com`, never with a path (a path is the site's `base:`; `seo.origin: https://user.github.io/repo` is refused). `seo.baseUrl` is retired, and refused. With no address, pages state no canonical, no `sitemap.xml` is written, and `llms.txt` links are root-relative — `uniweb doctor` says when that is so, and how to fix it for your host.
750
+ **Where the site lives — every absolute URL is made on it:** a page's canonical and `og:url`, its social image, its sitemap entry, the links in `llms.txt`. It is the site's origin plus its base path, and whoever serves the site states it: the GitHub Pages workflow `uniweb add ci --host=github-pages` writes asks GitHub; Netlify and Vercel state their production URL during the build; on any other static host, set `seo.origin` — a scheme and a host, `https://acme.com`, never with a path (a path is the site's `base:`; `seo.origin: https://user.github.io/repo` is refused). `seo.baseUrl` is retired, and refused. With no address, pages state no canonical, no `sitemap.xml` is written, and the social image and `llms.txt` links are root-relative — `uniweb doctor` says when that is so, and how to fix it for your host.
749
751
 
750
752
  **Localized URLs:** on a multilingual site (`languages:` in site.yml), `slug: { <lang>: <segment> }` gives a page a native URL segment per language; the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap. See Part 5 for the translation workflow.
751
753
 
@@ -6,6 +6,7 @@ import {
6
6
  existsSync,
7
7
  readFileSync,
8
8
  readdirSync,
9
+ statSync,
9
10
  writeFileSync,
10
11
  mkdirSync,
11
12
  rmSync
@@ -19,9 +20,17 @@ import {
19
20
  extensionNameOf,
20
21
  findSiteExtension
21
22
  } from '@uniweb/build'
22
- import { loadDeployYml, AGENTS_KEYS, originOf, refuseUnreadableAddress } from '@uniweb/build/site'
23
+ import {
24
+ loadDeployYml,
25
+ AGENTS_KEYS,
26
+ originOf,
27
+ refuseUnreadableAddress,
28
+ collectSiteContent,
29
+ resolveAssetPath,
30
+ findPublicFavicon
31
+ } from '@uniweb/build/site'
23
32
  import { listAdapters } from '@uniweb/build/hosts'
24
- import { RUNTIME_KEYS } from '@uniweb/build/uwx'
33
+ import { RUNTIME_KEYS, writeSiteConfig } from '@uniweb/build/uwx'
25
34
  import { getCliVersion } from '../versions.js'
26
35
  import { readAgentsVersion } from '../utils/agents-stamp.js'
27
36
  import { writeJsonPreservingStyle } from '../utils/json-file.js'
@@ -356,7 +365,7 @@ export function checkAgentsBlock({ siteName, siteYml, issues }) {
356
365
  const id = 'agents-not-an-object'
357
366
  issues.push({
358
367
  id,
359
- type: 'warning',
368
+ type: 'warn',
360
369
  site: siteName,
361
370
  message: `site.yml: \`agents:\` should be a map of options, or \`false\` to turn it off`
362
371
  })
@@ -373,7 +382,7 @@ export function checkAgentsBlock({ siteName, siteYml, issues }) {
373
382
  const id = 'agents-unknown-key'
374
383
  issues.push({
375
384
  id,
376
- type: 'warning',
385
+ type: 'warn',
377
386
  site: siteName,
378
387
  message: `site.yml: \`agents:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
379
388
  })
@@ -404,7 +413,7 @@ function checkExpectedOrigins({ siteName, agents, issues }) {
404
413
  const shown = unreadable.map((entry) => `'${String(entry)}'`).join(', ')
405
414
  issues.push({
406
415
  id,
407
- type: 'warning',
416
+ type: 'warn',
408
417
  site: siteName,
409
418
  message: `site.yml: \`agents.expectedOrigins\` ${unreadable.length === 1 ? 'entry' : 'entries'} ${shown} name${unreadable.length === 1 ? 's' : ''} no origin, so ${unreadable.length === 1 ? 'it matches' : 'they match'} nothing`
410
419
  })
@@ -554,7 +563,7 @@ export async function checkFoundationSupports({
554
563
 
555
564
  issues.push({
556
565
  id,
557
- type: 'warning',
566
+ type: 'warn',
558
567
  foundation: foundationName,
559
568
  message: hasDeclaration
560
569
  ? `${foundationName} builds against ${list} but does not declare ${missing.length === 1 ? 'it' : 'them'} in uniweb.supports`
@@ -620,7 +629,7 @@ export function checkSectionFamilies({
620
629
  const near = suggestFamily(s.unknown, familyIds)
621
630
  issues.push({
622
631
  id,
623
- type: 'warning',
632
+ type: 'warn',
624
633
  foundation: foundationName,
625
634
  message: `${s.name} declares family '${s.unknown}', which is not a standard family`,
626
635
  })
@@ -664,7 +673,7 @@ export function checkSectionFamilies({
664
673
  fixable: writable.length > 0,
665
674
  })
666
675
 
667
- warn(`[${id}] ${folderName}: ${proposals.length} section(s) have no standard family`)
676
+ info(`[${id}] ${folderName}: ${proposals.length} section(s) have no standard family`)
668
677
  const width = Math.max(...proposals.map((p) => p.name.length))
669
678
  for (const p of proposals) {
670
679
  const { id: hit, via, ambiguous } = p.suggestion
@@ -832,7 +841,7 @@ export function checkUngatedServiceControls({ foundationName, folderName, srcDir
832
841
  for (const f of found) {
833
842
  issues.push({
834
843
  id,
835
- type: 'warning',
844
+ type: 'warn',
836
845
  foundation: foundationName,
837
846
  message: `${foundationName}/${f.file} calls ${f.hook}() without reading ${f.gate}`,
838
847
  details: f,
@@ -855,7 +864,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
855
864
  const id = 'tracking-not-an-object'
856
865
  issues.push({
857
866
  id,
858
- type: 'warning',
867
+ type: 'warn',
859
868
  site: siteName,
860
869
  message: `site.yml: \`services.tracking\` should be true, false, an address, or a map of options`
861
870
  })
@@ -869,7 +878,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
869
878
  const id = 'tracking-unknown-key'
870
879
  issues.push({
871
880
  id,
872
- type: 'warning',
881
+ type: 'warn',
873
882
  site: siteName,
874
883
  message: `site.yml: \`services.tracking\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
875
884
  })
@@ -908,7 +917,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
908
917
  const shown = spelling === '' ? 'an empty value' : `\`${String(value)}\``
909
918
  issues.push({
910
919
  id,
911
- type: 'warning',
920
+ type: 'warn',
912
921
  site: siteName,
913
922
  message: `site.yml: \`services.tracking.consent\` is ${shown} — only the exact value \`required\` turns the gate on`
914
923
  })
@@ -933,7 +942,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
933
942
  const id = 'tracking-script-without-url'
934
943
  issues.push({
935
944
  id,
936
- type: 'warning',
945
+ type: 'warn',
937
946
  site: siteName,
938
947
  message: `site.yml: \`services.tracking.scripts\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
939
948
  })
@@ -999,7 +1008,7 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
999
1008
  const n = forms.length
1000
1009
  issues.push({
1001
1010
  id,
1002
- type: 'warning',
1011
+ type: 'warn',
1003
1012
  site: siteName,
1004
1013
  message: `${n} content file${n === 1 ? '' : 's'} declare${n === 1 ? 's' : ''} a form, but no submission destination is configured`
1005
1014
  })
@@ -1054,7 +1063,7 @@ export async function checkSiteAddress({ sitePath, siteName, siteYml, workspaceD
1054
1063
  const host = defaultDeployHost(deployYml)
1055
1064
  if (ADDRESS_STATING_HOSTS.has(host)) return
1056
1065
 
1057
- const without = 'Without one, no page states a canonical URL, no sitemap.xml is written, and llms.txt links root-relative.'
1066
+ const without = 'Without one, no page states a canonical URL, no sitemap.xml is written, and llms.txt links and the image a link preview shows (og:image) are root-relative.'
1058
1067
  if (host === 'github-pages') {
1059
1068
  const workflowPath = join(workspaceDir, '.github', 'workflows', 'deploy-github-pages.yml')
1060
1069
  const workflow = existsSync(workflowPath) ? readFileSync(workflowPath, 'utf8') : null
@@ -1063,7 +1072,7 @@ export async function checkSiteAddress({ sitePath, siteName, siteYml, workspaceD
1063
1072
  const id = 'site-address-from-github'
1064
1073
  issues.push({
1065
1074
  id,
1066
- type: 'warning',
1075
+ type: 'warn',
1067
1076
  site: siteName,
1068
1077
  message: "The GitHub Pages workflow does not hand the build the address GitHub serves the site at"
1069
1078
  })
@@ -1077,9 +1086,9 @@ export async function checkSiteAddress({ sitePath, siteName, siteYml, workspaceD
1077
1086
  const id = 'site-address-unknown'
1078
1087
  issues.push({
1079
1088
  id,
1080
- type: 'warning',
1089
+ type: 'warn',
1081
1090
  site: siteName,
1082
- message: 'The site states no address: no canonical URLs, no sitemap.xml, root-relative llms.txt'
1091
+ message: 'The site states no address: no canonical URLs, no sitemap.xml, a root-relative llms.txt and og:image'
1083
1092
  })
1084
1093
  warn(`[${id}] Nothing states where this site is served`)
1085
1094
  log(` Set ${colors.green}seo.origin${colors.reset} in site.yml — the origin it is served from, e.g. https://example.com`)
@@ -1087,6 +1096,217 @@ export async function checkSiteAddress({ sitePath, siteName, siteYml, workspaceD
1087
1096
  log(` ${colors.dim}${without}${colors.reset}`)
1088
1097
  }
1089
1098
 
1099
+ // What a link preview draws, from the platforms' own pages, read 2026-10-27. Facebook
1100
+ // (developers.facebook.com/docs/sharing/webmasters/images): 1.91:1 shows the whole image, at least
1101
+ // 1200×630 for high-resolution screens and 600×315 for a large card, 8 MB at most. LinkedIn
1102
+ // (linkedin.com/help/linkedin/answer/a521928): 1.91:1, at least 1200×627, 5 MB at most, and a
1103
+ // thumbnail for an image under 401 px wide.
1104
+ const CARD = { width: 1200, height: 630, ratio: 1.91 }
1105
+ const CARD_LARGE = { width: 600, height: 315 }
1106
+ const CARD_MAX_BYTES = 5 * 1024 * 1024
1107
+ // The formats link previews draw. SVG is not among them: X's card rules exclude it by name.
1108
+ const CARD_FORMATS = new Set(['jpeg', 'png', 'webp', 'gif'])
1109
+ // No search engine or preview publishes one length; their snippets commonly end near this.
1110
+ const DESCRIPTION_SHOWN = 160
1111
+
1112
+ /** A path into the site's own files (`/images/card.jpg`), not an address of its own. */
1113
+ const isSitePath = (v) => typeof v === 'string' && v.startsWith('/') && !v.startsWith('//')
1114
+
1115
+ /** `/x` → the file under the site's public/ or assets/ that serves it, or null when there is none. */
1116
+ function siteFile(sitePath, ref) {
1117
+ const { resolved } = resolveAssetPath(ref, join(sitePath, 'site.yml'), sitePath)
1118
+ return resolved && existsSync(resolved) ? resolved : null
1119
+ }
1120
+
1121
+ /** Up to `n` items, then how many more. */
1122
+ function listSome(items, n = 5) {
1123
+ for (const item of items.slice(0, n)) log(` • ${item}`)
1124
+ if (items.length > n) log(` ${colors.dim}…and ${items.length - n} more${colors.reset}`)
1125
+ }
1126
+
1127
+ /**
1128
+ * What a link to the site shows. A chat, a feed or a search result draws a card from a page's head
1129
+ * tags (`Page#getHeadMeta`): its image — the page's `seo.image`, else the site's — its title and its
1130
+ * description. Nothing fails when they are wrong; a shared link just looks it, and only the source
1131
+ * says why: whether the image is there, its size and format, and the text.
1132
+ *
1133
+ * Read from the build's own collect (`collectSiteContent`), so the pages are the ones the site
1134
+ * publishes — drafts dropped, the home page found by the site's own rule — and a page.yml the build
1135
+ * cannot parse is reported as that, never as a page with no description. A page made from a record
1136
+ * (`[slug]`) takes its image and title from the record, which no file here describes, so it is left
1137
+ * out. An image at an address of its own is left alone: doctor reads files, it fetches nothing.
1138
+ */
1139
+ export async function checkLinkPreviews({ sitePath, siteName, issues }) {
1140
+ let content
1141
+ let failure = null
1142
+ // The collect reports its progress on the console; this report is doctor's.
1143
+ const { log: consoleLog, warn: consoleWarn } = console
1144
+ console.log = console.warn = () => {}
1145
+ try {
1146
+ content = await collectSiteContent(sitePath, { dropUnpublished: true, strict: true })
1147
+ } catch (err) {
1148
+ failure = err
1149
+ } finally {
1150
+ console.log = consoleLog
1151
+ console.warn = consoleWarn
1152
+ }
1153
+ if (failure) {
1154
+ const id = 'site-content-unreadable'
1155
+ const [first, ...rest] = String(failure.message)
1156
+ .replace(/^\[content-collector\] /, '')
1157
+ .replaceAll(`${sitePath}/`, '')
1158
+ .split('\n')
1159
+ issues.push({ id, type: 'error', site: siteName, message: first })
1160
+ error(`[${id}] ${first}`)
1161
+ for (const line of rest) if (line.trim()) log(` ${colors.dim}${line}${colors.reset}`)
1162
+ log(` ${colors.dim}\`uniweb build\` stops on the same thing. Link previews were not checked.${colors.reset}`)
1163
+ return
1164
+ }
1165
+
1166
+ // A folder with no content of its own only redirects (to its first page), so a link never lands on it.
1167
+ const pages = (content.pages || []).filter((p) => !p.isDynamic && p.hasContent !== false)
1168
+ if (pages.length === 0) return
1169
+ const site = content.config || {}
1170
+ const siteImage = site.seo?.image || null
1171
+
1172
+ // ── The image ──────────────────────────────────────────────────────────────
1173
+ const without = pages.filter((p) => !(p.seo?.image || siteImage))
1174
+ if (without.length > 0) {
1175
+ const id = 'share-image-none'
1176
+ const n = without.length
1177
+ const message = n === pages.length ? 'Links to this site show no image' : `Links to ${n} page${n === 1 ? '' : 's'} show no image`
1178
+ issues.push({ id, type: 'info', site: siteName, message })
1179
+ info(`[${id}] ${message}`)
1180
+ if (without.length < pages.length) listSome(without.map((p) => p.route))
1181
+ log(` Set ${colors.green}seo.image${colors.reset} in site.yml — every page without one of its own shows it. A ${CARD.width}×${CARD.height} JPEG or PNG fills a card.`)
1182
+ }
1183
+
1184
+ // Each image once, with the pages that show it.
1185
+ const shownBy = new Map()
1186
+ for (const page of pages) {
1187
+ const image = page.seo?.image || siteImage
1188
+ if (!isSitePath(image)) continue
1189
+ if (!shownBy.has(image)) shownBy.set(image, [])
1190
+ shownBy.get(image).push(page.route)
1191
+ }
1192
+ let readImageSize = null
1193
+ for (const [image, routes] of shownBy) {
1194
+ const shownOn = routes.length === pages.length ? 'every page' : routes.length === 1 ? routes[0] : `${routes.length} pages`
1195
+ const file = siteFile(sitePath, image)
1196
+ if (!file) {
1197
+ const id = 'share-image-missing'
1198
+ issues.push({ id, type: 'warn', site: siteName, message: `${image}, the image a link to ${shownOn} shows, is not in public/ or assets/` })
1199
+ warn(`[${id}] ${image} is not in public/ or assets/ — a link to ${shownOn} shows no image`)
1200
+ continue
1201
+ }
1202
+ readImageSize ??= (await import('@uniweb/build/images')).readImageSize
1203
+ const size = await readImageSize(file)
1204
+ const bytes = statSync(file).size
1205
+ if (!size || !CARD_FORMATS.has(size.format)) {
1206
+ const id = 'share-image-format'
1207
+ const what = !size
1208
+ ? 'could not be read as an image'
1209
+ : size.format === 'svg'
1210
+ ? 'is an SVG, which link previews do not draw'
1211
+ : `is ${size.format.toUpperCase()}, which not every link preview draws`
1212
+ issues.push({ id, type: 'warn', site: siteName, message: `${image} ${what}` })
1213
+ warn(`[${id}] ${image}, the image a link to ${shownOn} shows, ${what}`)
1214
+ log(` Link previews draw a JPEG, PNG, WebP or GIF; a ${CARD.width}×${CARD.height} JPEG or PNG is the safe choice.`)
1215
+ continue
1216
+ }
1217
+ if (bytes > CARD_MAX_BYTES) {
1218
+ const id = 'share-image-heavy'
1219
+ const mb = (bytes / 1024 / 1024).toFixed(1)
1220
+ issues.push({ id, type: 'warn', site: siteName, message: `${image} is ${mb} MB; LinkedIn takes 5 MB at most` })
1221
+ warn(`[${id}] ${image} is ${mb} MB — LinkedIn draws no image over 5 MB`)
1222
+ log(` A ${CARD.width}×${CARD.height} JPEG is usually well under 1 MB.`)
1223
+ }
1224
+ const { width, height } = size
1225
+ const ratio = width / height
1226
+ const advice =
1227
+ width < CARD_LARGE.width || height < CARD_LARGE.height
1228
+ ? `under ${CARD_LARGE.width}×${CARD_LARGE.height}, previews draw it as a thumbnail, not a full card`
1229
+ : Math.abs(ratio - CARD.ratio) / CARD.ratio > 0.1
1230
+ ? `${ratio.toFixed(2)}:1 — previews crop it to ${CARD.ratio}:1`
1231
+ : width < CARD.width
1232
+ ? `high-resolution screens draw it soft`
1233
+ : null
1234
+ if (advice) {
1235
+ const id = 'share-image-shape'
1236
+ issues.push({ id, type: 'info', site: siteName, message: `${image} is ${width}×${height}: ${advice}` })
1237
+ info(`[${id}] ${image} is ${width}×${height}: ${advice}`)
1238
+ log(` Link previews draw ${CARD.ratio}:1 — ${CARD.width}×${CARD.height} fills one, whole and sharp.`)
1239
+ }
1240
+ }
1241
+
1242
+ // ── The description ────────────────────────────────────────────────────────
1243
+ // What the page states (`Page#getHeadMeta`): its own, or for the home page the site's. A page
1244
+ // marked `noindex` is left out — it asks search engines not to list it at all.
1245
+ const described = pages
1246
+ .filter((p) => !p.seo?.noindex)
1247
+ .map((p) => ({ route: p.route, text: (p.description || (p.route === '/' ? site.description : '') || '').trim() }))
1248
+ const undescribed = described.filter((p) => !p.text)
1249
+ if (undescribed.length > 0) {
1250
+ const id = 'page-description-none'
1251
+ const n = undescribed.length
1252
+ issues.push({ id, type: 'info', site: siteName, message: `${n} page${n === 1 ? ' states' : 's state'} no description` })
1253
+ info(`[${id}] ${n} page${n === 1 ? ' states' : 's state'} no description — search results and link previews pick their own text, or show none`)
1254
+ listSome(undescribed.map((p) => p.route))
1255
+ log(` Give each a ${colors.green}description:${colors.reset} in its page.yml — a sentence or two, under ${DESCRIPTION_SHOWN} characters.`)
1256
+ }
1257
+ const long = described.filter((p) => p.text.length > DESCRIPTION_SHOWN)
1258
+ if (long.length > 0) {
1259
+ const id = 'page-description-long'
1260
+ const n = long.length
1261
+ issues.push({ id, type: 'info', site: siteName, message: `${n} description${n === 1 ? ' runs' : 's run'} past ${DESCRIPTION_SHOWN} characters` })
1262
+ info(`[${id}] ${n} description${n === 1 ? ' runs' : 's run'} past ${DESCRIPTION_SHOWN} characters — search results and link previews cut ${n === 1 ? 'it' : 'them'} short`)
1263
+ listSome(long.map((p) => `${p.route} ${colors.dim}(${p.text.length})${colors.reset}`))
1264
+ }
1265
+ }
1266
+
1267
+ /**
1268
+ * The tab icon. A static build links `site.yml`'s `favicon:`, else the favicon its public folder
1269
+ * holds (`findPublicFavicon`); a Uniweb host's pages link only the one `favicon:` names, and only
1270
+ * that file is uploaded. So a site whose icon is found rather than named has one on a static host
1271
+ * and none on a Uniweb host — every site made from a template that ships `public/favicon.svg`
1272
+ * before 2026-10-27, when the templates started naming it.
1273
+ */
1274
+ export async function checkFavicon({ sitePath, siteName, siteYml, issues, shouldFix, fixed: reportFixed }) {
1275
+ const named = typeof siteYml?.favicon === 'string' ? siteYml.favicon.trim() : ''
1276
+ if (named) {
1277
+ if (!isSitePath(named) || siteFile(sitePath, named)) return
1278
+ const id = 'favicon-missing'
1279
+ issues.push({ id, type: 'warn', site: siteName, message: `favicon: ${named} is not in public/ or assets/` })
1280
+ warn(`[${id}] favicon: ${named} is not in public/ or assets/ — every page links an icon that is not there`)
1281
+ return
1282
+ }
1283
+
1284
+ const found = findPublicFavicon(join(sitePath, 'public'))
1285
+ if (!found) return
1286
+ const id = 'favicon-unnamed'
1287
+ const value = `/${found}`
1288
+ const issue = {
1289
+ id,
1290
+ type: 'info',
1291
+ site: siteName,
1292
+ message: `public/${found} is not named in site.yml, so a Uniweb host's pages do not link it`,
1293
+ fixable: true
1294
+ }
1295
+ issues.push(issue)
1296
+ info(`[${id}] public/${found} is not named in site.yml — a static build links it, a Uniweb host's pages do not`)
1297
+ if (shouldFix?.(id)) {
1298
+ try {
1299
+ writeSiteConfig(sitePath, { favicon: value })
1300
+ issue.fixed = true
1301
+ reportFixed?.(`site.yml now names favicon: ${value}`)
1302
+ return
1303
+ } catch (err) {
1304
+ warn(`[${id}] could not write site.yml: ${err.message}`)
1305
+ }
1306
+ }
1307
+ log(` Add ${colors.green}favicon: ${value}${colors.reset} to site.yml — ${colors.dim}uniweb doctor --fix ${id}${colors.reset}`)
1308
+ }
1309
+
1090
1310
  /** The bare map in `queries.yml`, or `{}` when there is none. */
1091
1311
  function readQueriesYml(sitePath) {
1092
1312
  try {
@@ -1131,7 +1351,7 @@ export function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, sho
1131
1351
  const id = 'orphaned-query-output'
1132
1352
  issues.push({
1133
1353
  id,
1134
- type: 'warning',
1354
+ type: 'warn',
1135
1355
  site: siteName,
1136
1356
  message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared query`
1137
1357
  })
@@ -1176,7 +1396,7 @@ export function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, sho
1176
1396
  const id = 'generated-data-not-ignored'
1177
1397
  issues.push({
1178
1398
  id,
1179
- type: 'warning',
1399
+ type: 'warn',
1180
1400
  site: siteName,
1181
1401
  message: `public/${DATA_DIR}/ is generated but not gitignored`
1182
1402
  })
@@ -1401,7 +1621,7 @@ async function runDoctor(args = []) {
1401
1621
  : 'uses the `prose` class'
1402
1622
  issues.push({
1403
1623
  id,
1404
- type: 'warning',
1624
+ type: 'warn',
1405
1625
  foundation: f.name,
1406
1626
  message: `${f.name} ${reason} but does not import prose-tokens.css`
1407
1627
  })
@@ -1431,7 +1651,7 @@ async function runDoctor(args = []) {
1431
1651
  const id = 'prose-plugin-alongside'
1432
1652
  issues.push({
1433
1653
  id,
1434
- type: 'warning',
1654
+ type: 'warn',
1435
1655
  foundation: f.name,
1436
1656
  message: `${f.name} loads Tailwind Typography as well as kit's prose layer`
1437
1657
  })
@@ -1451,7 +1671,7 @@ async function runDoctor(args = []) {
1451
1671
  const names = [...new Set(found.flatMap((x) => x.modifiers))]
1452
1672
  issues.push({
1453
1673
  id,
1454
- type: 'warning',
1674
+ type: 'warn',
1455
1675
  foundation: f.name,
1456
1676
  message: `${f.name} names ${names.join(', ')}, which kit's prose layer does not have`
1457
1677
  })
@@ -1616,6 +1836,14 @@ async function runDoctor(args = []) {
1616
1836
  }
1617
1837
 
1618
1838
  await checkSiteAddress({ sitePath, siteName, siteYml, workspaceDir, issues })
1839
+ // What a link to the site shows, and its tab icon.
1840
+ await checkLinkPreviews({ sitePath, siteName, issues })
1841
+ await checkFavicon({ sitePath, siteName, siteYml, issues, shouldFix, fixed })
1842
+
1843
+ // Forms need a destination, and having none is only visible on the page.
1844
+ await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
1845
+ checkAgentsBlock({ siteName, siteYml, issues })
1846
+ checkTrackingBlock({ siteName, siteYml, issues })
1619
1847
 
1620
1848
  // `public/<DATA_DIR>/` is the build's output directory and nothing else —
1621
1849
  // `records/` is the only supported way to provide structured data. That makes the mapping a bijection: every entry there should be backed
@@ -1625,11 +1853,6 @@ async function runDoctor(args = []) {
1625
1853
  // than dist/, so what lands there persists and gets deployed. A collection
1626
1854
  // removed from site.yml leaves its compiled records behind — visible to
1627
1855
  // anyone who knows the URL, listed by nothing.
1628
- // Forms need a destination, and having none is only visible on the page.
1629
- await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
1630
- checkAgentsBlock({ siteName, siteYml, issues })
1631
- checkTrackingBlock({ siteName, siteYml, issues })
1632
-
1633
1856
  checkGeneratedDataDir({
1634
1857
  sitePath,
1635
1858
  siteName,
@@ -2233,10 +2456,16 @@ async function runDoctor(args = []) {
2233
2456
  const errors = issues.filter((i) => i.type === 'error' && !i.fixed)
2234
2457
  const warnings = issues.filter((i) => i.type === 'warn' && !i.fixed)
2235
2458
  const fixedCount = issues.filter((i) => i.fixed).length
2459
+ // Recommendations (→) fail nothing, but the summary should not read as though there were none.
2460
+ const recommendations = issues.filter((i) => i.type === 'info' && !i.fixed).length
2461
+ const recommended = recommendations
2462
+ ? `${colors.dim}${recommendations} recommendation${recommendations === 1 ? '' : 's'} above (→)${colors.reset}`
2463
+ : null
2236
2464
 
2237
2465
  if (errors.length === 0 && warnings.length === 0 && fixedCount === 0) {
2238
2466
  log('')
2239
2467
  log(`${colors.green}${colors.bright}All checks passed!${colors.reset}`)
2468
+ if (recommended) log(recommended)
2240
2469
  log('')
2241
2470
  } else {
2242
2471
  log('')
@@ -2256,6 +2485,7 @@ async function runDoctor(args = []) {
2256
2485
  `${colors.dim}Some issues were not auto-fixable. See the diagnostics above.${colors.reset}`
2257
2486
  )
2258
2487
  }
2488
+ if (recommended) log(recommended)
2259
2489
  log('')
2260
2490
  }
2261
2491
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-27T21:45:43.672Z",
3
+ "generatedAt": "2026-10-27T23:05:52.933Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.11"
9
+ "version": "1.10.12"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
@@ -15,7 +15,7 @@
15
15
  "version": "1.3.1"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.4.11"
18
+ "version": "1.4.12"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
@@ -36,7 +36,7 @@
36
36
  "version": "1.2.6"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.2.11"
39
+ "version": "1.2.12"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
@@ -54,7 +54,7 @@
54
54
  "version": "1.1.1"
55
55
  },
56
56
  "@uniweb/templates": {
57
- "version": "1.1.5"
57
+ "version": "1.1.6"
58
58
  },
59
59
  "@uniweb/theming": {
60
60
  "version": "1.2.1"
@@ -3,6 +3,9 @@
3
3
 
4
4
  name: {{projectName}}
5
5
 
6
+ # The tab icon, a file in public/. Every page links it, wherever the site is served.
7
+ favicon: /favicon.svg
8
+
6
9
  {{#if foundationRef}}
7
10
  foundation: {{{foundationRefYaml}}}
8
11