@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/worker/index.mjs CHANGED
@@ -36,6 +36,9 @@
36
36
  * /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
37
37
  * knocks for a Homie box, so it stops knocking (a studio site is not a box)
38
38
  * /.well-known/homie-studio.json what the homie.rocks directory reads
39
+ * /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt for search engines and AI agents, made from the public
40
+ * catalogue (worker/discover.mjs); every generated page also carries schema.org JSON-LD
41
+ * (worker/schema.mjs)
39
42
  * /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
40
43
  * /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
41
44
  * /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
@@ -93,7 +96,7 @@ import { doorPage, serverPage, serversPage } from './site.mjs';
93
96
  import { isLocalOrigin, qrSvg } from './qr.mjs';
94
97
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
95
98
  import {
96
- SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
99
+ SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaArt, mediaIndexPage,
97
100
  notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
98
101
  } from './site.mjs';
99
102
  import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
@@ -108,7 +111,9 @@ import {
108
111
  // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
109
112
  usePlayers(playerAccounts);
110
113
  import { STUDIO_VERSION_TAG } from './version.mjs';
111
- import { roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
114
+ import { readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
115
+ import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
116
+ import { ldScript, studioNode } from './schema.mjs';
112
117
  import { arrivalCookie, manifestReferrals } from './referrals.mjs';
113
118
  import { licenseOf, remixAllowed, remixRow } from './license.mjs';
114
119
 
@@ -143,6 +148,14 @@ async function catalogue(env, origin) {
143
148
  }
144
149
 
145
150
  /** The posts with their HTML (site/dist/_site/posts.json), for a post's page and the feeds. */
151
+ /** The studio's shop when it really sells (shop.json checked, the key, the webhook secret and its tables in): its items
152
+ * are a landing's offers (worker/schema.mjs). Else null: a landing never names a price nobody can pay. */
153
+ async function sellingShop(env, cat) {
154
+ const shop = shopOf(cat);
155
+ if (!shop || shop.broken) return null;
156
+ try { return (await readiness(env, shop)).ready ? shop : null; } catch { return null; }
157
+ }
158
+
146
159
  async function postsOf(env, origin) {
147
160
  try {
148
161
  const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
@@ -701,6 +714,23 @@ async function route(request, env, ctx) {
701
714
  }
702
715
  }
703
716
 
717
+ // Search engines and AI agents (0.27.0, worker/discover.mjs): robots.txt, sitemap.xml, llms.txt and llms-full.txt,
718
+ // made from the public catalogue (a private or invite-only game is in none of them); a file of the studio's own in
719
+ // site/public wins.
720
+ if (read && DISCOVERY_FILES.includes(path)) {
721
+ const own = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
722
+ if (own.ok) return own;
723
+ const cat = await getCat();
724
+ const method = request.method;
725
+ if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
726
+ const all = await getAll();
727
+ if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
728
+ const settings = await settingsOf(env);
729
+ const remixable = (g) => g.landing?.source !== false && remixOf(g, settings) && remixAllowed(g.license);
730
+ const full = path === '/llms-full.txt';
731
+ return discoveryResponse(llmsTxt(cat, url.origin, { remixable, directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
732
+ }
733
+
704
734
  // A page the studio made itself (site/pages) wins at its address.
705
735
  if (read && (path.endsWith('/') || !path.includes('.'))) {
706
736
  const cat = await getCat();
@@ -714,7 +744,14 @@ async function route(request, env, ctx) {
714
744
  const game = (cat.games ?? []).find((g) => g.id === top);
715
745
  await countVisit(request, env, ctx, parts.length === 0 ? 'home' : game && parts.length === 1 ? game.id : parts.join('/').slice(0, 80));
716
746
  const active = game ? 'games' : sectionsOf(cat).some((s) => s.key === top) ? top : null;
717
- return customPage(cat, await res.text(), { active });
747
+ const html = await res.text();
748
+ // <!-- homie:schema -->: the structured data the generated page here would carry (Home's, a landing's), else the
749
+ // studio's own Organization (an About page).
750
+ const schema = !/<!--\s*homie:schema\s*-->/.test(html) ? ''
751
+ : want === '/' ? ldScript(homeLd(cat, url.origin))
752
+ : game && parts.length === 1 ? ldScript(landingLd(cat, game, url.origin, { shop: await sellingShop(env, cat) }))
753
+ : ldScript([studioNode(cat, url.origin, { full: true })]);
754
+ return customPage(cat, html, { active, schema });
718
755
  }
719
756
  }
720
757
  }
@@ -825,7 +862,7 @@ async function route(request, env, ctx) {
825
862
  const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
826
863
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
827
864
  const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
828
- return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band });
865
+ return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band, listed: launch === 'public', shop: launch === 'public' ? await sellingShop(env, cat) : null });
829
866
  }
830
867
  if (sub === 'live') {
831
868
  if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
@@ -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
+ }