@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/CHANGELOG.md +64 -1
- package/README.md +18 -1
- package/lib/build.mjs +65 -1
- package/lib/feedback.mjs +181 -0
- package/lib/mcp-tools.mjs +134 -1
- package/lib/mcp.mjs +6 -2
- package/lib/media.mjs +7 -0
- package/lib/redact.mjs +99 -0
- package/lib/scaffold.mjs +17 -2
- package/lib/schema-check.mjs +227 -0
- package/lib/schema-vocab.json +1 -0
- package/lib/site.mjs +15 -0
- package/lib/template-history.json +79 -0
- package/mcp/ui/card.css +8 -0
- package/mcp/ui/feedback.js +112 -0
- package/media/MEDIA.md +5 -0
- package/package.json +1 -1
- package/site/SITE.md +67 -2
- package/worker/discover.mjs +232 -0
- package/worker/index.mjs +41 -4
- package/worker/schema.mjs +321 -0
- package/worker/site.mjs +46 -12
- package/worker/version.mjs +1 -1
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
|
-
|
|
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
|
+
}
|