@homie-rocks/studio 0.6.0 → 0.7.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/lib/site.mjs ADDED
@@ -0,0 +1,437 @@
1
+ /**
2
+ * The studio site's own parts, gathered at build time (site/SITE.md says all of it):
3
+ *
4
+ * site/theme.json the studio's look as tokens (colours, fonts, corner radius, its mark)
5
+ * site/theme.css any CSS on top (restyle anything, the "Made with Homie" footer included)
6
+ * site/partials/<n>.html a piece of every generated page: head, header, footer, home, game, game-<id>, post
7
+ * site/pages/.../*.html a whole page of the studio's own, served instead of (or beside) the generated one
8
+ * site/public/... files served as they are (fonts, a logo, hero footage), at the same path
9
+ * posts/*.md the studio's news and drops (a title, a date, a body, links to a game, song or video)
10
+ * games/<id>/ each game's landing: game.json `landing`, credits.json, hero/ footage, its cover
11
+ *
12
+ * Nothing here runs on Cloudflare: it writes site/dist (the Worker reads games.json and site/dist/_site/).
13
+ */
14
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
15
+ import { basename, dirname, join, normalize, relative, resolve, sep } from 'node:path';
16
+ import { renderMarkdown } from './markdown.mjs';
17
+
18
+ /** One file the site serves itself may be at most 25 MiB (Workers static assets). */
19
+ const MAX_FILE = 25 * 1024 * 1024;
20
+ const MAX_PARTIAL = 64 * 1024;
21
+ const MAX_CSS = 256 * 1024;
22
+ const MAX_POST = 256 * 1024;
23
+
24
+ /* ------------------------------------------------------------------ theme */
25
+
26
+ /**
27
+ * Curated looks a new studio starts from (picked from its slug, so two new studios rarely match). Each is dark,
28
+ * like the house brands' landings, and passes contrast for text on the background and on the accent.
29
+ */
30
+ export const PALETTES = {
31
+ neon: { bg: '#05070d', fg: '#dffcff', accent: '#ff3bd4', glow: '#00eaff' },
32
+ dock: { bg: '#070612', fg: '#eafcff', accent: '#ff8a3d', glow: '#7df0ff' },
33
+ gold: { bg: '#0b0c12', fg: '#f1f3f9', accent: '#ffcf5a', glow: '#7dffb0' },
34
+ acid: { bg: '#060905', fg: '#efffe6', accent: '#b6ff3b', glow: '#3bffd0' },
35
+ ember: { bg: '#0d0706', fg: '#fff1ea', accent: '#ff5a36', glow: '#ffc36b' },
36
+ orchid: { bg: '#0a0612', fg: '#f6ecff', accent: '#c77dff', glow: '#ff7dd8' },
37
+ tide: { bg: '#041014', fg: '#e6fbff', accent: '#2ee6c9', glow: '#6fb8ff' },
38
+ candy: { bg: '#120814', fg: '#fff0f7', accent: '#ff6fa8', glow: '#ffd36f' },
39
+ };
40
+ export const DEFAULT_THEME = { palette: 'gold', ...PALETTES.gold };
41
+
42
+ /** The palette a new studio gets: stable for its slug. */
43
+ export function paletteFor(slug) {
44
+ const names = Object.keys(PALETTES);
45
+ let h = 2166136261;
46
+ for (const c of String(slug ?? '')) h = Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0;
47
+ return names[h % names.length];
48
+ }
49
+
50
+ const COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\([0-9a-z.,%\s/+-]{1,80}\))$/i;
51
+ const FAMILY = /^[A-Za-z0-9 ,"'_.-]{1,240}$/;
52
+ const ASSET = /^\/[A-Za-z0-9._~/-]{1,200}$/;
53
+ const httpsUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'https:' && !/["'<>\s]/.test(u.href) ? u.href : null; } catch { return null; } };
54
+ const sitePath = (v) => (ASSET.test(String(v ?? '')) && !String(v).includes('..') ? String(v) : null);
55
+
56
+ /** Relative luminance of a #rgb/#rrggbb colour (0..1), or null for any other colour syntax. */
57
+ function luminance(hex) {
58
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex));
59
+ if (!m) return null;
60
+ const full = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
61
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
62
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
63
+ }
64
+
65
+ /**
66
+ * site/theme.json as the tokens the pages use. Unknown keys and unsafe values are dropped (a warning each),
67
+ * so a theme can never break out of the page's stylesheet.
68
+ */
69
+ export function readTheme(root, { log = () => {} } = {}) {
70
+ const file = join(root, 'site', 'theme.json');
71
+ let raw = {};
72
+ if (existsSync(file)) {
73
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { log(`warning: site/theme.json is not JSON (${error.message}); the default look is used`); raw = {}; }
74
+ }
75
+ if (raw.palette && !PALETTES[raw.palette]) log(`warning: site/theme.json palette "${raw.palette}" is not one of ${Object.keys(PALETTES).join(', ')}`);
76
+ const base = PALETTES[raw.palette] ?? DEFAULT_THEME;
77
+ const theme = { palette: PALETTES[raw.palette] ? raw.palette : (existsSync(file) ? 'custom' : DEFAULT_THEME.palette) };
78
+ for (const k of ['bg', 'fg', 'accent', 'glow', 'panel', 'accentInk']) {
79
+ const v = raw[k] ?? (k === 'accent' ? raw.hot : undefined) ?? base[k];
80
+ if (v === undefined) continue;
81
+ if (COLOR.test(String(v))) theme[k] = String(v);
82
+ else { log(`warning: site/theme.json ${k} "${String(v).slice(0, 40)}" is not a colour; left out`); if (base[k]) theme[k] = base[k]; }
83
+ }
84
+ if (!theme.accentInk) {
85
+ const l = luminance(theme.accent);
86
+ if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff';
87
+ }
88
+ const bgL = luminance(theme.bg);
89
+ theme.scheme = raw.scheme === 'light' || (raw.scheme !== 'dark' && bgL !== null && bgL > 0.5) ? 'light' : 'dark';
90
+ for (const k of ['display', 'text', 'mono']) {
91
+ if (raw[k] === undefined) continue;
92
+ if (FAMILY.test(String(raw[k]))) theme[k] = String(raw[k]);
93
+ else log(`warning: site/theme.json ${k} is not a font list; left out`);
94
+ }
95
+ if (raw.radius !== undefined) {
96
+ const r = Number(raw.radius);
97
+ if (Number.isFinite(r) && r >= 0 && r <= 40) theme.radius = r; else log('warning: site/theme.json radius is a number from 0 to 40; left out');
98
+ }
99
+ if (Array.isArray(raw.fonts)) {
100
+ theme.fonts = raw.fonts.slice(0, 6).map((f) => {
101
+ const family = String(f?.family ?? '');
102
+ const src = sitePath(f?.src) ?? httpsUrl(f?.src);
103
+ if (!/^[A-Za-z0-9 _-]{1,60}$/.test(family) || !src) { log(`warning: site/theme.json fonts: "${family.slice(0, 30)}" needs a family (letters, digits, spaces) and a src (a /path in site/public, or https://); left out`); return null; }
104
+ const weight = /^\d{3}( \d{3})?$/.test(String(f.weight ?? '')) ? String(f.weight) : '400';
105
+ const style = f.style === 'italic' ? 'italic' : 'normal';
106
+ return { family, src, weight, style };
107
+ }).filter(Boolean);
108
+ if (!theme.fonts.length) delete theme.fonts;
109
+ }
110
+ for (const k of ['mark', 'icon', 'social']) {
111
+ if (raw[k] === undefined) continue;
112
+ const v = sitePath(raw[k]) ?? httpsUrl(raw[k]);
113
+ if (v) theme[k] = v; else log(`warning: site/theme.json ${k} is a /path in site/public or an https:// address; left out`);
114
+ }
115
+ if (typeof raw.wordmark === 'string') theme.wordmark = raw.wordmark.slice(0, 40);
116
+ if (raw.uppercase === false) theme.uppercase = false;
117
+ return theme;
118
+ }
119
+
120
+ /** What `homie-studio new` writes into site/theme.json: the studio's palette, spelled out so it can be changed. */
121
+ export function themeFile(slug) {
122
+ const palette = paletteFor(slug);
123
+ return `${JSON.stringify({ palette, ...PALETTES[palette], display: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', radius: 18 }, null, 2)}\n`;
124
+ }
125
+
126
+ /* ------------------------------------------------------------------ posts */
127
+
128
+ export const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
129
+ const slugOf = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
130
+
131
+ /** `---` frontmatter: `key: value` lines (quotes optional); returns [fields, body]. */
132
+ export function frontmatter(text) {
133
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
134
+ if (!m) return [{}, text];
135
+ const fields = {};
136
+ for (const line of m[1].split(/\r?\n/)) {
137
+ const kv = /^([A-Za-z][A-Za-z0-9_-]{0,30})\s*:\s*(.*)$/.exec(line);
138
+ if (!kv) continue;
139
+ let v = kv[2].trim();
140
+ if (/^(["']).*\1$/.test(v)) v = v.slice(1, -1);
141
+ if (v === 'true' || v === 'false') v = v === 'true';
142
+ fields[kv[1].toLowerCase()] = v;
143
+ }
144
+ return [fields, text.slice(m[0].length)];
145
+ }
146
+
147
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
148
+ /** A post's date as an ISO timestamp (a bare day is midnight UTC), or null. */
149
+ export function isoDate(value) {
150
+ const m = DAY.exec(String(value ?? '').trim());
151
+ if (!m) return null;
152
+ const t = Date.parse(m[4] ? String(value).trim().replace(' ', 'T') + (m[7] ? '' : 'Z') : `${m[1]}-${m[2]}-${m[3]}T00:00:00Z`);
153
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
154
+ }
155
+
156
+ /**
157
+ * posts/*.md, newest first. The filename is the address (`posts/2026-09-30-we-are-live.md` is
158
+ * `/posts/we-are-live/`, dated by its prefix unless the frontmatter says `date:`). A post links to at most one
159
+ * game, one song and one video of this studio (frontmatter `game:`, `song:`, `video:`); `draft: true` keeps it
160
+ * off the site. The record beside each (`record`) is the shape a post takes when it is published as an atproto
161
+ * record later: nothing in it depends on this site's HTML.
162
+ */
163
+ export function readPosts(root, { games = [], songs = [], videos = [], log = () => {} } = {}) {
164
+ const dir = join(root, 'posts');
165
+ const posts = [];
166
+ const skipped = [];
167
+ if (!existsSync(dir)) return { posts, skipped };
168
+ const seen = new Set();
169
+ for (const name of readdirSync(dir).sort()) {
170
+ if (!/\.md$/i.test(name) || /^readme\.md$/i.test(name) || /^[._]/.test(name)) continue;
171
+ const file = join(dir, name);
172
+ if (!statSync(file).isFile()) continue;
173
+ if (statSync(file).size > MAX_POST) { skipped.push({ post: name, why: 'over 256 KB: split it, and put pictures in site/public' }); continue; }
174
+ const [fm, body] = frontmatter(readFileSync(file, 'utf8'));
175
+ const stem = name.replace(/\.md$/i, '');
176
+ const prefix = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(stem);
177
+ const slug = fm.slug && POST_SLUG.test(String(fm.slug)) ? String(fm.slug) : slugOf(prefix ? prefix[2] : stem);
178
+ if (!POST_SLUG.test(slug)) { skipped.push({ post: name, why: 'its name gives no address: name it like 2026-09-30-we-are-live.md' }); continue; }
179
+ if (seen.has(slug)) { skipped.push({ post: name, why: `a second post with the address /posts/${slug}/` }); continue; }
180
+ if (fm.draft === true) { skipped.push({ post: name, why: 'a draft (draft: true)' }); continue; }
181
+ const date = isoDate(fm.date) ?? (prefix ? isoDate(prefix[1]) : null);
182
+ if (!date) { skipped.push({ post: name, why: 'no date: add `date: 2026-09-30` to its frontmatter, or start its name with the day' }); continue; }
183
+ const { html, text } = renderMarkdown(body);
184
+ const heading = /^#\s+(.+)$/m.exec(body)?.[1];
185
+ const title = String(fm.title ?? heading ?? '').trim().slice(0, 140);
186
+ if (!title) { skipped.push({ post: name, why: 'no title: add `title:` to its frontmatter' }); continue; }
187
+ const links = {};
188
+ for (const [kind, list, key] of [['game', games, 'id'], ['song', songs, 'slug'], ['video', videos, 'slug']]) {
189
+ const want = fm[kind];
190
+ if (!want) continue;
191
+ if (list.some((x) => x[key] === want)) links[kind] = String(want);
192
+ else log(`warning: posts/${name} links ${kind} "${want}", which this studio's site does not have; the link is left out`);
193
+ }
194
+ const image = fm.image ? (sitePath(fm.image) ?? httpsUrl(fm.image)) : null;
195
+ if (fm.image && !image) log(`warning: posts/${name} image is a /path on the site or an https:// address; left out`);
196
+ const summary = String(fm.summary ?? '').trim().slice(0, 300) || (text.length > 220 ? `${text.slice(0, 217).replace(/\s+\S*$/, '')}…` : text);
197
+ seen.add(slug);
198
+ posts.push({
199
+ slug, title, date, updated: isoDate(fm.updated) ?? null, summary, image, author: fm.author ? String(fm.author).slice(0, 80) : null, links, html,
200
+ record: {
201
+ $type: 'rocks.homie.studio.post',
202
+ title, text: body.trim().slice(0, 30000), createdAt: date,
203
+ ...(summary ? { summary } : {}),
204
+ links: Object.entries(links).map(([kind, id]) => ({ kind, id })),
205
+ },
206
+ });
207
+ }
208
+ posts.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)));
209
+ for (const s of skipped) log(`posts/${s.post}: ${s.why}`);
210
+ return { posts, skipped };
211
+ }
212
+
213
+ /* ------------------------------------------------------------------ site/ overrides */
214
+
215
+ export const PARTIALS = ['head', 'header', 'footer', 'home', 'game', 'post'];
216
+ /** Paths a page of the studio's own can never take: the rooms, the game files, the API and the owner's pages. */
217
+ const RESERVED_PAGE = /^\/(?:api|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
218
+ /** Top-level names site/public must not take (the build's own output). */
219
+ const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media']);
220
+
221
+ function walk(dir, fn, rel = '') {
222
+ for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
223
+ if (e.name.startsWith('.')) continue;
224
+ const r = rel ? `${rel}/${e.name}` : e.name;
225
+ if (e.isDirectory()) walk(dir, fn, r); else if (e.isFile()) fn(r);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * site/public, site/pages, site/partials and site/theme.css into site/dist. Returns what the Worker needs:
231
+ * the page paths (served from _site/pages), the partials' text, the extra CSS, and what was left out.
232
+ */
233
+ export function buildSiteFiles(root, dist, { gameIds = [], log = () => {} } = {}) {
234
+ const site = join(root, 'site');
235
+ const res = { pages: [], partials: {}, css: null, public: [], skipped: [] };
236
+ const skip = (what, why) => { res.skipped.push({ what, why }); log(`${what}: ${why}`); };
237
+
238
+ const pub = join(site, 'public');
239
+ if (existsSync(pub)) {
240
+ walk(pub, (rel) => {
241
+ const top = rel.split('/')[0];
242
+ if (RESERVED_PUBLIC.has(top)) return skip(`site/public/${rel}`, `/${top}/ is the site's own; put it somewhere else`);
243
+ const bytes = statSync(join(pub, rel)).size;
244
+ if (bytes > MAX_FILE) return skip(`site/public/${rel}`, `${Math.round(bytes / 1048576)} MiB is over the 25 MiB a site serves itself`);
245
+ mkdirSync(dirname(join(dist, rel)), { recursive: true });
246
+ cpSync(join(pub, rel), join(dist, rel));
247
+ res.public.push(`/${rel}`);
248
+ });
249
+ }
250
+
251
+ const pages = join(site, 'pages');
252
+ if (existsSync(pages)) {
253
+ walk(pages, (rel) => {
254
+ if (!/\.html$/.test(rel)) return skip(`site/pages/${rel}`, 'only .html pages go in site/pages (files go in site/public)');
255
+ const path = `/${rel.replace(/(^|\/)index\.html$/, '$1').replace(/\.html$/, '/')}`;
256
+ if (RESERVED_PAGE.test(path)) return skip(`site/pages/${rel}`, `${path} belongs to the site itself (rooms, game files, the API)`);
257
+ if (!/^\/([a-z0-9][a-z0-9._-]*\/)*$/.test(path)) return skip(`site/pages/${rel}`, 'a page address is lowercase letters, digits, dots and hyphens');
258
+ const bytes = statSync(join(pages, rel)).size;
259
+ if (bytes > MAX_FILE) return skip(`site/pages/${rel}`, 'over 25 MiB');
260
+ const dest = join(dist, '_site', 'pages', path, 'index.html');
261
+ mkdirSync(dirname(dest), { recursive: true });
262
+ cpSync(join(pages, rel), dest);
263
+ res.pages.push(path);
264
+ });
265
+ }
266
+
267
+ const partials = join(site, 'partials');
268
+ if (existsSync(partials)) {
269
+ for (const name of readdirSync(partials).sort()) {
270
+ if (!name.endsWith('.html') || name.startsWith('.')) continue;
271
+ const key = name.slice(0, -5);
272
+ const known = PARTIALS.includes(key) || (/^game-[a-z0-9][a-z0-9-]{0,39}$/.test(key) && gameIds.includes(key.slice(5)));
273
+ if (!known) { skip(`site/partials/${name}`, `not a partial the pages use (${PARTIALS.join(', ')}, or game-<id> for one of this studio's games)`); continue; }
274
+ const text = readFileSync(join(partials, name), 'utf8');
275
+ if (text.length > MAX_PARTIAL) { skip(`site/partials/${name}`, 'over 64 KB'); continue; }
276
+ res.partials[key] = text;
277
+ }
278
+ }
279
+
280
+ const css = join(site, 'theme.css');
281
+ if (existsSync(css)) {
282
+ const text = readFileSync(css, 'utf8');
283
+ if (text.length > MAX_CSS) skip('site/theme.css', 'over 256 KB');
284
+ else res.css = text.replace(/<\/(style)/gi, '<\\/$1');
285
+ }
286
+ res.pages.sort();
287
+ return res;
288
+ }
289
+
290
+ /* ------------------------------------------------------------------ a game's landing */
291
+
292
+ const inside = (base, rel) => {
293
+ if (typeof rel !== 'string' || !rel || rel.startsWith('/') || /^[a-z]+:/i.test(rel)) return null;
294
+ const abs = resolve(base, normalize(rel));
295
+ return abs.startsWith(`${resolve(base)}${sep}`) ? abs : null;
296
+ };
297
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
298
+ const str = (v, n) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, n) : null);
299
+ const IMAGE = /\.(jpe?g|png|webp|avif|gif)$/i;
300
+ const VIDEO = /\.(mp4|webm|m4v|mov)$/i;
301
+
302
+ /** Hero footage by the house brands' names: hero/wide.mp4, hero/tall.mp4, their .av1.mp4 cuts, hero/wide.jpg, hero/tall.jpg. */
303
+ const HERO_NAMES = { wide: ['wide.mp4', 'wide.webm'], tall: ['tall.mp4', 'tall.webm'], wideAv1: ['wide.av1.mp4'], tallAv1: ['tall.av1.mp4'], wideImage: ['wide.jpg', 'wide.webp', 'wide.png', 'wide.avif'], tallImage: ['tall.jpg', 'tall.webp', 'tall.png', 'tall.avif'] };
304
+
305
+ /**
306
+ * Everything a game's landing shows, from the game's own files: game.json (`landing` block, name, blurb, players,
307
+ * round length, cover, share), credits.json, hero/ footage, and the studio's trailer and music for it. Files the
308
+ * landing shows are copied into the game's built folder under _landing/ (the cover beside the build when the
309
+ * build left it out), so the site serves them. Nothing is invented: a landing without footage uses the cover.
310
+ */
311
+ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } = {}) {
312
+ const L = g.landing && typeof g.landing === 'object' ? g.landing : {};
313
+ const credits = readJson(join(g.dir, 'credits.json')) ?? {};
314
+ const landDir = join(out, '_landing');
315
+ const copied = new Map();
316
+ const serve = (abs, label) => {
317
+ if (!abs || !existsSync(abs) || !statSync(abs).isFile()) return null;
318
+ if (copied.has(abs)) return copied.get(abs);
319
+ const bytes = statSync(abs).size;
320
+ if (bytes > MAX_FILE) { log(`warning: games/${g.id}: ${label} is ${Math.round(bytes / 1048576)} MiB, over the 25 MiB a site serves itself; the landing leaves it out`); return null; }
321
+ // Already in the game's build (a static game copies its whole folder): served from there, not twice.
322
+ const rel = relative(g.dir, abs);
323
+ const beside = join(out, rel);
324
+ if (!rel.startsWith('..') && existsSync(beside) && statSync(beside).size === bytes) {
325
+ const url = `/games/${g.id}/${rel.split(sep).map(encodeURIComponent).join('/')}`;
326
+ copied.set(abs, url);
327
+ return url;
328
+ }
329
+ mkdirSync(landDir, { recursive: true });
330
+ let name = basename(abs).replace(/[^A-Za-z0-9._-]/g, '-');
331
+ if ([...copied.values()].some((u) => u.endsWith(`/${name}`))) name = `${copied.size}-${name}`;
332
+ cpSync(abs, join(landDir, name));
333
+ const url = `/games/${g.id}/_landing/${name}`;
334
+ copied.set(abs, url);
335
+ return url;
336
+ };
337
+ const pick = (explicit, names, label) => {
338
+ if (explicit) {
339
+ const abs = inside(g.dir, explicit);
340
+ const url = abs ? serve(abs, label) : null;
341
+ if (!url) log(`warning: games/${g.id}/game.json landing ${label} "${String(explicit).slice(0, 60)}" is not a file in the game's folder`);
342
+ return url;
343
+ }
344
+ for (const n of names) { const url = serve(join(g.dir, 'hero', n), `hero/${n}`); if (url) return url; }
345
+ return null;
346
+ };
347
+ const H = L.hero && typeof L.hero === 'object' ? L.hero : {};
348
+ const hero = {
349
+ wide: pick(H.video ?? H.wide, HERO_NAMES.wide, 'hero video'),
350
+ tall: pick(H.tall, HERO_NAMES.tall, 'hero tall video'),
351
+ wideAv1: H.video || H.wide ? null : pick(null, HERO_NAMES.wideAv1, 'hero AV1 video'),
352
+ tallAv1: H.tall ? null : pick(null, HERO_NAMES.tallAv1, 'hero tall AV1 video'),
353
+ wideImage: pick(H.image, HERO_NAMES.wideImage, 'hero image'),
354
+ tallImage: pick(H.tallImage, HERO_NAMES.tallImage, 'hero tall image'),
355
+ };
356
+ // The cover: the game's card everywhere, and the hero still when there is no footage.
357
+ // game.json `cover` is a path in the game's build: already there (a static game, or a bundled game's public/,
358
+ // where the art skill writes it), or beside game.json (copied into the build).
359
+ let cover = null;
360
+ if (g.cover) {
361
+ const built = inside(out, g.cover);
362
+ const beside = inside(g.dir, g.cover);
363
+ const pub = inside(join(g.dir, 'public'), g.cover);
364
+ const from = [beside, pub].find((p) => p && existsSync(p) && statSync(p).isFile());
365
+ if (built && !existsSync(built) && from) { mkdirSync(dirname(built), { recursive: true }); cpSync(from, built); }
366
+ if (built && existsSync(built)) cover = `/games/${g.id}/${relative(out, built).split(sep).map(encodeURIComponent).join('/')}`;
367
+ else log(`warning: games/${g.id}/game.json cover "${g.cover}" is not a file in the game's folder or its public/`);
368
+ }
369
+ // A trailer of this game from videos/manifest.json: the hero's footage when the game has none of its own.
370
+ const forGame = (list) => list.filter((e) => e.for?.game === g.id);
371
+ const trailers = forGame(videos).sort((a, b) => (a.kind === 'trailer' ? -1 : 0) - (b.kind === 'trailer' ? -1 : 0));
372
+ const trailer = trailers[0] ?? null;
373
+ const fileUrl = (e, role) => (e?.files ?? []).find((f) => f.role === role)?.url ?? null;
374
+ if (trailer && !hero.wide && !hero.tall) {
375
+ hero.wide = fileUrl(trailer, 'video');
376
+ hero.tall = fileUrl(trailer, 'vertical');
377
+ hero.fromTrailer = trailer.slug;
378
+ }
379
+ if (!hero.wideImage) hero.wideImage = (trailer && hero.fromTrailer ? fileUrl(trailer, 'poster') : null) ?? cover;
380
+ for (const k of Object.keys(hero)) if (!hero[k]) delete hero[k];
381
+ const focus = /^\d{1,3}% \d{1,3}%$/.test(String(H.focus ?? '')) ? String(H.focus) : null;
382
+ const tint = Number.isFinite(Number(H.tint)) && H.tint !== null && H.tint !== '' ? Math.max(0, Math.min(80, Math.round(Number(H.tint)))) : null;
383
+ const alt = str(H.alt, 300);
384
+
385
+ // How to play: game.json landing.controls, else credits.json controls.
386
+ const controls = {};
387
+ for (const k of ['phone', 'computer', 'tv']) {
388
+ const v = str(L.controls?.[k], 300) ?? str(credits.controls?.[k], 300);
389
+ if (v) controls[k] = v;
390
+ }
391
+ const how = Array.isArray(L.howToPlay) ? L.howToPlay.map((s) => str(s, 240)).filter(Boolean).slice(0, 6) : [];
392
+
393
+ // Credits: who made it (game.json landing.credits), the original it is based on and the parts inside it
394
+ // (credits.json, as a port records them), with the licence texts on /<id>/credits.
395
+ const people = (Array.isArray(L.credits) ? L.credits : Array.isArray(g.credits) ? g.credits : []).slice(0, 24).map((c) => ({
396
+ role: str(c?.role, 60), name: str(c?.name, 80), url: httpsUrl(c?.url),
397
+ })).filter((c) => c.name);
398
+ const o = credits.original && typeof credits.original === 'object' ? credits.original : null;
399
+ const original = o ? {
400
+ title: str(o.title, 120), author: str(o.author, 120), year: str(String(o.year ?? ''), 12), url: httpsUrl(o.url),
401
+ licence: str(o.licence, 60), house: o.house === true,
402
+ } : null;
403
+ const parts = (Array.isArray(credits.parts) ? credits.parts : []).slice(0, 40).map((p) => ({
404
+ what: str(p?.what, 160), author: str(p?.author, 120), url: httpsUrl(p?.url), licence: str(p?.licence, 60), licenceUrl: httpsUrl(p?.licenceUrl), note: str(p?.note, 200),
405
+ })).filter((p) => p.what);
406
+ // The licence texts, for /<id>/credits: CREDITS.md and every licence file credits.json names.
407
+ const texts = [];
408
+ for (const f of ['CREDITS.md', o?.licenceFile, ...(credits.parts ?? []).map((p) => p?.licenceFile)]) {
409
+ if (typeof f !== 'string' || texts.some((t) => t.file === f)) continue;
410
+ const abs = inside(g.dir, f);
411
+ if (!abs || !existsSync(abs) || statSync(abs).size > 256 * 1024) continue;
412
+ const text = readFileSync(abs, 'utf8');
413
+ if (text.includes('\u0000')) continue;
414
+ texts.push({ file: f, text });
415
+ }
416
+ if (texts.length) { mkdirSync(landDir, { recursive: true }); writeFileSync(join(landDir, 'credits.json'), `${JSON.stringify({ v: 1, texts })}\n`); }
417
+
418
+ const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
419
+ return {
420
+ pitch: str(L.pitch, 240) ?? str(credits.tagline, 240),
421
+ kicker: str(L.kicker, 120),
422
+ about: str(L.about, 1200) ?? str(credits.about, 1200),
423
+ controls, how,
424
+ hero: { ...hero, ...(focus ? { focus } : {}), ...(alt ? { alt } : {}), ...(tint !== null ? { tint } : {}) },
425
+ headline: str(L.headline, 120),
426
+ cover,
427
+ words: words && (words.one || words.many) ? words : null,
428
+ credits: { people, original, parts, texts: texts.length },
429
+ trailer: trailer ? trailer.slug : null,
430
+ videos: trailers.map((e) => e.slug),
431
+ music: forGame(songs).map((e) => e.slug),
432
+ source: g.share?.source !== false,
433
+ tv: g.screen?.tv !== false && L.tv !== false,
434
+ theme: L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null,
435
+ };
436
+ }
437
+
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts']);
11
+ const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.6.0",
4
- "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), song and video pages from the music/ and videos/ manifests, optional R2 storage for large media, deploy, and the homie.rocks directory.",
3
+ "version": "0.7.0",
4
+ "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://homie.rocks/studio/",
@@ -37,6 +37,7 @@
37
37
  "worker/",
