@homie-rocks/studio 0.26.1 → 0.28.0

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/lib/media.mjs CHANGED
@@ -14,6 +14,7 @@ import { spawnSync } from 'node:child_process';
14
14
  import { createHash } from 'node:crypto';
15
15
  import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
17
+ import { isoDate } from './site.mjs';
17
18
 
18
19
  export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
19
20
  export const KINDS = {
@@ -177,12 +178,18 @@ export function resolveMedia(root, kind, { r2 = false, deploy = false } = {}) {
177
178
  }
178
179
  const main = kind === 'music' ? 'audio' : 'video';
179
180
  if (!files.some((f) => f.role === main)) { skipped.push({ kind, item: item.slug, why: `no playable "${main}" file reachable (see the file lines above)` }); continue; }
181
+ if (kind === 'videos' && !isoDate(item.date) && !isoDate(item.made?.at)) notes.push({ kind, item: item.slug, file: 'date', why: 'no "date" (or made.at): its page names no upload date, so search engines will not show it as a video; add "date": "2026-09-30"' });
180
182
  entries.push({
181
183
  slug: item.slug, kind: KINDS[kind].includes(item.kind) ? item.kind : KINDS[kind][0], title: String(item.title).slice(0, 120), blurb: String(item.blurb ?? '').slice(0, 600),
182
184
  duration: Number.isFinite(item.duration) ? item.duration : null, bpm: Number.isFinite(item.bpm) ? item.bpm : null, key: item.key ?? null,
183
185
  lyrics: typeof item.lyrics === 'string' ? item.lyrics.slice(0, 8000) : null, credits: typeof item.credits === 'string' ? item.credits.slice(0, 600) : null,
184
186
  rights: item.rights && typeof item.rights === 'object' ? item.rights : null, for: item.for && typeof item.for === 'object' ? item.for : null,
185
187
  honesty: typeof item.honesty === 'string' ? item.honesty.slice(0, 600) : null, made: item.made?.at ? { at: item.made.at, provider: item.made.provider ?? null } : null,
188
+ // When it came out (0.27.0): `date` (a day, or a day and a time), else when it was made (`made.at`). A video's
189
+ // VideoObject needs it as its uploadDate; a song's MusicRecording says it as datePublished.
190
+ date: isoDate(item.date) ?? isoDate(item.made?.at) ?? null,
191
+ // A music manifest's `album` (or an entry's own): the songs' MusicAlbum.
192
+ ...(kind === 'music' && typeof (item.album ?? manifest.album) === 'string' && (item.album ?? manifest.album).trim() ? { album: String(item.album ?? manifest.album).trim().slice(0, 120) } : {}),
186
193
  files,
187
194
  });
188
195
  }
