@homie-rocks/studio 0.26.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/README.md +8 -0
- package/lib/build.mjs +65 -1
- package/lib/media.mjs +7 -0
- package/lib/scaffold.mjs +5 -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 +39 -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
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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).
|
|
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")
|
|
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
|
|
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)">` : ''}` : ''}
|
|
1208
|
-
|
|
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>
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.27.0';
|