@homie-rocks/studio 0.26.1 → 0.27.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/CHANGELOG.md CHANGED
@@ -17,9 +17,43 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.27.0] - 2026-10-03
21
+
22
+ **Plugin 0.28.0** · [#42](https://github.com/homie-rocks/homie/pull/42)
23
+
24
+ Search engines and AI agents read a studio's site correctly: schema.org data on every page, a full VideoGame on every landing, and a sitemap, robots.txt and llms.txt made from what is public.
25
+
26
+ ### Added
27
+
28
+ - Structured data (schema.org JSON-LD) on every generated page, from the studio's own files and nothing else:
29
+ - Home: the studio as an `Organization` (its name, address, logo, share picture and tagline) and its `WebSite`.
30
+ - Each game's landing: a `VideoGame` (co-typed `WebApplication`, as Google asks) with its description and pitch, pictures and screenshots, genre, players (fewest and most), play modes, platforms, the studio as author and publisher, the source's licence (linked to its SPDX page when it names one), what it is based on (a remix's original page, a port's original), its trailer as a `VideoObject`, when it came out and last changed, a Play action and a free-to-play offer.
31
+ - Music: `MusicRecording` (the studio as the artist, its length, its audio file, its cover, its key and date) and a `MusicAlbum` when the music manifest names one. Videos: `VideoObject` (poster, upload date, length, the file itself). Posts: `BlogPosting` and the studio's `Blog`. Games and Rooms: an `ItemList` of the public games. Every page but Home: a `BreadcrumbList`.
32
+ - Never a rating or a review, and no price for anything the shop does not sell. While your shop really sells, each item sold in a game is an add-on of that game's free offer, in real money. The Rooms page's data names the games, never who is playing this minute.
33
+ - `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt`, made from your public catalogue:
34
+ - robots.txt lets crawlers read every public page, keeps them off your office, the APIs, accounts and each game's play, TV, watch and socket doors, and names the sitemap. A Preview asks not to be crawled.
35
+ - The sitemap lists Home, Games and each landing, Music, Videos, Rooms, Posts and your own pages, with dates only where a real date says when each changed.
36
+ - llms.txt tells an AI agent what your studio is: each game with its pitch, players, Play, Watch and big-screen links; which games are open to remix, with the licence, the source and the words to say to Claude Code or Codex with Homie; songs, videos, posts with their feeds, your own pages, and homie.rocks's llms.txt. llms-full.txt adds each game's whole description, how to play and credits, and every post's text.
37
+ - A private or invite-only game is in none of them, and a game is offered for remixing only when its source is shared, your Remixable switch is on and its licence allows it.
38
+ - game.json takes `"genre"` (a word, or up to three), `"released"` (the day it came out) and `"schema"` (schema.org properties of your own on the game). studio.json takes `"site": { "schema": { … } }` for the studio, such as `"sameAs"` links to its other pages. Homie's own properties always win, and ratings, reviews and offers are refused (the build says so).
39
+ - Screenshots: pictures in `games/<id>/screenshots/` (or game.json `landing.screenshots`), at most eight, get a band on the landing and go into its VideoGame. A game's genre shows with its room facts.
40
+ - A video's or song's `"date"` in its manifest; without one, when it was made (`made.at`). Search engines show no video without an upload date, so the build says when a video has neither.
41
+ - `<!-- homie:schema -->` in the `<head>` of a page of your own (`site/pages`) puts there the structured data the generated page would carry.
42
+ - `lib/schema-check.mjs` checks every JSON-LD block of a page against schema.org's own vocabulary (types, properties, which type each property is for, value kinds, enumerations) and against what Google documents as required for VideoGame, VideoObject, BreadcrumbList, ItemList, BlogPosting, Organization and MusicRecording.
43
+
44
+ ### Changed
45
+
46
+ - A landing's VideoGame was a short block with a price of 0 and no co-type; it is now the full record above, inside one `@graph` with the page's breadcrumbs.
47
+ - A private or invite-only game's landing, which only its owner and invitees see, answers `x-robots-tag: noindex`.
48
+
49
+ ### Upgrade notes
50
+
51
+ - Nothing to do: `homie-studio upgrade` and a deploy bring all of it. Give each game a `"genre"`, put a few pictures of real play in `games/<id>/screenshots/`, give each video a `"date"` if it has no `made.at`, and add your studio's other pages to `studio.json` `"site": { "schema": { "sameAs": [...] } }`.
52
+ - A `robots.txt`, `sitemap.xml`, `llms.txt` or `llms-full.txt` you already keep in `site/public/` still wins over the made one. A landing or Home page of your own in `site/pages/` is left exactly as it is; add `<!-- homie:schema -->` to its `<head>` to give it the structured data.
53
+
20
54
  ## [0.26.1] - 2026-10-03
21
55
 
22
- **Plugin 0.27.1** · [#41](https://github.com/homie-rocks/homie/pull/41)
56
+ **Plugin 0.27.1** · [#41](https://github.com/homie-rocks/homie/pull/41) · [release-2026-10-03-studio-0.26.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.26.1)
23
57
 
24
58
  In Codex, Homie now holds what the Claude Code mod holds: protected files, deploys, Cloudflare changes, spending past the budget and model downloads wait for your own "proceed", and secrets come out of what Codex reads.
25
59
 
package/README.md CHANGED
@@ -107,6 +107,14 @@ in that palette, light or dark as its paper is (0.26.0). Every HTML answer is `n
107
107
  site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
108
108
  `not-a-homie`, at the site's root as under the game.
109
109
 
110
+ Search engines and AI agents read a studio correctly (0.27.0): every generated page carries schema.org JSON-LD
111
+ (`worker/schema.mjs`: the studio's `Organization` and `WebSite` on Home, a full `VideoGame` on each landing with its
112
+ players, platforms, licence, remix original, trailer, dates and a free-to-play offer whose add-ons are what the shop
113
+ really sells, `MusicRecording`, `VideoObject`, `BlogPosting`, `ItemList` and `BreadcrumbList`; never a rating), and the
114
+ site makes `/robots.txt`, `/sitemap.xml`, `/llms.txt` and `/llms-full.txt` from its public catalogue
115
+ (`worker/discover.mjs`). `lib/schema-check.mjs` checks every block against schema.org's own vocabulary and what Google
116
+ documents as required. game.json `"schema"` and studio.json `site.schema` add the owner's own properties.
117
+
110
118
  Anyone can **watch** a live room from any player's view at `/<game>/watch?room=<room>` (a Watch button sits beside
111
119
  Join on every room): the game itself, drawn by the watcher's own browser as a watcher that never takes a seat,
112
120
  with a strip of the players to switch between (a tap, keys 1-9, Auto, the whole room). A game draws the followed
package/lib/build.mjs CHANGED
@@ -40,7 +40,9 @@ import { join, relative } from 'node:path';
40
40
  import { buildCodexPages } from './codex.mjs';
41
41
  import { servedAssets } from './asset-manifest.mjs';
42
42
  import { buildMedia } from './media.mjs';
43
- import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
43
+ import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
44
+ import { checkJsonLd } from './schema-check.mjs';
45
+ import { SCHEMA_REFUSED } from '../worker/schema.mjs';
44
46
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
45
47
  import { SEAT_MAX } from '../worker/seats.mjs';
46
48
  import { STUDIO_VERSION } from './version.mjs';
@@ -152,6 +154,57 @@ function readJson(path) {
152
154
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
153
155
  }
154
156
 
157
+ /**
158
+ * When a game came and last changed (its landing's datePublished and dateModified, and the sitemap's lastmod):
159
+ * game.json `"released"` (a day) when the owner says so, else the commit that added its game.json; and the last
160
+ * commit that touched its folder. Nothing from a shallow clone (its first commit would claim every file was made that
161
+ * day) or a folder git does not track.
162
+ */
163
+ export function gameDates(root, g, log = () => {}) {
164
+ const git = (args) => { try { const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 }); return r.status === 0 ? r.stdout.trim() : null; } catch { return null; } };
165
+ const released = isoDate(g.released);
166
+ if (g.released !== undefined && !released) log(`warning: games/${g.id}/game.json "released" is a day (2026-09-30); left out`);
167
+ const shallow = git(['rev-parse', '--is-shallow-repository']);
168
+ if (shallow !== 'false') return released ? { published: released } : null;
169
+ const rel = relative(root, g.dir).split('\\').join('/');
170
+ const added = git(['log', '--diff-filter=A', '--format=%cI', '--', `${rel}/game.json`])?.split('\n').filter(Boolean).pop() ?? null;
171
+ const last = git(['log', '-1', '--format=%cI', '--', rel]) || null;
172
+ const iso = (v) => (v && Number.isFinite(Date.parse(v)) ? new Date(Date.parse(v)).toISOString() : null);
173
+ const out = { published: released ?? iso(added), modified: iso(last) };
174
+ if (out.published && out.modified && Date.parse(out.modified) < Date.parse(out.published)) out.modified = out.published;
175
+ return out.published || out.modified ? Object.fromEntries(Object.entries(out).filter(([, v]) => v)) : null;
176
+ }
177
+
178
+ /**
179
+ * The owner's own structured data (game.json "schema", studio.json "site": { "schema" }): a plain object of schema.org
180
+ * properties put on the game's VideoGame (the studio's Organization), under Homie's own. Ratings, reviews and offers
181
+ * are refused (Homie shows no ratings on the page; prices are shop.json's), and so is anything that is not JSON-LD a
182
+ * page can carry; a property schema.org does not have, or does not put on that type, is warned about.
183
+ */
184
+ export function schemaExtras(value, { where, types, log = () => {} }) {
185
+ if (value === undefined) return null;
186
+ if (!value || typeof value !== 'object' || Array.isArray(value)) { log(`warning: ${where} "schema" is an object of schema.org properties; left out`); return null; }
187
+ const out = {};
188
+ for (const [k, v] of Object.entries(value)) {
189
+ if (SCHEMA_REFUSED.includes(k)) { log(`warning: ${where} schema.${k} is left out: Homie publishes no ${k === 'offers' ? 'prices but the shop\'s (shop.json)' : 'ratings or reviews the page does not show'}`); continue; }
190
+ if (!/^[a-z][A-Za-z0-9]{0,63}$/.test(k)) { log(`warning: ${where} schema "${k.slice(0, 40)}" is not a property name (Homie sets @type and @id); left out`); continue; }
191
+ out[k] = v;
192
+ }
193
+ const json = JSON.stringify(out);
194
+ if (json.length > 8192) { log(`warning: ${where} "schema" is over 8 KB; left out`); return null; }
195
+ const { errors } = checkJsonLd({ '@context': 'https://schema.org', '@type': types, ...out });
196
+ for (const e of errors) log(`warning: ${where} schema: ${e.replace(/^\$: /, '')}`);
197
+ return Object.keys(out).length ? out : null;
198
+ }
199
+
200
+ /** game.json "genre": a word or up to three ("Arcade", ["Racing", "Party"]); its landing and VideoGame say it. */
201
+ export function genreOf(g, log = () => {}) {
202
+ if (g.genre === undefined) return null;
203
+ const list = (Array.isArray(g.genre) ? g.genre : [g.genre]).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim().slice(0, 40)).slice(0, 3);
204
+ if (!list.length) log(`warning: games/${g.id}/game.json "genre" is a word or a list of up to three; left out`);
205
+ return list.length ? list : null;
206
+ }
207
+
155
208
  /**
156
209
  * A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
157
210
  * built output (a ported Vite game ships public/netplay.json), over nothing.
@@ -357,6 +410,11 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
357
410
  // The game's own palette (style.json, its art direction): the play page's buttons wear its paper and ink, so the
358
411
  // page's pills and the game's own HUD are one UI.
359
412
  const ui = uiOf(g);
413
+ // Search engines and agents (0.27.0, worker/schema.mjs): its genre, when it came and last changed, and the owner's
414
+ // own schema.org properties.
415
+ const genre = genreOf(g, log);
416
+ const dates = gameDates(root, g, log);
417
+ const extras = schemaExtras(g.schema, { where: `games/${g.id}/game.json`, types: ['VideoGame', 'WebApplication'], log });
360
418
  return {
361
419
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
362
420
  ...(ui ? { ui } : {}),
@@ -384,6 +442,9 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
384
442
  // Its source licence (worker/license.mjs), and, for a remix, what it is a remix of (shown on its landing).
385
443
  license: licenseOf(g.license),
386
444
  ...(remixRow(g.remixOf) ? { remixOf: remixRow(g.remixOf) } : {}),
445
+ ...(genre ? { genre } : {}),
446
+ ...(dates ? { dates } : {}),
447
+ ...(extras ? { schema: extras } : {}),
387
448
  landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
388
449
  };
389
450
  });
@@ -394,11 +455,14 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
394
455
  mkdirSync(join(dist, '_site'), { recursive: true });
395
456
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
396
457
  const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
458
+ const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
397
459
  const catalogue = {
398
460
  studio: {
399
461
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
400
462
  ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
401
463
  theme,
464
+ // studio.json "site": { "schema": { … } }: the owner's own schema.org properties on the studio (sameAs, …).
465
+ ...(studioExtras ? { schema: studioExtras } : {}),
402
466
  site: {
403
467
  ...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
404
468
  ...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
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/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
@@ -533,8 +536,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
533
536
  | \`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
537
  | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
535
538
  | \`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\`). |
539
+ | \`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). |
540
+ | \`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
541
 
539
542
  \`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
543
  `;
@@ -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
+ }