package/lib/redact.mjs ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * SECRETS OUT OF TOOL OUTPUT. The Homie mod runs every tool result through `redact` before Claude reads it or the
3
+ * transcript keeps it. It only matches text: it never opens a key file, the keychain or the environment.
4
+ *
5
+ * What it hides: the studio's own one-time and owner keys (office and stats keys `hsk_`, progress write keys `hbk_`,
6
+ * agent passes `hap_…`, whose public id part stays), Cloudflare API tokens and global keys, provider keys (fal,
7
+ * ElevenLabs, Anthropic, OpenAI, GitHub, npm, Stripe keys and webhook secrets, AWS, Google), bearer tokens, private key blocks, and the value
8
+ * of any `NAME=value` whose name says key, token or secret.
9
+ *
10
+ * A link that carries one (the owner's one-time sign-in link, a confirm link, a player-owner link) is a link for the
11
+ * person, not for Claude: it is taken out whole and handed back in `links`, so the mod can show it to the person in
12
+ * its pane, and Claude reads that it is there.
13
+ */
14
+
15
+ const HIDE = (kind) => `[${kind} hidden by Homie]`;
16
+
17
+ /** Each rule: what it is, the pattern, and how to write what replaces a match (the default hides the whole match). */
18
+ export const RULES = [
19
+ { kind: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
20
+ { kind: 'office key', re: /\bhsk_[a-f0-9]{48}\b/g },
21
+ { kind: 'progress key', re: /\bhbk_[a-f0-9]{48}\b/g },
22
+ // An agent pass: its id (hap_ and ten hex digits) is public and stays; the secret after it goes.
23
+ { kind: 'agent pass', re: /\b(hap_[a-f0-9]{10})_[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/g, to: (m, id) => `${id}_${HIDE('agent pass')}` },
24
+ { kind: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
25
+ { kind: 'OpenAI key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/g },
26
+ { kind: 'ElevenLabs key', re: /\bsk_[a-f0-9]{48}\b/g },
27
+ { kind: 'Stripe key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
28
+ // A webhook signing secret has no live/test part: whsec_ and the secret itself.
29
+ { kind: 'Stripe webhook secret', re: /\bwhsec_[A-Za-z0-9+/=_-]{16,}/g },
30
+ { kind: 'fal key', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\b/g },
31
+ { kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g },
32
+ { kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
33
+ { kind: 'AWS key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
34
+ { kind: 'Google key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
35
+ { kind: 'Cloudflare key', re: /(X-Auth-Key["']?\s*[:=]\s*["']?)([a-f0-9]{37})\b/gi, to: (m, head) => `${head}${HIDE('Cloudflare key')}` },
36
+ { kind: 'token', re: /(\b[Bb]earer\s+)([A-Za-z0-9_\-.~+/]{24,}=*)/g, to: (m, head) => `${head}${HIDE('token')}` },
37
+ // NAME=value (or NAME: value) where the name says what it is: CLOUDFLARE_API_TOKEN, FAL_KEY, ELEVENLABS_API_KEY ...
38
+ // Only a value that looks like a key (16+ characters of letters AND digits, no dots or brackets), so source code
39
+ // that names a key (`const FAL_KEY = process.env.FAL_KEY`) is never changed under Claude's eyes.
40
+ {
41
+ kind: 'secret',
42
+ re: /\b([A-Z][A-Z0-9_]*(?:API_KEY|API_TOKEN|_TOKEN|_SECRET|SECRET_KEY|_KEY|PASSWORD)\b["']?\s*[=:]\s*["']?)([A-Za-z0-9_\-+/=:]{16,})(?![A-Za-z0-9_.(])/g,
43
+ to: (m, head, value) => (/[A-Za-z]/.test(value) && /[0-9]/.test(value) ? `${head}${HIDE('secret')}` : m),
44
+ },
45
+ ];
46
+
47
+ const URL_RE = /https?:\/\/[^\s"'<>`)\]]+/g;
48
+ const LINK_SECRET = /\b(?:hsk|hbk)_[a-f0-9]{48}\b|\bhap_[a-f0-9]{10}_[A-Za-z0-9_-]{40}/;
49
+ export const LINK_NOTE = '[one-time owner link: the Homie mod showed it to the person in its Studio pane; Claude never sees it]';
50
+
51
+ /** Redact one string: { text, hits: [kinds], links: [urls] }. Unchanged text keeps its identity. */
52
+ export function redactText(text) {
53
+ if (typeof text !== 'string' || text.length < 8) return { text, hits: [], links: [] };
54
+ const hits = [];
55
+ const links = [];
56
+ let out = text.replace(URL_RE, (url) => {
57
+ if (!LINK_SECRET.test(url)) return url;
58
+ links.push(url.replace(/[.,;:]+$/, ''));
59
+ hits.push('owner link');
60
+ return LINK_NOTE + url.slice(url.replace(/[.,;:]+$/, '').length);
61
+ });
62
+ for (const rule of RULES) {
63
+ rule.re.lastIndex = 0;
64
+ out = out.replace(rule.re, (...m) => {
65
+ const replaced = rule.to ? rule.to(...m) : HIDE(rule.kind);
66
+ if (replaced !== m[0]) hits.push(rule.kind);
67
+ return replaced;
68
+ });
69
+ }
70
+ return hits.length ? { text: out, hits, links } : { text, hits, links };
71
+ }
72
+
73
+ /**
74
+ * Redact every string inside a value (a tool's result record, an MCP result, a string), at most 12 levels deep.
75
+ * Returns { value, hits, links }; `value` is the same object when nothing was hidden.
76
+ */
77
+ export function redact(value) {
78
+ const hits = [];
79
+ const links = [];
80
+ const walk = (v, depth) => {
81
+ if (typeof v === 'string') {
82
+ const r = redactText(v);
83
+ if (r.hits.length) { hits.push(...r.hits); links.push(...r.links); }
84
+ return r.text;
85
+ }
86
+ if (depth > 12 || v === null || typeof v !== 'object') return v;
87
+ if (Array.isArray(v)) {
88
+ let changed = false;
89
+ const next = v.map((x) => { const y = walk(x, depth + 1); if (y !== x) changed = true; return y; });
90
+ return changed ? next : v;
91
+ }
92
+ let changed = false;
93
+ const next = {};
94
+ for (const [k, x] of Object.entries(v)) { const y = walk(x, depth + 1); if (y !== x) changed = true; next[k] = y; }
95
+ return changed ? next : v;
96
+ };
97
+ const out = walk(value, 0);
98
+ return { value: out, hits: [...new Set(hits)], links: [...new Set(links)] };
99
+ }
package/lib/scaffold.mjs CHANGED
@@ -210,6 +210,9 @@ studio's pinned copy, never a registry lookup of the bare name.
210
210
  - **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
211
211
  a cover of its own shows the music manifest's \`cover\`, else its game's still.
212
212
  - **Live rooms** are listed on homie.rocks too; studio.json \`"rooms": { "share": false }\` keeps them off it.
213
+ - **Search engines and AI agents** read every page's schema.org data (a full VideoGame on each landing) and
214
+ \`/robots.txt\`, \`/sitemap.xml\`, \`/llms.txt\`, made from what is public. Give a game game.json \`"genre"\`, put
215
+ pictures of play in \`games/<id>/screenshots/\`, give each video a \`"date"\`, and never invent ratings.
213
216
  - Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
214
217
 
215
218
  ## Making games
@@ -424,6 +427,18 @@ chat's card follows the work from then on) and, for a studio still being set up,
424
427
  the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
425
428
  you meant to do and why; do not retry it unless the person asks.
426
429
 
430
+ ## Telling Homie
431
+
432
+ The people who make Homie read every note, and someone who is stuck rarely writes to a forum. So when the person is
433
+ stuck, confused or frustrated, after an error you could not fix, or at the end of their first studio setup or first
434
+ publish, you may OFFER, once in a session, to send them a short note about it: the Homie MCP's \`homie_feedback\`
435
+ (in Claude Code also \`/feedback\`, and Tell Homie in the Studio pane). Draft it in plain words from what happened
436
+ (what they tried, what they expected, what they saw); a draft sends nothing. Show it exactly as it would go, and send
437
+ it only after they say yes (in Claude Code, Claude Code itself asks them with the exact note). Never nag: a no is
438
+ final for the session, and help never depends on it. Never put a key, a log, a file, code or anyone's name in a
439
+ note; a reply address only if they typed it. When they ask to tell Homie something, draft it with
440
+ \`offered: false\`.
441
+
427
442
  ## Beta
428
443
 
429
444
  Homie for studios is in beta. When something breaks, or a game you want to port does
@@ -533,8 +548,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
533
548
  | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
534
549
  | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
535
550
  | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
536
- | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`. |
537
- | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). |
551
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). |
552
+ | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). A \`robots.txt\`, \`sitemap.xml\` or \`llms.txt\` here replaces the one the site makes. |
538
553
 
539
554
  \`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
540
555
  `;
@@ -0,0 +1,227 @@
1
+ /**
2
+ * THE STRUCTURED-DATA CHECK (0.27.0): every `<script type="application/ld+json">` block of a page, read the way a
3
+ * search engine reads it, and held to two bars.
4
+ *
5
+ * errors the block is not valid JSON, has no schema.org @context, names a type or a property schema.org does
6
+ * not have (schema-vocab.json, written from schema.org's own release file by scripts/schema-vocab.mjs),
7
+ * puts a property on a type it is not for, or gives a value of the wrong kind (a date that is not ISO
8
+ * 8601, a duration that is not PT…, a type the property does not take, an enumeration member that is not
9
+ * one of its enumeration's).
10
+ * google what Google Search Central documents as required for the result each type can get: VideoGame (as a
11
+ * Software App: name and offers.price, co-typed with another app type), VideoObject (name, thumbnailUrl,
12
+ * uploadDate), BreadcrumbList (each ListItem's position and name, and its item except the last's),
13
+ * ItemList (each ListItem's position and url), and the fields Homie holds itself to where Google requires
14
+ * none (BlogPosting and every other Article: headline, datePublished, author.name; Organization: name, url;
15
+ * MusicRecording: name, url, byArtist).
16
+ * notes what is true and not a fault: a Software App is shown as a rich result only with ratings or reviews,
17
+ * which Homie never invents.
18
+ *
19
+ * import { checkHtml, checkJsonLd } from '@homie-rocks/studio/lib/schema-check.mjs' (or a relative path)
20
+ * checkHtml(html) -> { blocks, errors, google, notes }
21
+ */
22
+ import { readFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ let vocabulary = null;
27
+ /** schema.org's vocabulary, as scripts/schema-vocab.mjs wrote it beside this file. */
28
+ export function vocab() {
29
+ vocabulary ??= JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'schema-vocab.json'), 'utf8'));
30
+ return vocabulary;
31
+ }
32
+
33
+ const DATATYPES = new Set(['Text', 'URL', 'Number', 'Integer', 'Float', 'Boolean', 'Date', 'DateTime', 'Time', 'CssSelectorType', 'XPathType', 'PronounceableText', 'Duration', 'DataType']);
34
+ const ISO_DATE = /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/;
35
+ const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/;
36
+ const ISO_DURATION = /^P(?!$)(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?=\d)(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
37
+ const ABS_URL = /^https?:\/\/[^\s<>"]+$/i;
38
+ const SCHEMA = /^https?:\/\/schema\.org\/?$/;
39
+
40
+ /** The JSON-LD blocks of a page: [{ text, value }] (value undefined when the block is not JSON). */
41
+ export function jsonLdBlocks(html) {
42
+ const out = [];
43
+ const re = /<script\b[^>]*\btype\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi;
44
+ for (let m = re.exec(String(html ?? '')); m; m = re.exec(String(html ?? ''))) {
45
+ let value;
46
+ try { value = JSON.parse(m[1]); } catch { value = undefined; }
47
+ out.push({ text: m[1], value });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const typesOf = (node) => (Array.isArray(node['@type']) ? node['@type'] : node['@type'] ? [node['@type']] : []).map((t) => String(t).replace(/^https?:\/\/schema\.org\//, '').replace(/^schema:/, ''));
53
+
54
+ /** A type and every type above it. */
55
+ function lineage(type, v = vocab()) {
56
+ const seen = new Set();
57
+ const walk = (t) => { if (seen.has(t)) return; seen.add(t); for (const p of v.types[t] ?? []) walk(p); };
58
+ walk(type);
59
+ return seen;
60
+ }
61
+ const isA = (type, want, v) => lineage(type, v).has(want);
62
+
63
+ function checkValue(prop, value, ranges, at, errors, v) {
64
+ if (value === null || value === undefined) { errors.push(`${at}: ${prop} is empty`); return; }
65
+ const allowsText = ranges.some((r) => r === 'Text' || r === 'PronounceableText' || r === 'CssSelectorType' || r === 'XPathType');
66
+ const numeric = ranges.some((r) => r === 'Number' || r === 'Integer' || r === 'Float');
67
+ const onlyData = ranges.every((r) => DATATYPES.has(r));
68
+ const want = ranges.join(' or ');
69
+ if (typeof value === 'object') {
70
+ const types = typesOf(value);
71
+ if (!types.length) {
72
+ if (!value['@id']) errors.push(`${at}: ${prop} is an object with no @type`);
73
+ return;
74
+ }
75
+ if (onlyData) { errors.push(`${at}: ${prop} takes ${want}, not a ${types.join('/')}`); return; }
76
+ if (!types.some((t) => ranges.some((r) => r === 'Thing' || isA(t, r, v)))) errors.push(`${at}: ${prop} takes ${want}, not a ${types.join('/')}`);
77
+ return;
78
+ }
79
+ if (typeof value === 'boolean') {
80
+ if (!ranges.includes('Boolean')) errors.push(`${at}: ${prop} takes ${want}, not true or false`);
81
+ return;
82
+ }
83
+ if (typeof value === 'number') {
84
+ if (!numeric && !ranges.includes('QuantitativeValue')) errors.push(`${at}: ${prop} takes ${want}, not a number`);
85
+ else if (ranges.includes('Integer') && !ranges.includes('Number') && !Number.isInteger(value)) errors.push(`${at}: ${prop} is a whole number`);
86
+ return;
87
+ }
88
+ const s = String(value);
89
+ // An enumeration's member, by its schema.org address.
90
+ const enums = ranges.filter((r) => v.types[r] && isA(r, 'Enumeration', v));
91
+ const member = /^https?:\/\/schema\.org\/([A-Za-z0-9]+)$/.exec(s)?.[1];
92
+ if (enums.length && member && v.members[member]) {
93
+ if (!v.members[member].some((e) => enums.some((r) => isA(e, r, v)))) errors.push(`${at}: ${prop} "${s}" is not one of ${enums.join(' or ')}`);
94
+ return;
95
+ }
96
+ if (ranges.includes('DateTime') || ranges.includes('Date')) {
97
+ if (ISO_DATE.test(s) || ISO_DATETIME.test(s)) return;
98
+ if (!allowsText) { errors.push(`${at}: ${prop} "${s.slice(0, 40)}" is not an ISO 8601 date`); return; }
99
+ }
100
+ if (ranges.includes('Duration')) {
101
+ if (ISO_DURATION.test(s)) return;
102
+ if (!allowsText) { errors.push(`${at}: ${prop} "${s.slice(0, 40)}" is not an ISO 8601 duration (PT1M5S)`); return; }
103
+ }
104
+ if (numeric && /^-?\d+(?:\.\d+)?$/.test(s)) return;
105
+ if (allowsText) return;
106
+ // A URL, or a thing given by its address.
107
+ if (ABS_URL.test(s)) return;
108
+ if (enums.length) { errors.push(`${at}: ${prop} "${s.slice(0, 60)}" is not a member of ${enums.join(' or ')} (https://schema.org/<Member>)`); return; }
109
+ errors.push(`${at}: ${prop} takes ${want}, not "${s.slice(0, 60)}"`);
110
+ }
111
+
112
+ /** Every node, depth first, with where it is: [{ node, at, depth, via }]. */
113
+ function nodesOf(value, at = '$', depth = 0, via = null, out = []) {
114
+ if (Array.isArray(value)) { value.forEach((x, i) => nodesOf(x, `${at}[${i}]`, depth, via, out)); return out; }
115
+ if (!value || typeof value !== 'object') return out;
116
+ out.push({ node: value, at, depth, via });
117
+ for (const [k, x] of Object.entries(value)) {
118
+ if (k === '@context') continue;
119
+ if (k === '@graph') { nodesOf(x, `${at}.@graph`, depth, null, out); continue; }
120
+ if (x && typeof x === 'object') nodesOf(x, `${at}.${k}`, depth + 1, k, out);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** One block's value: { errors, google, notes }. */
126
+ export function checkJsonLd(value) {
127
+ const v = vocab();
128
+ const errors = [];
129
+ const google = [];
130
+ const notes = [];
131
+ const tops = Array.isArray(value) ? value : [value];
132
+ for (const top of tops) {
133
+ if (!top || typeof top !== 'object') { errors.push('$: a block is a JSON object (or a list of them)'); continue; }
134
+ if (!SCHEMA.test(String(top['@context'] ?? ''))) errors.push(`$: @context is "https://schema.org" (it is ${JSON.stringify(top['@context'] ?? null)})`);
135
+ }
136
+ for (const { node, at, depth, via } of tops.flatMap((t) => nodesOf(t))) {
137
+ const types = typesOf(node);
138
+ if (node['@id'] !== undefined && !(typeof node['@id'] === 'string' && (ABS_URL.test(node['@id']) || node['@id'].startsWith('#')))) errors.push(`${at}: @id is an absolute URL`);
139
+ const keys = Object.keys(node).filter((k) => !k.startsWith('@'));
140
+ if (!types.length) {
141
+ if (keys.length && !node['@graph'] && !node['@id']) errors.push(`${at}: an object with properties has a @type`);
142
+ if (!keys.length) continue;
143
+ }
144
+ for (const t of types) if (!v.types[t]) errors.push(`${at}: "${t}" is not a schema.org type`);
145
+ const known = types.filter((t) => v.types[t]);
146
+ const lines = new Set(known.flatMap((t) => [...lineage(t, v)]));
147
+ for (const k of keys) {
148
+ const p = v.props[k];
149
+ if (!p) { errors.push(`${at}: "${k}" is not a schema.org property`); continue; }
150
+ const [domains, ranges, superseded] = p;
151
+ if (known.length && !domains.some((d) => lines.has(d))) errors.push(`${at}: "${k}" is not a property of ${known.join('/')} (it is for ${domains.join(', ')})`);
152
+ if (superseded) errors.push(`${at}: "${k}" is superseded by "${superseded}"`);
153
+ for (const x of Array.isArray(node[k]) ? node[k] : [node[k]]) checkValue(k, x, ranges, at, errors, v);
154
+ }
155
+ // Google's bar: what the result each type can get requires. Top-level nodes, and every video.
156
+ const top = depth === 0;
157
+ const has = (k) => node[k] !== undefined && node[k] !== null && node[k] !== '' && !(Array.isArray(node[k]) && !node[k].length);
158
+ if (top && known.some((t) => isA(t, 'SoftwareApplication', v))) {
159
+ if (!has('name')) google.push(`${at}: a ${known.join('/')} has a name`);
160
+ const offers = Array.isArray(node.offers) ? node.offers : node.offers ? [node.offers] : [];
161
+ if (!offers.length || offers.some((o) => o.price === undefined)) google.push(`${at}: a ${known.join('/')} has offers.price (0 when it is free)`);
162
+ if (known.includes('VideoGame') && known.length < 2) google.push(`${at}: Google shows no Software App result for a bare VideoGame; co-type it (["VideoGame", "WebApplication"])`);
163
+ if (!has('aggregateRating') && !has('review')) notes.push(`${at}: a Software App is shown as a rich result only with aggregateRating or review; none is given (Homie never invents one)`);
164
+ }
165
+ if (known.some((t) => isA(t, 'VideoObject', v)) && (top || via === 'trailer' || via === 'video')) {
166
+ for (const k of ['name', 'thumbnailUrl', 'uploadDate']) if (!has(k)) google.push(`${at}: a VideoObject has ${k}`);
167
+ if (has('uploadDate') && /T\d/.test(node.uploadDate) && !/(?:Z|[+-]\d{2}:?\d{2})$/.test(node.uploadDate)) google.push(`${at}: uploadDate has a time zone`);
168
+ }
169
+ if (top && known.some((t) => isA(t, 'BreadcrumbList', v))) {
170
+ const items = node.itemListElement ?? [];
171
+ if (!items.length) google.push(`${at}: a BreadcrumbList has itemListElement`);
172
+ items.forEach((li, i) => {
173
+ if (li?.position !== i + 1) google.push(`${at}.itemListElement[${i}]: position is ${i + 1}`);
174
+ if (!li?.name) google.push(`${at}.itemListElement[${i}]: a breadcrumb has a name`);
175
+ if (i < items.length - 1 && !li?.item) google.push(`${at}.itemListElement[${i}]: a breadcrumb before the last has an item`);
176
+ });
177
+ }
178
+ if (top && known.some((t) => isA(t, 'ItemList', v) && !isA(t, 'BreadcrumbList', v))) {
179
+ (node.itemListElement ?? []).forEach((li, i) => {
180
+ if (li?.position !== i + 1) google.push(`${at}.itemListElement[${i}]: position is ${i + 1}`);
181
+ if (!li?.url && !li?.item) google.push(`${at}.itemListElement[${i}]: a list item has a url`);
182
+ });
183
+ }
184
+ if (top && known.some((t) => isA(t, 'Article', v))) {
185
+ if (!has('headline')) google.push(`${at}: an article has a headline`);
186
+ if (has('headline') && String(node.headline).length > 110) google.push(`${at}: a headline is at most 110 characters`);
187
+ if (!has('datePublished')) google.push(`${at}: an article has datePublished`);
188
+ const authors = Array.isArray(node.author) ? node.author : node.author ? [node.author] : [];
189
+ if (!authors.length || authors.some((a) => !a?.name)) google.push(`${at}: an article's author has a name`);
190
+ }
191
+ if (top && known.some((t) => t === 'Organization' || isA(t, 'Organization', v)) && !known.some((t) => isA(t, 'CreativeWork', v))) {
192
+ if (!has('name')) google.push(`${at}: an Organization has a name`);
193
+ if (!has('url')) google.push(`${at}: an Organization has a url`);
194
+ }
195
+ if (top && known.some((t) => isA(t, 'MusicRecording', v))) {
196
+ for (const k of ['name', 'url', 'byArtist']) if (!has(k)) google.push(`${at}: a MusicRecording has ${k}`);
197
+ }
198
+ }
199
+ return { errors, google, notes };
200
+ }
201
+
202
+ /** Every JSON-LD block of a page, checked. */
203
+ export function checkHtml(html) {
204
+ const blocks = jsonLdBlocks(html);
205
+ const errors = [];
206
+ const google = [];
207
+ const notes = [];
208
+ blocks.forEach((b, i) => {
209
+ if (b.value === undefined) { errors.push(`block ${i + 1}: not valid JSON`); return; }
210
+ const r = checkJsonLd(b.value);
211
+ errors.push(...r.errors.map((e) => `block ${i + 1} ${e}`));
212
+ google.push(...r.google.map((e) => `block ${i + 1} ${e}`));
213
+ notes.push(...r.notes.map((e) => `block ${i + 1} ${e}`));
214
+ });
215
+ return { blocks: blocks.map((b) => b.value), errors, google, notes };
216
+ }
217
+
218
+ /** The types a page's blocks declare, top-level graph nodes only, in order: ["Organization", "WebSite"]. */
219
+ export function typesOnPage(html) {
220
+ const out = [];
221
+ for (const b of jsonLdBlocks(html)) {
222
+ const v = b.value;
223
+ const tops = Array.isArray(v) ? v : v?.['@graph'] ?? (v ? [v] : []);
224
+ for (const n of tops) out.push(...typesOf(n));
225
+ }
226
+ return out;
227
+ }