@homie-rocks/studio 0.26.0 → 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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * STRUCTURED DATA (0.27.0): what each of a studio site's generated pages says about itself to search engines, as
3
+ * schema.org JSON-LD, one `<script type="application/ld+json">` with an `@graph` in the page's <head>. Only what the
4
+ * page shows and the studio's own files say: nothing is invented.
5
+ *
6
+ * Home Organization (the studio: name, url, logo, image, description, the owner's sameAs) and WebSite
7
+ * /games/, /rooms/ ItemList of the public games' landings (the Rooms list never carries a live count)
8
+ * /<game>/ VideoGame (co-typed WebApplication: it runs in a browser) with its pictures and screenshots,
9
+ * genre, players, play modes, platforms, the studio as author and publisher, the source's
10
+ * licence, isBasedOn (a remix's original, a port's original), a trailer (VideoObject), dates,
11
+ * and one free-to-play Offer whose addOn lists what the studio's shop really sells in it
12
+ * /music/, /music/<s>/ MusicAlbum (when the manifest names one) or ItemList; MusicRecording (byArtist: the studio)
13
+ * /videos/, /<v>/ ItemList; VideoObject (thumbnailUrl, uploadDate, duration, contentUrl)
14
+ * /posts/, /<post>/ Blog; BlogPosting (author, publisher, dates, image, the game it is about)
15
+ * every page but Home BreadcrumbList
16
+ *
17
+ * Never: a rating or a review (Homie shows none on the page), a price for anything the shop does not sell, or a
18
+ * live number that a cache would keep stale.
19
+ *
20
+ * The owner adds their own: game.json `"schema": { … }` (properties on the game's VideoGame) and studio.json
21
+ * `"site": { "schema": { … } }` (properties on the studio's Organization, such as `sameAs`). Homie's own properties
22
+ * win, and `aggregateRating`, `review` and `offers` are refused (lib/build.mjs warns). A page of the studio's own
23
+ * (site/pages) takes `<!-- homie:schema -->` where it wants these blocks.
24
+ */
25
+ import { licenseOf, LICENSE_KINDS, remixRow } from './license.mjs';
26
+
27
+ const CONTEXT = 'https://schema.org';
28
+
29
+ /** Properties an owner's extras may never set: ratings Homie cannot show, and prices that are not the shop's. */
30
+ export const SCHEMA_REFUSED = Object.freeze(['aggregateRating', 'review', 'reviews', 'offers']);
31
+
32
+ /** JSON in a <script> data block: `<` is escaped, so no value can end the block. */
33
+ export const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
34
+
35
+ /** The <script> block for a page's nodes (none: nothing). */
36
+ export function ldScript(nodes) {
37
+ const list = (nodes ?? []).filter(Boolean);
38
+ if (!list.length) return '';
39
+ return `<script type="application/ld+json">${jsonScript({ '@context': CONTEXT, '@graph': list })}</script>`;
40
+ }
41
+
42
+ /** An address on the site made absolute (a /path), an https:// one kept, anything else null. */
43
+ export const absUrl = (origin, u) => {
44
+ if (typeof u !== 'string' || !u) return null;
45
+ if (/^https:\/\//i.test(u)) return u;
46
+ if (u.startsWith('/') && !u.startsWith('//')) return `${origin}${u}`;
47
+ return null;
48
+ };
49
+
50
+ /** Seconds as an ISO 8601 duration: 75 is PT1M15S. */
51
+ export function isoDuration(seconds) {
52
+ const s = Number(seconds);
53
+ if (!Number.isFinite(s) || s <= 0) return null;
54
+ const whole = Math.round(s);
55
+ const h = Math.floor(whole / 3600);
56
+ const m = Math.floor((whole % 3600) / 60);
57
+ const r = whole % 60;
58
+ return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${r || (!h && !m) ? `${r}S` : ''}`;
59
+ }
60
+
61
+ /** An ISO date-time (or day) as it may go out: parsed and kept as it was, else null. */
62
+ const when = (v) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) && Number.isFinite(Date.parse(v)) ? v : null);
63
+ const uniq = (list) => [...new Set(list.filter(Boolean))];
64
+ const text = (v, n = 5000) => (typeof v === 'string' && v.trim() ? v.replace(/\s+/g, ' ').trim().slice(0, n) : null);
65
+ const studioName = (cat) => cat?.studio?.name ?? 'Studio';
66
+ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
67
+ const fileOf = (e, role) => (e?.files ?? []).find((f) => f.role === role) ?? null;
68
+
69
+ /**
70
+ * The owner's extra properties (game.json `schema`, studio.json `site.schema`), on top of Homie's: a key Homie set
71
+ * stays Homie's, a refused key and anything that is not a property name is left out.
72
+ */
73
+ export function withExtras(node, extras) {
74
+ if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return node;
75
+ const out = { ...node };
76
+ for (const [k, v] of Object.entries(extras)) {
77
+ if (!/^[a-z][A-Za-z0-9]{0,63}$/.test(k) || SCHEMA_REFUSED.includes(k) || Object.hasOwn(out, k) || v === null || v === undefined) continue;
78
+ out[k] = v;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /* ------------------------------------------------------------------ the studio and its site */
84
+
85
+ export const studioId = (origin) => `${origin}/#studio`;
86
+ export const siteId = (origin) => `${origin}/#website`;
87
+
88
+ /** The studio as an Organization: in full on Home, else a short reference with its name and address. */
89
+ export function studioNode(cat, origin, { full = false } = {}) {
90
+ const node = { '@type': 'Organization', '@id': studioId(origin), name: studioName(cat), url: `${origin}/` };
91
+ if (!full) return node;
92
+ const t = cat?.studio?.theme ?? {};
93
+ const logo = absUrl(origin, t.mark) ?? absUrl(origin, t.icon);
94
+ const image = absUrl(origin, t.social);
95
+ return withExtras({
96
+ ...node,
97
+ ...(logo ? { logo } : {}),
98
+ ...(image ? { image } : {}),
99
+ ...(text(cat?.studio?.tagline) ? { description: text(cat.studio.tagline) } : {}),
100
+ }, cat?.studio?.schema);
101
+ }
102
+
103
+ /** The site itself (Home): its name, address and publisher. There is no site search, so no SearchAction. */
104
+ export function websiteNode(cat, origin) {
105
+ return { '@type': 'WebSite', '@id': siteId(origin), name: studioName(cat), url: `${origin}/`, inLanguage: 'en', publisher: { '@id': studioId(origin) } };
106
+ }
107
+
108
+ /** A BreadcrumbList from [name, path] pairs (Home first). */
109
+ export function breadcrumbs(origin, trail) {
110
+ return {
111
+ '@type': 'BreadcrumbList',
112
+ itemListElement: trail.map(([name, path], i) => ({ '@type': 'ListItem', position: i + 1, name: String(name), item: absUrl(origin, path) })),
113
+ };
114
+ }
115
+
116
+ /** An ItemList of pages: [{ name, path }] in the page's order. */
117
+ export function itemList(origin, id, name, items) {
118
+ return {
119
+ '@type': 'ItemList', '@id': `${origin}${id}`, name, numberOfItems: items.length,
120
+ itemListElement: items.map((x, i) => ({ '@type': 'ListItem', position: i + 1, url: absUrl(origin, x.path), name: x.name })),
121
+ };
122
+ }
123
+
124
+ /* ------------------------------------------------------------------ a game */
125
+
126
+ /** "Remix with credit (MIT)" as a licence: a CreativeWork, linked to the SPDX page when the owner named one. */
127
+ export function licenseNode(value) {
128
+ const l = licenseOf(value);
129
+ return { '@type': 'CreativeWork', name: `${LICENSE_KINDS[l.kind]}${l.spdx ? ` (${l.spdx})` : ''}`, ...(l.spdx ? { url: `https://spdx.org/licenses/${encodeURIComponent(l.spdx)}.html` } : {}) };
130
+ }
131
+
132
+ const PLAY_MODE = (mode) => `https://schema.org/${mode}`;
133
+
134
+ /** The shop's items for a game as Offers (only fixed real-money prices; a tip has none). */
135
+ export function shopOffers(shop, g, origin) {
136
+ if (!shop || !Array.isArray(shop.items)) return [];
137
+ const currency = String(shop.currency ?? '').toUpperCase();
138
+ if (!/^[A-Z]{3}$/.test(currency)) return [];
139
+ const now = Date.now();
140
+ return shop.items
141
+ .filter((i) => (!i.game || i.game === g.id) && Number.isInteger(i.price) && i.price > 0)
142
+ .filter((i) => !(i.ends && Date.parse(i.ends) < now))
143
+ .map((i) => ({
144
+ '@type': 'Offer',
145
+ name: String(i.name ?? i.id),
146
+ ...(text(i.blurb, 300) ? { description: text(i.blurb, 300) } : {}),
147
+ category: String(i.kind ?? 'item'),
148
+ price: Number((i.price / 100).toFixed(2)),
149
+ priceCurrency: currency,
150
+ url: `${origin}/shop/?game=${encodeURIComponent(g.id)}&item=${encodeURIComponent(i.id)}`,
151
+ availability: 'https://schema.org/OnlineOnly',
152
+ ...(when(i.starts) ? { validFrom: i.starts } : {}),
153
+ ...(when(i.ends) ? { validThrough: i.ends } : {}),
154
+ }));
155
+ }
156
+
157
+ /**
158
+ * A game's VideoGame. `listed`: the game is public (a private or invite-only game's landing, which only its owner
159
+ * and invitees see, carries no offer). `shop`: the studio's shop when it is really selling (the Worker's readiness),
160
+ * else null: its items for this game become the free offer's add-ons.
161
+ */
162
+ export function gameNode(cat, g, origin, { listed = true, shop = null } = {}) {
163
+ const L = g.landing ?? {};
164
+ const h = L.hero ?? {};
165
+ const page = `${origin}/${g.id}/`;
166
+ const play = `${origin}/${g.id}/play`;
167
+ const min = Number(g.players?.min) || 1;
168
+ const max = Number(g.players?.max) || 8;
169
+ const modes = [...(min <= 1 ? ['SinglePlayer'] : []), ...(max > 1 ? ['MultiPlayer'] : [])];
170
+ const images = uniq([absUrl(origin, h.wideImage), absUrl(origin, h.tallImage), absUrl(origin, L.cover)]);
171
+ const shots = uniq((L.screenshots ?? []).map((u) => absUrl(origin, u)));
172
+ const trailer = L.trailer ? (cat.videos ?? []).find((v) => v.slug === L.trailer) : null;
173
+ const lineage = remixRow(g.remixOf);
174
+ const o = L.credits?.original;
175
+ const based = [
176
+ lineage ? { '@type': 'VideoGame', name: lineage.name, ...(lineage.page ? { url: lineage.page } : {}), ...(lineage.studio ? { creditText: `${lineage.name} by ${lineage.studio}` } : {}) } : null,
177
+ o && !o.house && o.title ? { '@type': 'VideoGame', name: o.title, ...(o.url ? { url: o.url } : {}), ...(o.author ? { creditText: `${o.title} by ${o.author}${o.year ? ` (${o.year})` : ''}${o.licence ? `, ${o.licence} licence` : ''}` } : {}) } : null,
178
+ ].filter(Boolean);
179
+ // The studio by name and address, not only its @id: a page is read on its own, and Home is where it is in full.
180
+ const studio = studioNode(cat, origin);
181
+ const addOn = listed ? shopOffers(shop, g, origin) : [];
182
+ const genre = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).map((x) => text(x, 40)).filter(Boolean);
183
+ const node = {
184
+ '@type': ['VideoGame', 'WebApplication'],
185
+ '@id': `${page}#game`,
186
+ name: g.name,
187
+ ...(text(g.blurb ?? L.pitch) ? { description: text(g.blurb || L.pitch) } : {}),
188
+ ...(text(L.pitch) && text(L.pitch) !== text(g.blurb) ? { abstract: text(L.pitch) } : {}),
189
+ url: page,
190
+ ...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
191
+ ...(shots.length ? { screenshot: shots } : {}),
192
+ ...(genre.length ? { genre: genre.length === 1 ? genre[0] : genre } : {}),
193
+ playMode: modes.map(PLAY_MODE),
194
+ numberOfPlayers: { '@type': 'QuantitativeValue', minValue: min, maxValue: max },
195
+ gamePlatform: ['Web browser', 'Phone', 'Computer', ...(L.tv !== false ? ['TV'] : [])],
196
+ applicationCategory: 'GameApplication',
197
+ operatingSystem: 'Any',
198
+ browserRequirements: 'A modern web browser with JavaScript',
199
+ isAccessibleForFree: true,
200
+ author: studio,
201
+ publisher: studio,
202
+ license: licenseNode(g.license),
203
+ ...(based.length ? { isBasedOn: based.length === 1 ? based[0] : based } : {}),
204
+ ...(trailer ? { trailer: videoNode(cat, trailer, origin, { nested: true }) } : {}),
205
+ ...(when(g.dates?.published) ? { datePublished: g.dates.published } : {}),
206
+ ...(when(g.dates?.modified) ? { dateModified: g.dates.modified } : {}),
207
+ potentialAction: { '@type': 'PlayAction', target: play },
208
+ ...(listed ? { offers: { '@type': 'Offer', price: 0, priceCurrency: /^[A-Z]{3}$/.test(String(shop?.currency ?? '').toUpperCase()) ? String(shop.currency).toUpperCase() : 'USD', availability: 'https://schema.org/OnlineOnly', url: play, ...(addOn.length ? { addOn } : {}) } } : {}),
209
+ };
210
+ return withExtras(node, g.schema);
211
+ }
212
+
213
+ /* ------------------------------------------------------------------ music and videos */
214
+
215
+ /** A song's MusicRecording: the studio is its artist; its audio file, duration, cover, album and date. */
216
+ export function songNode(cat, e, origin, { art = null } = {}) {
217
+ const page = `${origin}/music/${e.slug}/`;
218
+ const audio = fileOf(e, 'audio');
219
+ const name = studioName(cat);
220
+ const artist = { '@type': 'MusicGroup', name, url: `${origin}/` };
221
+ const duration = isoDuration(e.duration);
222
+ const image = absUrl(origin, art);
223
+ const date = when(e.date) ?? when(e.made?.at);
224
+ return {
225
+ '@type': 'MusicRecording',
226
+ '@id': `${page}#recording`,
227
+ name: e.title,
228
+ url: page,
229
+ ...(text(e.blurb) ? { description: text(e.blurb) } : {}),
230
+ byArtist: artist,
231
+ publisher: studioNode(cat, origin),
232
+ ...(duration ? { duration } : {}),
233
+ ...(image ? { image } : {}),
234
+ ...(audio?.url ? { audio: { '@type': 'AudioObject', contentUrl: absUrl(origin, audio.url), ...(audio.type ? { encodingFormat: audio.type } : {}), ...(duration ? { duration } : {}) } } : {}),
235
+ ...(date ? { datePublished: date } : {}),
236
+ ...(text(e.album, 120) ? { inAlbum: { '@type': 'MusicAlbum', name: text(e.album, 120), byArtist: artist } } : {}),
237
+ ...(text(e.key, 40) ? { recordingOf: { '@type': 'MusicComposition', name: e.title, musicalKey: text(e.key, 40) } } : {}),
238
+ ...(text(e.credits, 600) ? { creditText: text(e.credits, 600) } : {}),
239
+ };
240
+ }
241
+
242
+ /** A video's VideoObject: what Google asks for (name, thumbnailUrl, uploadDate) and the file itself (contentUrl). */
243
+ export function videoNode(cat, e, origin, { art = null, nested = false } = {}) {
244
+ const page = `${origin}/videos/${e.slug}/`;
245
+ const video = fileOf(e, 'video');
246
+ const duration = isoDuration(e.duration);
247
+ const poster = absUrl(origin, art ?? fileOf(e, 'poster')?.url);
248
+ const date = when(e.date) ?? when(e.made?.at);
249
+ const game = e.for?.game ? (cat.games ?? []).find((g) => g.id === e.for.game) : null;
250
+ return {
251
+ '@type': 'VideoObject',
252
+ '@id': `${page}#video`,
253
+ name: e.title,
254
+ description: text(e.blurb) ?? `${KIND_WORD[e.kind] ?? 'Video'} from ${studioName(cat)}.`,
255
+ url: page,
256
+ ...(poster ? { thumbnailUrl: [poster] } : {}),
257
+ ...(date ? { uploadDate: date } : {}),
258
+ ...(duration ? { duration } : {}),
259
+ ...(video?.url ? { contentUrl: absUrl(origin, video.url), ...(video.type ? { encodingFormat: video.type } : {}) } : {}),
260
+ ...(nested ? {} : { publisher: studioNode(cat, origin) }),
261
+ creator: { '@type': 'Organization', name: studioName(cat), url: `${origin}/` },
262
+ ...(game && !nested ? { about: { '@type': 'VideoGame', '@id': `${origin}/${game.id}/#game`, name: game.name, url: `${origin}/${game.id}/` } } : {}),
263
+ };
264
+ }
265
+
266
+ /** /music/: a MusicAlbum when every song names the same album, else an ItemList of the songs' pages. */
267
+ export function musicIndexNode(cat, origin, songs, { art = () => null } = {}) {
268
+ const album = songs.length && songs.every((e) => text(e.album, 120) && text(e.album, 120) === text(songs[0].album, 120)) ? text(songs[0].album, 120) : null;
269
+ if (!album) return itemList(origin, '/music/#list', `Music from ${studioName(cat)}`, songs.map((e) => ({ name: e.title, path: `/music/${e.slug}/` })));
270
+ const cover = absUrl(origin, art(songs[0]));
271
+ return {
272
+ '@type': 'MusicAlbum', '@id': `${origin}/music/#album`, name: album, url: `${origin}/music/`,
273
+ byArtist: { '@type': 'MusicGroup', name: studioName(cat), url: `${origin}/` },
274
+ ...(cover ? { image: cover } : {}),
275
+ numTracks: songs.length,
276
+ track: songs.map((e) => ({ '@type': 'MusicRecording', name: e.title, url: `${origin}/music/${e.slug}/`, ...(isoDuration(e.duration) ? { duration: isoDuration(e.duration) } : {}) })),
277
+ };
278
+ }
279
+
280
+ /* ------------------------------------------------------------------ posts */
281
+
282
+ const sameName = (a, b) => String(a ?? '').trim().toLowerCase() === String(b ?? '').trim().toLowerCase();
283
+
284
+ /** A post's author: a person it names (frontmatter `author:`), else the studio. */
285
+ function authorOf(cat, p, origin) {
286
+ return p.author && !sameName(p.author, studioName(cat)) ? { '@type': 'Person', name: String(p.author) } : studioNode(cat, origin);
287
+ }
288
+
289
+ export function postNode(cat, p, origin, { image = null } = {}) {
290
+ const page = `${origin}/posts/${p.slug}/`;
291
+ const game = p.links?.game ? (cat.games ?? []).find((g) => g.id === p.links.game) : null;
292
+ const img = absUrl(origin, image ?? p.image);
293
+ return {
294
+ '@type': 'BlogPosting',
295
+ '@id': `${page}#post`,
296
+ headline: String(p.title).slice(0, 110),
297
+ ...(text(p.summary) ? { description: text(p.summary) } : {}),
298
+ url: page,
299
+ mainEntityOfPage: page,
300
+ ...(img ? { image: [img] } : {}),
301
+ ...(when(p.date) ? { datePublished: p.date } : {}),
302
+ ...(when(p.updated ?? p.date) ? { dateModified: p.updated ?? p.date } : {}),
303
+ author: authorOf(cat, p, origin),
304
+ publisher: studioNode(cat, origin),
305
+ isPartOf: { '@type': 'Blog', '@id': `${origin}/posts/#blog` },
306
+ ...(game ? { about: { '@type': 'VideoGame', '@id': `${origin}/${game.id}/#game`, name: game.name, url: `${origin}/${game.id}/` } } : {}),
307
+ };
308
+ }
309
+
310
+ /** /posts/: the studio's Blog, with each post's headline, address and dates. */
311
+ export function blogNode(cat, origin, posts) {
312
+ return {
313
+ '@type': 'Blog', '@id': `${origin}/posts/#blog`, name: `${studioName(cat)}: posts`, url: `${origin}/posts/`,
314
+ publisher: studioNode(cat, origin),
315
+ blogPost: posts.map((p) => ({
316
+ '@type': 'BlogPosting', headline: String(p.title).slice(0, 110), url: `${origin}/posts/${p.slug}/`,
317
+ ...(when(p.date) ? { datePublished: p.date } : {}), ...(when(p.updated ?? p.date) ? { dateModified: p.updated ?? p.date } : {}),
318
+ author: authorOf(cat, p, origin),
319
+ })),
320
+ };
321
+ }
package/worker/site.mjs CHANGED
@@ -13,9 +13,9 @@ import { isLocalOrigin, qrSvg } from './qr.mjs';
13
13
  import { STUDIO_VERSION_TAG } from './version.mjs';
14
14
  import { licenseLabel, licenseOf, remixRow } from './license.mjs';
15
15
  import { POLICY_WORDS, policyWords } from './servers.mjs';
16
+ import { blogNode, breadcrumbs, gameNode, itemList, ldScript, musicIndexNode, postNode, songNode, studioNode, videoNode, websiteNode } from './schema.mjs';
16
17
 
17
18
  export const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
18
- const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
19
19
 
20
20
  /* ------------------------------------------------------------------ headers */
21
21
 
@@ -288,6 +288,9 @@ img,video{display:block;max-width:100%}
288
288
  .credit li{margin:4px 0}
289
289
  .credit strong{color:var(--fg)}
290
290
  .credit a{color:var(--fg)}
291
+ .shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:14px;margin-top:24px}
292
+ .shots a{display:block;border-radius:var(--r);overflow:hidden;border:1px solid var(--line);background:var(--panel)}
293
+ .shots img{width:100%;aspect-ratio:16/9;object-fit:cover}
291
294
 
292
295
  /* make a game like this */
293
296
  .make{position:relative;overflow:hidden;border-radius:calc(var(--r) + 8px);padding:clamp(26px,4vw,48px);border:1px solid color-mix(in srgb,var(--hot) 55%,transparent);
@@ -599,7 +602,7 @@ const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]
599
602
  * A whole generated page. `hero` pages draw the top line over the hero; `style` is the game's own accent on its
600
603
  * landing (game.json landing.theme).
601
604
  */
602
- export function layout(cat, { title, description = '', origin = '', path = '/', image = null, type = 'website', active = null, page = 'page', hero = false, head = '', main, over = null, status = 200, extraHeaders = {} }) {
605
+ export function layout(cat, { title, description = '', origin = '', path = '/', image = null, type = 'website', active = null, page = 'page', hero = false, head = '', ld = null, main, over = null, status = 200, extraHeaders = {} }) {
603
606
  const theme = cat.studio?.theme ?? {};
604
607
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
605
608
  const icon = theme.icon ? `<link rel="icon" href="${esc(theme.icon)}">` : `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="3" fill="${(over?.accent ?? theme.accent ?? TOKEN_DEFAULTS.accent)}"/></svg>`)}">`;
@@ -614,7 +617,7 @@ ${description ? `<meta name="description" content="${esc(description)}">` : ''}
614
617
  ${origin ? `<link rel="canonical" href="${esc(`${origin}${path}`)}">` : ''}
615
618
  <meta name="theme-color" content="${esc(over?.bg ?? theme.bg ?? TOKEN_DEFAULTS.bg)}">
616
619
  ${ogTags({ 'og:title': title, 'og:description': description, 'og:type': type, 'og:url': origin ? `${origin}${path}` : null, 'og:image': abs(image ?? theme.social ?? null), 'og:site_name': studioName(cat), 'twitter:card': image || theme.social ? 'summary_large_image' : 'summary' })}
617
- ${icon}${feeds}${head}
620
+ ${icon}${feeds}${head}${origin && ld ? ldScript(ld) : ''}
618
621
  <style>${tokensCss(theme, over)}${BASE_CSS}${cat.site?.css ?? ''}</style>
619
622
  ${partial(cat, 'head') ?? ''}
620
623
  </head>
@@ -632,12 +635,14 @@ ${footer(cat)}
632
635
  }
633
636
 
634
637
  /**
635
- * A whole page from site/pages, served as it is, in the site's frame rules (never framed, no-transform). Four
638
+ * A whole page from site/pages, served as it is, in the site's frame rules (never framed, no-transform). Five
636
639
  * markers let it borrow the generated parts: <!-- homie:style --> (the tokens and the stylesheet),
637
- * <!-- homie:header -->, <!-- homie:footer --> ("Made with Homie") and <!-- homie:script -->.
640
+ * <!-- homie:header -->, <!-- homie:footer --> ("Made with Homie"), <!-- homie:script --> and, in its <head>,
641
+ * <!-- homie:schema --> (the structured data the generated page at that address would carry: worker/schema.mjs).
638
642
  */
639
- export function customPage(cat, html, { active = null } = {}) {
643
+ export function customPage(cat, html, { active = null, schema = '' } = {}) {
640
644
  const out = String(html)
645
+ .replace(/<!--\s*homie:schema\s*-->/g, () => schema)
641
646
  .replace(/<!--\s*homie:style\s*-->/g, () => `<style>${tokensCss(cat.studio?.theme ?? {})}${BASE_CSS}${cat.site?.css ?? ''}</style>`)
642
647
  .replace(/<!--\s*homie:header\s*-->/g, () => header(cat, active))
643
648
  .replace(/<!--\s*homie:footer\s*-->/g, () => footer(cat))
@@ -770,6 +775,15 @@ export function featuredOf(cat) {
770
775
  return games.find((g) => g.id === want) ?? games.find((g) => g.landing?.hero?.wide || g.landing?.hero?.tall) ?? games.find((g) => coverOf(g)) ?? games[0] ?? null;
771
776
  }
772
777
 
778
+ /** Home's structured data: the studio (an Organization) and its site. */
779
+ export const homeLd = (cat, origin) => [studioNode(cat, origin, { full: true }), websiteNode(cat, origin)];
780
+
781
+ /** A game landing's structured data: its breadcrumbs and its VideoGame (worker/schema.mjs gameNode). */
782
+ export const landingLd = (cat, g, origin, opts = {}) => [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`]]), gameNode(cat, g, origin, opts)];
783
+
784
+ /** The public games as a list of their landings (Games, Rooms). */
785
+ const gamesList = (cat, origin, id, name) => itemList(origin, id, name, (cat.games ?? []).map((g) => ({ name: g.name, path: `/${g.id}/` })));
786
+
773
787
  export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
774
788
  const name = studioName(cat);
775
789
  const games = cat.games ?? [];
@@ -846,7 +860,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
846
860
  return layout(cat, {
847
861
  title: tagline ? `${name} — ${tagline}` : name,
848
862
  description: tagline || (f ? `${name}: ${games.map((g) => g.name).join(', ')}. Free in your browser, on a phone, a computer or a TV.` : soon ? `${name}: first game coming soon.` : name),
849
- origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true,
863
+ origin, path: '/', image: f ? (f.landing?.hero?.wideImage ?? coverOf(f)) : null, page: 'home', hero: true, ld: homeLd(cat, origin),
850
864
  head: f?.landing?.hero?.wideImage ? `<link rel="preload" as="image" href="${esc(f.landing.hero.tallImage ?? f.landing.hero.wideImage)}">` : '',
851
865
  main: `${hero}${extra ? `<section class="band">${extra}</section>` : ''}${roomsBand}${gamesBand}${postsBand}${soon}${mediaBand(videos, 'videos', 'Trailers and clips')}${mediaBand(songs, 'music', 'Songs and scores')}`,
852
866
  });
@@ -863,6 +877,7 @@ export function gamesPage(cat, { origin = '', live = {} } = {}) {
863
877
  const name = studioName(cat);
864
878
  return layout(cat, {
865
879
  title: `Games · ${name}`, description: `Every game from ${name}: press Play and you are in a live room.`, origin, path: '/games/', page: 'games', active: 'games',
880
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/']]), gamesList(cat, origin, '/games/#list', `Games from ${name}`)],
866
881
  main: `${pageHead(name, 'Games', `${games.length === 1 ? 'One game' : `${games.length} games`}. Press Play on a phone, a computer or a TV browser and you are in a live public room with whoever is playing; bots hold the empty seats.`)}
867
882
  <div class="wrap"><div class="cards">${games.map((g) => gameCard(g, live[g.id] ?? 0)).join('')}</div></div>`,
868
883
  });
@@ -874,6 +889,8 @@ export function roomsPage(cat, { origin = '', rooms = [] } = {}) {
874
889
  const total = rooms.reduce((n, r) => n + r.players, 0);
875
890
  return layout(cat, {
876
891
  title: `Rooms · ${name}`, description: `Public rooms playing now across ${name}'s games. Join one.`, origin, path: '/rooms/', page: 'rooms', active: 'rooms',
892
+ // The games a room can be opened in, never who is in one now: a cached copy of the page must not go stale.
893
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Rooms', '/rooms/']]), gamesList(cat, origin, '/rooms/#games', `Games with public rooms at ${name}`)],
877
894
  main: `${pageHead('Live now', 'Rooms', rooms.length ? `${total} ${total === 1 ? 'person' : 'people'} playing in ${rooms.length} public ${rooms.length === 1 ? 'room' : 'rooms'} right now. Join one, or press Play on any game.` : `Public rooms across ${name}'s games, as they happen.`)}
878
895
  <div class="wrap">
879
896
  <ol class="rooms" data-rooms data-src="/api/rooms"${rooms.length ? '' : ' hidden'}>${roomRows(rooms)}</ol>
@@ -921,6 +938,7 @@ export function serversPage(cat, g, { origin = '', servers = [], hidden = false,
921
938
  const cards = [...(pub && !hidden ? [pub] : []), ...shown];
922
939
  return layout(cat, {
923
940
  title: `Servers · ${g.name}`, description: `${g.name}'s servers: ${SERVERS_LEAD}`, origin, path: `/${g.id}/servers/`, page: 'servers', active: 'games',
941
+ ld: pick ? null : [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`]])],
924
942
  main: `${pageHead(g.name, pick ? 'Pick a server' : 'Servers', SERVERS_LEAD)}
925
943
  <div class="wrap">${cards.length ? `<div class="scards">${cards.map((sv) => serverCard(g, sv)).join('')}</div>` : `<p class="quiet">No server is open right now.</p>`}
926
944
  <p class="sec">Every policy</p><ul class="howto">${Object.values(POLICY_WORDS).map((w) => `<li>${esc(w.line.replace('{n}', 'N'))}</li>`).join('')}</ul></div>`,
@@ -942,6 +960,7 @@ export function serverPage(cat, g, sv, { origin = '', rooms = [], door = { ok: t
942
960
  const blocked = !door.ok ? `<p class="door-why">${esc(doorWords(g, sv, door).line)}</p>` : '';
943
961
  return layout(cat, {
944
962
  title: `${sv.name} · ${g.name}`, description: `${sv.name}, a ${words.badge.toLowerCase()} server of ${g.name}. ${words.line}`, origin, path: `/${g.id}/s/${sv.id}/`, page: 'server', active: 'games',
963
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Servers', `/${g.id}/servers/`], [sv.name, `/${g.id}/s/${sv.id}/`]])],
945
964
  main: `<header class="head"><p class="kicker">${esc(g.name)} · server</p><h1>${esc(sv.name)}</h1>
946
965
  <p class="lead"><span class="pol ${esc(sv.policy)}">${esc(words.badge)}</span> ${esc(words.line)}</p>${sv.blurb ? `<p class="lead">${esc(sv.blurb)}</p>` : ''}
947
966
  ${blocked}<div class="keys">${door.ok ? `<a class="btn" href="${esc(play)}" data-play>${PLAY_ICON}<span>Play on ${esc(sv.name)}</span></a>` : ''}${membership}</div></header>
@@ -988,6 +1007,7 @@ export function postsPage(cat, { origin = '' } = {}) {
988
1007
  const items = posts.map((p) => `<li class="pitem reveal"><a href="/posts/${esc(p.slug)}/"><div><time datetime="${esc(p.date)}">${esc(fmtDay(p.date))}</time><h3>${esc(p.title)}</h3><p>${esc(p.summary)}</p>${p.links?.game && gameName(p.links.game) ? `<div class="chips"><span>${esc(gameName(p.links.game))}</span></div>` : ''}</div>${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy" decoding="async">` : ''}</a></li>`).join('');
989
1008
  return layout(cat, {
990
1009
  title: `Posts · ${name}`, description: `News and drops from ${name}.`, origin, path: '/posts/', page: 'posts', active: 'posts',
1010
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Posts', '/posts/']]), blogNode(cat, origin, posts)],
991
1011
  main: `${pageHead(name, 'Posts', 'News, drops and notes from the studio.', `<div class="feeds"><a href="/posts/feed.xml">${icon('feed')}Atom feed</a><a href="/posts/feed.json">${icon('feed')}JSON feed</a></div>`)}
992
1012
  <div class="wrap"><ol class="plist">${items}</ol></div>`,
993
1013
  });
@@ -1003,6 +1023,7 @@ export function postPage(cat, p, { origin = '' } = {}) {
1003
1023
  const linked = [g ? gameCard(g) : '', video ? mediaCard(video, 'videos', cat) : '', song ? mediaCard(song, 'music', cat) : ''].filter(Boolean).join('');
1004
1024
  return layout(cat, {
1005
1025
  title: `${p.title} · ${name}`, description: p.summary, origin, path: `/posts/${p.slug}/`, image: p.image ?? (g ? coverOf(g) : null), type: 'article', page: 'post', active: 'posts',
1026
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Posts', '/posts/'], [p.title, `/posts/${p.slug}/`]]), postNode(cat, p, origin, { image: p.image ?? (g ? coverOf(g) : null) })],
1006
1027
  head: `<meta property="article:published_time" content="${esc(p.date)}">${p.updated ? `<meta property="article:modified_time" content="${esc(p.updated)}">` : ''}`,
1007
1028
  main: `<div class="wrap" style="padding-top:clamp(36px,7vw,90px)"><article class="article">
1008
1029
  <p class="post-meta"><time datetime="${esc(p.date)}">${esc(fmtDay(p.date))}</time>${p.author ? ` · ${esc(p.author)}` : ''}</p>
@@ -1092,7 +1113,7 @@ export function jsonFeed(cat, posts, origin) {
1092
1113
  * public room, phone / computer / TV (with the join code), live rooms, how to play, credits, and "Make a game like
1093
1114
  * this". A studio's site/pages/<id>/index.html replaces it; site/partials/game.html and game-<id>.html add a band.
1094
1115
  */
1095
- export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, remix = true, servers = null } = {}) {
1116
+ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week = null, remix = true, servers = null, listed = true, shop = null } = {}) {
1096
1117
  const name = studioName(cat);
1097
1118
  const L = g.landing ?? {};
1098
1119
  // A remix says what it is a remix of, linked, under its name and in its credits (game.json `remixOf`).
@@ -1155,7 +1176,8 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1155
1176
  ${week ? `<div class="stat"><span>Played this week: <b>${esc(week.plays)}</b> ${week.plays === 1 ? 'time' : 'times'}</span><span><b>${esc(week.rounds)}</b> ${week.rounds === 1 ? 'round' : 'rounds'} with people</span>${week.peak ? `<span>Most at once today: <b>${esc(week.peak)}</b></span>` : ''}</div>` : ''}
1156
1177
  </div></section>`;
1157
1178
 
1158
- const facts = ['free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
1179
+ const genres = (Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : []).filter((x) => typeof x === 'string' && x.trim());
1180
+ const facts = [...genres, 'free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
1159
1181
  const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
1160
1182
  const howBand = `<section class="band hotband" aria-labelledby="how-title"><div class="band-in split">
1161
1183
  <div class="reveal">
@@ -1168,6 +1190,11 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1168
1190
  ${side ? `<div>${side}${trailer && song ? `<div style="margin-top:14px">${mediaCard(song, 'music', cat)}</div>` : ''}</div>` : ''}
1169
1191
  </div></section>`;
1170
1192
 
1193
+ // Screenshots (landing.screenshots, else games/<id>/screenshots/): what the game looks like in play.
1194
+ const shots = (L.screenshots ?? []).slice(0, 8);
1195
+ const shotsBand = shots.length ? `<section class="band tight" aria-labelledby="shots-title"><div class="band-in"><p class="kicker reveal">Screenshots</p><h2 class="small-h reveal" id="shots-title">${esc(g.name)}, in play</h2>
1196
+ <div class="shots reveal">${shots.map((u, i) => `<a href="${esc(u)}"><img src="${esc(u)}" alt="${esc(`${g.name}: screenshot ${i + 1}`)}" loading="lazy" decoding="async"></a>`).join('')}</div></div></section>` : '';
1197
+
1171
1198
  const C = L.credits ?? {};
1172
1199
  const o = C.original;
1173
1200
  const creditCards = [
@@ -1199,13 +1226,14 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
1199
1226
  // A partial that is its own <section> stands as it is; anything else sits in one of the page's bands.
1200
1227
  const band = (html) => (!html ? '' : /^\s*<section\b/i.test(html) ? html : `<section class="band tight"><div class="band-in">${html}</div></section>`);
1201
1228
  const vars = { 'game.name': g.name, 'game.id': g.id, 'game.play': `/${g.id}/play` };
1202
- const ld = { '@context': 'https://schema.org', '@type': 'VideoGame', name: g.name, description: L.pitch ?? g.blurb ?? '', url: `${origin}/${g.id}/`, playMode: 'MultiPlayer', gamePlatform: ['Web browser', 'Phone', 'TV'], applicationCategory: 'Game', ...(h.wideImage ? { image: `${origin}${h.wideImage}` } : {}), author: { '@type': 'Organization', name, url: `${origin}/` }, offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' } };
1203
1229
  return layout(cat, {
1204
1230
  title: `${g.name} — play free in your browser`,
1205
1231
  description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
1206
1232
  origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
1207
- head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}<script type="application/ld+json">${jsonScript(ld)}</script>`,
1208
- main: `${hero}${ways}${liveBand}${servers ? serversBand(g, servers) : ''}${howBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
1233
+ head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
1234
+ // A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
1235
+ ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
1236
+ main: `${hero}${ways}${liveBand}${servers ? serversBand(g, servers) : ''}${howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
1209
1237
  });
1210
1238
  }
1211
1239
 
@@ -1216,6 +1244,7 @@ export function creditsPage(cat, g, texts, { origin = '' } = {}) {
1216
1244
  const of = lineage ? `<p class="sec">Remix of</p><p>${lineage.page ? `<a href="${esc(lineage.page)}" rel="noopener">${esc(lineage.name)}</a>` : esc(lineage.name)}${lineage.studio ? ` by ${esc(lineage.studio)}` : ''}</p>` : '';
1217
1245
  return layout(cat, {
1218
1246
  title: `Credits · ${g.name} · ${name}`, description: `Credits and licences for ${g.name}.`, origin, path: `/${g.id}/credits`, page: 'credits', active: 'games',
1247
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Games', '/games/'], [g.name, `/${g.id}/`], ['Credits', `/${g.id}/credits`]])],
1219
1248
  main: `${pageHead(g.name, 'Credits and licences', null, `<div class="keys"><a class="ghost" href="/${esc(g.id)}/">${icon('arrow')}<span>Back to ${esc(g.name)}</span></a></div>`)}
1220
1249
  <div class="wrap">${of}${texts.map(({ file, text }) => `<p class="sec">${esc(file)}</p><pre class="licence">${esc(text)}</pre>`).join('')}</div>`,
1221
1250
  });
@@ -1246,6 +1275,9 @@ export function mediaIndexPage(cat, kind, { origin = '' } = {}) {
1246
1275
  const title = kind === 'music' ? 'Music' : 'Videos';
1247
1276
  return layout(cat, {
1248
1277
  title: `${title} · ${name}`, description: `${title} from ${name}.`, origin, path: `/${kind}/`, page: kind, active: kind,
1278
+ ld: [breadcrumbs(origin, [['Home', '/'], [title, `/${kind}/`]]), kind === 'music'
1279
+ ? musicIndexNode(cat, origin, list, { art: (e) => mediaArt(e, 'music', cat) })
1280
+ : itemList(origin, '/videos/#list', `Videos from ${name}`, list.map((e) => ({ name: e.title, path: `/videos/${e.slug}/` })))],
1249
1281
  main: `${pageHead(name, title, kind === 'music' ? 'Songs, scores and loops from the studio.' : 'Trailers, music videos and clips from the studio.')}
1250
1282
  <div class="wrap"><div class="cards">${list.map((e) => mediaCard(e, kind, cat)).join('')}</div></div>`,
1251
1283
  });
@@ -1261,6 +1293,7 @@ export function songPage(cat, e, origin = '') {
1261
1293
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
1262
1294
  return layout(cat, {
1263
1295
  title: `${e.title} · ${name}`, description: e.blurb ?? '', origin, path: `/music/${e.slug}/`, image: cover?.url ?? null, type: 'music.song', page: 'song', active: 'music',
1296
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Music', '/music/'], [e.title, `/music/${e.slug}/`]]), songNode(cat, e, origin, { art: cover?.url ?? null })],
1264
1297
  head: ogTags({ 'og:audio': abs(audio.url) }),
1265
1298
  main: `<div class="wrap" style="padding-top:clamp(36px,7vw,90px)">
1266
1299
  <section class="songhead${cover ? ' has-cover' : ''}">
@@ -1293,6 +1326,7 @@ export function videoPage(cat, e, origin = '') {
1293
1326
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
1294
1327
  return layout(cat, {
1295
1328
  title: `${e.title} · ${name}`, description: e.blurb ?? '', origin, path: `/videos/${e.slug}/`, image: poster?.url ?? null, type: 'video.other', page: 'video', active: 'videos',
1329
+ ld: [breadcrumbs(origin, [['Home', '/'], ['Videos', '/videos/'], [e.title, `/videos/${e.slug}/`]]), videoNode(cat, e, origin, { art: poster?.url ?? null })],
1296
1330
  head: ogTags({ 'og:video': abs(video.url) }),
1297
1331
  main: `<div class="wrap" style="padding-top:clamp(28px,5vw,64px)">
1298
1332
  <div class="player"${beaconAttr('video', e.slug)}${vertical ? ` data-vertical="${esc(vertical.url)}"` : ''}><video data-main controls playsinline preload="metadata" src="${esc(video.url)}"${poster ? ` poster="${esc(poster.url)}"` : ''}>${captions ? `<track kind="captions" src="${esc(captions.url)}" srclang="en" label="English">` : ''}</video></div>
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.26.0';
2
+ export const STUDIO_VERSION_TAG = '0.27.0';