38
38
  "starters/",
39
39
  "media/",
40
+ "site/",
40
41
  "README.md",
41
42
  "LICENSE",
42
43
  "NOTICE"
package/site/SITE.md ADDED
@@ -0,0 +1,128 @@
1
+ # The studio site
2
+
3
+ `npm run build` (`homie-studio build`) makes a studio's whole site from the studio's own folder, and the
4
+ studio's Worker serves it. Every studio site has the same sections as homie.rocks, in the studio's own look,
5
+ and every game gets a landing page. Anything the studio puts in `site/` wins.
6
+
7
+ ## Sections
8
+
9
+ | Address | What it is | Shown when |
10
+ | --- | --- | --- |
11
+ | `/` | Home: the featured game as a full-bleed hero with Play, the live rooms, the games, the latest posts, videos and music | always |
12
+ | `/games/` | Every game, with its cover, pitch, players playing now, Play and About | the studio has a game |
13
+ | `/music/`, `/videos/` | Songs and videos (`media/MEDIA.md`) | the studio has one published |
14
+ | `/rooms/` | Every public room playing now across the studio's games, each with Join (`/api/rooms` is the same as JSON) | the studio has a game |
15
+ | `/posts/` | The studio's posts, newest first, with `/posts/feed.xml` (Atom) and `/posts/feed.json` (JSON Feed) | the studio has a post |
16
+
17
+ A section with nothing in it has no tab, and its address answers 404. Every page ends with a
18
+ "Made with Homie" link to https://homie.rocks/studio/. studio.json may add `"tagline"` (one line, on Home and in
19
+ the feeds) and `"site": { "featured": "<game id>" }` (the game on Home; otherwise the first with its own footage).
20
+
21
+ ## A game's landing: `/<id>/`
22
+
23
+ Made from the game's own files, nothing invented:
24
+
25
+ - **The hero**, full-bleed: the game's footage when it has some, else its cover (or key art) moving slowly, tinted
26
+ with the studio's colour so the words read. Footage is looked for in this order: `landing.hero` in game.json;
27
+ `games/<id>/hero/` by the house brands' names (`wide.mp4` and `tall.mp4`, their AV1 cuts `wide.av1.mp4` and
28
+ `tall.av1.mp4`, stills `wide.jpg` and `tall.jpg`); a trailer in `videos/manifest.json` whose `for.game` is the
29
+ game (its 16:9 `video`, 9:16 `vertical` and `poster`). A loop of 8 to 15 s under 3 MB each, muted, plays best.
30
+ - **The pitch** (`landing.pitch`, else the blurb), a big **Play** button that drops into a public room at once,
31
+ and the live line ("3 players playing right now", refreshed every 15 s from `/<id>/live`).
32
+ - **Phone, computer, TV**: the controls for each (`landing.controls`, else `credits.json` `controls`), a code a
33
+ phone scans to play, and how to put the game on a TV (`/<id>/tv` shows the room with its own join code).
34
+ - **Live rooms**, each with Join. When studio.json shares its stats (`stats.share`), also "played this week".
35
+ - **How to play**: `landing.about`, `landing.howToPlay` (a few lines), the controls, the room facts.
36
+ - **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a port, the original, its
37
+ author and licence and every part inside it (`credits.json`, as the port skill writes it), with the full
38
+ licence texts on `/<id>/credits`; and Homie's open engine.
39
+ - **Make a game like this**: the two commands that install the Homie plugin and the words to say. When the game
40
+ shares its source (the default; `"share": { "source": false }` keeps it closed) the words remix it from
41
+ `/games/<id>/source.json`, and the button goes to `https://homie.rocks/studio/?remix=<that address>`.
42
+
43
+ game.json's `landing` block, every key optional:
44
+
45
+ ```json
46
+ "landing": {
47
+ "pitch": "Blast rocks, not friends.",
48
+ "kicker": "A line above the title",
49
+ "headline": "The How to play heading",
50
+ "about": "A paragraph about the game.",
51
+ "howToPlay": ["Steer with the stick", "Fire at the edge"],
52
+ "controls": { "phone": "Stick left, FIRE right", "computer": "Arrows and Space", "tv": "Phones are the pads" },
53
+ "players": { "one": "pilot", "many": "pilots" },
54
+ "hero": { "video": "hero/wide.mp4", "tall": "hero/tall.mp4", "image": "hero/wide.jpg", "tallImage": "hero/tall.jpg",
55
+ "alt": "What the footage shows", "focus": "50% 40%", "tint": 30 },
56
+ "credits": [{ "role": "Music", "name": "Low Tide", "url": "https://example.com" }],
57
+ "theme": { "accent": "#ff3bd4", "glow": "#00eaff" },
58
+ "tv": true
59
+ }
60
+ ```
61
+
62
+ ## Posts: `posts/*.md`
63
+
64
+ One markdown file each. The name is the address, `posts/2026-09-30-we-are-live.md` is `/posts/we-are-live/`, dated
65
+ by its prefix unless the frontmatter says `date:` (a day, or a day and a time, UTC).
66
+
67
+ ```markdown
68
+ ---
69
+ title: We are live
70
+ date: 2026-09-30
71
+ summary: One line for the cards and the feeds.
72
+ image: /games/crown-thief/cover.jpg
73
+ game: crown-thief
74
+ song: theme
75
+ video: trailer
76
+ author: The studio
77
+ draft: false
78
+ ---
79
+
80
+ The body.
81
+ ```
82
+
83
+ `game:`, `song:` and `video:` link one of the studio's own (a link to one the site does not have is left out,
84
+ and the build says so); the post shows each as a card with Play, Listen or Watch. `draft: true` keeps it off the
85
+ site. README.md and names starting with `_` or `.` are not posts.
86
+
87
+ The markdown is a safe subset, rendered once at build time: headings (`#` is the page's second level), paragraphs,
88
+ **bold**, *italic*, ~~struck~~, `code`, fenced code, links, images, lists, quotes and rules. Raw HTML is shown as
89
+ text, and a link or an image goes only to `https://…` or a path on the site.
90
+
91
+ Each post is also a record (`/posts/feed.json`, `_homie.record`): `{ "$type": "rocks.homie.studio.post", title,
92
+ text (the markdown), createdAt, summary, links: [{ kind, id }] }`, the shape it keeps when studios publish posts to
93
+ atproto later. `/.well-known/homie-studio.json` lists the latest posts for the homie.rocks directory.
94
+
95
+ ## What `site/` overrides
96
+
97
+ | Put it in | What it does |
98
+ | --- | --- |
99
+ | `site/theme.json` | The tokens every page uses: `bg`, `fg`, `accent`, `glow`, `panel`, `accentInk` (colours: `#hex`, `rgb()`, `hsl()`, `oklch()`), `display`, `text`, `mono` (font lists), `fonts` (`[{ "family", "src": "/fonts/x.woff2", "weight": "400 800" }]`, files in `site/public`), `radius` (0 to 40), `mark` (a logo), `icon`, `social` (the share picture), `wordmark`, `uppercase: false`, `scheme: "light"`. Or `palette`: neon, dock, gold, acid, ember, orchid, tide, candy. A value that is not what it should be is dropped, with a warning. |
100
+ | `site/theme.css` | CSS after the site's own, on every generated page: restyle anything. |
101
+ | `site/partials/<name>.html` | A piece of every generated page: `head` (in `<head>`), `header` (the top line and its tabs), `footer` (the "Made with Homie" footer), `home` (a band on Home), `game` (a band on every landing), `game-<id>` (a band on one), `post` (after a post). `{{studio.name}}`, `{{game.name}}`, `{{game.id}}`, `{{game.play}}`, `{{year}}` are filled in. A band partial sits in one of the page's bands, unless it is its own `<section class="band">`. |
102
+ | `site/pages/<path>/index.html` (or `site/pages/<path>.html`) | A whole page at `/<path>/`, served as it is, instead of the generated one (`site/pages/index.html` is Home, `site/pages/<id>/index.html` a game's landing) or beside them (`/about/`). It may borrow the site's parts: `<!-- homie:style -->`, `<!-- homie:header -->`, `<!-- homie:footer -->`, `<!-- homie:script -->`. The rooms, the game files, the API and the owner's pages are never a page's. |
103
+ | `site/public/` | Files served as they are at the same path: fonts, a logo, hero footage, a page's pictures. |
104
+ | `site/src/worker.mjs` | The Worker. A studio that keeps a whole brand site of its own (with its own routes) wraps the template's Worker here, as the house brands do; everything it does not answer goes to the template. |
105
+
106
+ A studio with its own landing in `site/` keeps it: the template never replaces what the studio made.
107
+
108
+ ## The play page
109
+
110
+ `/<id>/play` puts the visitor in a public room at once and writes the room into the address, so a reload comes
111
+ back to it and a copied address brings a friend into it. A small room button at the edge (game.json
112
+ `"screen": { "share": "top-left" }` moves it off the game's own HUD; top right by default) opens:
113
+ **Invite** (the phone's share sheet, or the link copied), **Big screen** (`/<id>/tv` of this room, on another
114
+ screen) and the room's code. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
115
+ on the page, never swapped for a public room. A player who typed no name gets a two-word handle.
116
+
117
+ The game runs in a sandboxed frame with an opaque origin, which is why its allow list delegates with `*`
118
+ (`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
119
+ SecurityError), so a controller would not work.
120
+
121
+ ## Headers
122
+
123
+ Every HTML answer is `no-transform` (a custom domain's edge injects nothing: Web Analytics' automatic beacon
124
+ posts to a `/cdn-cgi/rum` a Worker does not serve). Generated pages are never framed (`x-frame-options: DENY`,
125
+ `frame-ancestors 'none'`), load scripts only from the site, and send `referrer-policy:
126
+ strict-origin-when-cross-origin` (another studio's stats see which site sent a visitor, never the path). The
127
+ play page and the game may be framed by the site itself and by the https origins studio.json lists in
128
+ `"site": { "frameAncestors": ["https://…"] }`.