uniweb 1.21.16 → 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.
|
|
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,13 +41,13 @@
|
|
|
41
41
|
"js-yaml": "^4.3.2",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.5.21",
|
|
44
|
-
"@uniweb/schemas": "~1.6.5",
|
|
45
|
-
"@uniweb/semantic-parser": "~1.13.0",
|
|
46
44
|
"@uniweb/content-writer": "~1.3.1",
|
|
47
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/semantic-parser": "~1.13.0",
|
|
46
|
+
"@uniweb/core": "~1.4.12",
|
|
47
|
+
"@uniweb/schemas": "~1.6.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.10.
|
|
50
|
+
"@uniweb/build": "~1.10.12",
|
|
51
51
|
"@uniweb/content-reader": "~1.5.7",
|
|
52
52
|
"@uniweb/semantic-parser": "~1.13.0"
|
|
53
53
|
},
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
57
|
+
"version": "1.1.6"
|
|
58
58
|
},
|
|
59
59
|
"@uniweb/theming": {
|
|
60
60
|
"version": "1.2.1"
|