@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/README.md CHANGED
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
13
- npx -y --package=https://homie.rocks/npm/homie-studio-0.6.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.7.0.tgz homie-studio new ./night-owls --name "Night Owls"
14
14
  cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
@@ -21,6 +21,21 @@ npx homie-studio publish # the homie.rocks directo
21
21
  npx homie-studio stats # the studio's own numbers, for its owner
22
22
  ```
23
23
 
24
+ ## The site: the hub's shape, and a landing for every game
25
+
26
+ Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
27
+ featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
28
+ joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
29
+ page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
30
+ pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
31
+ live rooms, credits (the original and its licence for a port), and "Make a game like this". Every page ends with
32
+ "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
33
+ files. `site/SITE.md` says all of it.
34
+
35
+ The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
36
+ screen and the room code. Every HTML answer is `no-transform`, and the site's pages are never framed by another
37
+ site.
38
+
24
39
  ## Rooms of up to 32
25
40
 
26
41
  Every room of a game has the seats its netplay manifest names: game.json's `netplay`
@@ -97,6 +112,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
97
112
  | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
98
113
  | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
99
114
  | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
115
+ | `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
100
116
  | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
101
117
  | `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
102
118
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
@@ -12,6 +12,8 @@
12
12
  * homie-studio build [<id>]
13
13
  * homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
14
14
  * homie-studio check <id> [--url <site>] [--shots <dir>]
15
+ * homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
16
+ * (the site's pages on a computer and a phone, as pictures, with what is wrong)
15
17
  * homie-studio port plan <game folder>
16
18
  * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
17
19
  * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
@@ -36,6 +38,7 @@ import { build } from '../lib/build.mjs';
36
38
  import { check } from '../lib/check.mjs';
37
39
  import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
38
40
  import { publish } from '../lib/directory.mjs';
41
+ import { look } from '../lib/look.mjs';
39
42
  import { importPort, planPort } from '../lib/port.mjs';
40
43
  import { portCheck } from '../lib/port-check.mjs';
41
44
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
@@ -62,7 +65,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
62
65
 
63
66
  function print(result) {
64
67
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
65
- if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
68
+ if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
66
69
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
67
70
  const lines = [];
68
71
  switch (result.command) {
@@ -81,7 +84,12 @@ function print(result) {
81
84
  case 'build':
82
85
  lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
83
86
  if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
87
+ for (const l of result.landings ?? []) lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (add a cover or hero/ footage)'}${l.credits ? ', credits' : ''}${l.source ? ', "Make a game like this" with its source' : ''}`);
88
+ if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
89
+ if (result.pages?.length || result.partials?.length) lines.push(`The studio's own: ${[...(result.pages ?? []).map((x) => `page ${x}`), ...(result.partials ?? []).map((x) => `partial ${x}`)].join(', ')}`);
84
90
  for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
91
+ for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
92
+ for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
85
93
  break;
86
94
  case 'media list':
87
95
  for (const kind of ['music', 'videos']) {
@@ -147,6 +155,9 @@ function print(result) {
147
155
  case 'stats share':
148
156
  lines.push(result.message);
149
157
  break;
158
+ case 'look':
159
+ lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
160
+ break;
150
161
  case 'check':
151
162
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
152
163
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -193,6 +204,19 @@ async function main() {
193
204
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
194
205
  return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
195
206
  }
207
+ if (cmd === 'look') {
208
+ const url = flags.get('url') ?? siteUrl(root);
209
+ if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
210
+ let paths = positional.slice(1).map((p) => (p.startsWith('/') ? p : `/${p}`));
211
+ if (!paths.length) {
212
+ let cat = null;
213
+ try { cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')); } catch { /* not built */ }
214
+ const games = cat?.games?.map((g) => g.id) ?? listGames(root).map((g) => g.id);
215
+ paths = ['/', ...games.map((id) => `/${id}/`), ...(games.length ? ['/games/', '/rooms/'] : []), ...(cat?.posts?.length ? ['/posts/', `/posts/${cat.posts[0].slug}/`] : [])];
216
+ }
217
+ const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
218
+ return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
219
+ }
196
220
  if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
197
221
  if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
198
222
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
@@ -304,7 +328,7 @@ try {
304
328
  if (result && result.command !== 'help') print(result);
305
329
  if (result?.ok === false) process.exitCode = 1;
306
330
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
307
- if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
331
+ if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
308
332
  } catch (error) {
309
333
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
310
334
  process.exitCode = 1;
package/lib/build.mjs CHANGED
@@ -5,8 +5,12 @@
5
5
  * site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
6
6
  * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
7
7
  * site/dist/games/<id>/... everything in games/<id>/public/
8
- * site/dist/games.json { studio, games[], songs[], videos[] } from studio.json, game.json
9
- * files and the music/ and videos/ manifests (media/MEDIA.md)
8
+ * site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
9
+ * game.json files, the music/ and videos/ manifests (media/MEDIA.md),
10
+ * posts/*.md and the studio's site/ folder (site/SITE.md)
11
+ * site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
12
+ * site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
13
+ * site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
10
14
  * site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
11
15
  *
12
16
  * game.json "build" picks how a game becomes files (a ported game keeps its own shape):
@@ -22,6 +26,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statS
22
26
  import { createRequire } from 'node:module';
23
27
  import { join, relative } from 'node:path';
24
28
  import { buildMedia } from './media.mjs';
29
+ import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
25
30
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
26
31
  import { SEAT_MAX } from '../worker/seats.mjs';
27
32
  import { STUDIO_VERSION } from './version.mjs';
@@ -173,28 +178,49 @@ export async function build(root, { only = null, log = () => {} } = {}) {
173
178
  else {
174
179
  try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
175
180
  }
181
+ // The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
182
+ const theme = readTheme(root, { log });
183
+ const shown = all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html')));
184
+ const rows = shown.map((g) => {
185
+ // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
186
+ const net = netplayOf(g, join(dist, 'games', g.id));
187
+ const { min, max } = seatsFor(g, net);
188
+ return {
189
+ id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
190
+ roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
191
+ ...(g.screen ? { screen: g.screen } : {}),
192
+ landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
193
+ };
194
+ });
195
+ const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
196
+ const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
197
+ mkdirSync(join(dist, '_site'), { recursive: true });
198
+ writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
199
+ const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
176
200
  const catalogue = {
177
201
  studio: {
178
202
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
203
+ ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
204
+ theme,
205
+ site: {
206
+ ...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
207
+ ...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
208
+ },
179
209
  // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
180
210
  ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
181
211
  },
182
- games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => {
183
- // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
184
- const net = netplayOf(g, join(dist, 'games', g.id));
185
- const { min, max } = seatsFor(g, net);
186
- return {
187
- id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
188
- roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
189
- ...(g.screen ? { screen: g.screen } : {}),
190
- };
191
- }),
212
+ games: rows,
192
213
  songs: media.songs,
193
214
  videos: media.videos,
215
+ // Summaries only; each post's HTML is in _site/posts.json.
216
+ posts: posts.map(({ html, record, ...p }) => p),
217
+ site: { pages: site.pages, partials: site.partials, ...(site.css ? { css: site.css } : {}) },
194
218
  };
195
219
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
196
220
  return {
197
221
  ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
198
222
  songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
223
+ posts: posts.map((p) => p.slug), postsSkipped, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
224
+ landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
199
225
  };
200
226
  }
package/lib/look.mjs ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `homie-studio look [<path>...] --url <site> [--shots <dir>]` — the studio's pages as a stranger sees them:
3
+ * each page on a computer (1440 x 900), a phone held upright (390 x 844) and the phone turned sideways
4
+ * (844 x 390), scrolled to the end so everything that rises into view has, then shot whole and above the fold.
5
+ * Measured on every shot: the answer's status, anything wider than the screen, whether the Play button is on the
6
+ * first screen (Home and a game's landing), pictures that did not load, footage that did not play, and script errors. Pictures go to
7
+ * `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
8
+ * (the studio's stats leave them out).
9
+ */
10
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const CHROMES = [
15
+ process.env.CHROME_PATH,
16
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
17
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
18
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
19
+ ].filter(Boolean);
20
+
21
+ export const LOOK_DEVICES = {
22
+ computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
23
+ phone: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
24
+ sideways: { width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
25
+ };
26
+
27
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
+ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi, '-') || 'home';
29
+
30
+ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
31
+ if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
32
+ const chrome = CHROMES.find((p) => existsSync(p));
33
+ if (!chrome) return { ok: false, command: 'look', why: 'no Chrome found (set CHROME_PATH)' };
34
+ let puppeteer;
35
+ try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
36
+ const base = String(url).replace(/\/+$/, '');
37
+ mkdirSync(shots, { recursive: true });
38
+ const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
39
+ const browser = await puppeteer.launch({
40
+ executablePath: chrome, headless: true, userDataDir: profile,
41
+ args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
42
+ });
43
+ const rows = [];
44
+ try {
45
+ for (const path of paths) {
46
+ for (const device of devices) {
47
+ const page = await browser.newPage();
48
+ const errors = [];
49
+ page.on('pageerror', (e) => errors.push(String(e?.message ?? e).slice(0, 200)));
50
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); });
51
+ await page.setViewport(LOOK_DEVICES[device]);
52
+ await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
53
+ const t0 = Date.now();
54
+ const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 45_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
55
+ const ms = Date.now() - t0;
56
+ await sleep(900);
57
+ const fold = await page.evaluate(() => {
58
+ // Home and a game's landing lead with Play; other pages (a post, the rooms) are not judged on it.
59
+ if (!['home', 'landing'].includes(document.body?.dataset?.page ?? '')) return { play: null };
60
+ const play = [...document.querySelectorAll('.play')].find((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
61
+ const r = play?.getBoundingClientRect();
62
+ return { play: r ? r.bottom <= innerHeight && r.top >= 0 : false };
63
+ });
64
+ const foldFile = join(shots, `${nameOf(path)}-${device}-fold.png`);
65
+ await page.screenshot({ path: foldFile });
66
+ await page.evaluate(async () => { for (let y = 0; y < document.documentElement.scrollHeight; y += Math.round(innerHeight * 0.7)) { window.scrollTo({ top: y, behavior: 'instant' }); await new Promise((r) => setTimeout(r, 110)); } window.scrollTo({ top: 0, behavior: 'instant' }); });
67
+ await sleep(900);
68
+ const facts = await page.evaluate(() => ({
69
+ title: document.title,
70
+ wide: [...document.querySelectorAll('body *')].filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > innerWidth + 1 && !e.closest('[data-hero-media],.nav,pre,.hero-media'); }).slice(0, 5).map((e) => `${e.tagName.toLowerCase()}${e.className ? `.${String(e.className).split(' ')[0]}` : ''}`),
71
+ brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getAttribute('src')).map((i) => i.getAttribute('src')).slice(0, 5),
72
+ footage: [...document.querySelectorAll('[data-hero-media] video')].map((v) => ({ playing: !v.paused && v.readyState >= 2, src: (v.currentSrc || '').split('/').pop() })),
73
+ height: document.documentElement.scrollHeight,
74
+ }));
75
+ const file = join(shots, `${nameOf(path)}-${device}.png`);
76
+ await page.screenshot({ path: file, fullPage: true });
77
+ const row = {
78
+ path, device, status: res?.status() ?? null, ms, title: facts.title, height: facts.height, playAboveFold: fold.play,
79
+ tooWide: facts.wide, brokenImages: facts.brokenImages, footage: facts.footage, errors, shot: file, fold: foldFile,
80
+ };
81
+ const bad = [];
82
+ if (!row.status || row.status >= 400) bad.push(`answered ${row.status}`);
83
+ if (row.tooWide.length) bad.push(`wider than the screen: ${row.tooWide.join(', ')}`);
84
+ if (row.playAboveFold === false && device !== 'sideways') bad.push('Play is not on the first screen');
85
+ if (row.brokenImages.length) bad.push(`pictures that did not load: ${row.brokenImages.join(', ')}`);
86
+ if (row.footage.some((f) => !f.playing)) bad.push('the hero footage did not play');
87
+ if (errors.length) bad.push(`${errors.length} script error(s): ${errors[0]}`);
88
+ row.ok = bad.length === 0;
89
+ row.problems = bad;
90
+ rows.push(row);
91
+ log(`${row.ok ? 'ok ' : 'LOOK'} ${path} ${device}: ${row.status} in ${ms} ms${bad.length ? ` · ${bad.join(' · ')}` : ''}`);
92
+ await page.close();
93
+ }
94
+ }
95
+ } finally {
96
+ await browser.close().catch(() => {});
97
+ rmSync(profile, { recursive: true, force: true });
98
+ }
99
+ writeFileSync(join(shots, 'look.json'), `${JSON.stringify(rows, null, 2)}\n`);
100
+ return { ok: rows.every((r) => r.ok), command: 'look', url: base, shots, rows };
101
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Markdown for a studio's posts, rendered once at build time into HTML the site serves as it is.
3
+ *
4
+ * A small, safe subset (site/SITE.md lists it): headings, paragraphs, **bold**, *italic*, ~~struck~~,
5
+ * `code`, fenced code blocks, links, images, lists, quotes and rules. Everything else is text: raw HTML in
6
+ * a post is escaped, never passed through, and a link or image goes only to an https:// address or a path
7
+ * on the studio's own site (`/…`), so a post can never run a script or point at `javascript:`.
8
+ */
9
+
10
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
11
+ export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
12
+
13
+ /** A link or image address a post may use: https://…, or a path on this site. Anything else is null. */
14
+ export function safeUrl(value, { images = false } = {}) {
15
+ const url = String(value ?? '').trim();
16
+ if (!url || /[\s<>"'`\\]/.test(url)) return null;
17
+ if (url.startsWith('/') && !url.startsWith('//')) return url;
18
+ if (!images && /^#[A-Za-z0-9_-]{1,64}$/.test(url)) return url;
19
+ try {
20
+ const u = new URL(url);
21
+ return u.protocol === 'https:' ? u.href : null;
22
+ } catch { return null; }
23
+ }
24
+
25
+ const HOLD = /\u0000(\d+)\u0000/g;
26
+ const unescapeHtml = (v) => v.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&');
27
+
28
+ function emphasis(s) {
29
+ return s
30
+ .replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (m, a, b) => `<strong>${a ?? b}</strong>`)
31
+ .replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?!\w)/g, (m, pre, t) => `${pre}<em>${t}</em>`)
32
+ .replace(/(^|[^_\w])_([^_\s][^_]*?)_(?!\w)/g, (m, pre, t) => `${pre}<em>${t}</em>`)
33
+ .replace(/~{2}([^~]+)~{2}/g, '<s>$1</s>');
34
+ }
35
+
36
+ /* Inline: code spans, images and links are held aside as finished HTML (so emphasis never reaches inside an
37
+ address), then emphasis and line breaks, then the held pieces go back. */
38
+ function inline(text) {
39
+ const kept = [];
40
+ const hold = (html) => { kept.push(html); return `\u0000${kept.length - 1}\u0000`; };
41
+ let s = String(text).replace(/\u0000/g, '').replace(/`([^`\n]+)`/g, (_, code) => hold(`<code>${escapeHtml(code)}</code>`));
42
+ s = escapeHtml(s);
43
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g, (m, alt, src, title) => {
44
+ const url = safeUrl(unescapeHtml(src), { images: true });
45
+ return url ? hold(`<img src="${escapeHtml(url)}" alt="${alt}" loading="lazy" decoding="async"${title ? ` title="${title}"` : ''}>`) : alt;
46
+ });
47
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, href) => {
48
+ const url = safeUrl(unescapeHtml(href));
49
+ if (!url) return label;
50
+ return hold(`<a href="${escapeHtml(url)}"${/^https:/.test(url) ? ' rel="noopener"' : ''}>${emphasis(label)}</a>`);
51
+ });
52
+ s = s.replace(/&lt;(https:\/\/[^\s&]+)&gt;/g, (m, href) => {
53
+ const url = safeUrl(href);
54
+ return url ? hold(`<a href="${escapeHtml(url)}" rel="noopener">${escapeHtml(url.replace(/^https:\/\//, '').replace(/\/$/, ''))}</a>`) : m;
55
+ });
56
+ s = emphasis(s).replace(/ {2,}\n|\\\n/g, '<br>').replace(/\n/g, ' ');
57
+ for (let guard = 0; guard < 8 && HOLD.test(s); guard++) s = s.replace(HOLD, (_, i) => kept[Number(i)]);
58
+ return s;
59
+ }
60
+
61
+ /**
62
+ * Markdown to HTML. Headings shift down one level (a post's page has its title as the one h1), so `#` is h2.
63
+ * Returns the HTML and the plain text (for a feed's summary and the post record's text).
64
+ */
65
+ export function renderMarkdown(source) {
66
+ const lines = String(source ?? '').replace(/\r\n?/g, '\n').split('\n');
67
+ const out = [];
68
+ let para = [];
69
+ const flush = () => { if (para.length) { out.push(`<p>${inline(para.join('\n'))}</p>`); para = []; } };
70
+ const LIST = /^\s{0,3}([-*+]|\d{1,3}[.)])\s+(.*)$/;
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const line = lines[i];
73
+ const fence = /^(```|~~~)\s*([A-Za-z0-9_+-]{0,20})\s*$/.exec(line);
74
+ if (fence) {
75
+ flush();
76
+ const body = [];
77
+ for (i += 1; i < lines.length && !lines[i].startsWith(fence[1]); i++) body.push(lines[i]);
78
+ out.push(`<pre><code${fence[2] ? ` data-lang="${escapeHtml(fence[2])}"` : ''}>${escapeHtml(body.join('\n'))}</code></pre>`);
79
+ continue;
80
+ }
81
+ if (!line.trim()) { flush(); continue; }
82
+ const h = /^(#{1,5})\s+(.+?)\s*#*\s*$/.exec(line);
83
+ if (h) { flush(); const n = Math.min(6, h[1].length + 1); out.push(`<h${n}>${inline(h[2])}</h${n}>`); continue; }
84
+ if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) { flush(); out.push('<hr>'); continue; }
85
+ const img = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$/.exec(line.trim());
86
+ if (img) {
87
+ flush();
88
+ const url = safeUrl(img[2], { images: true });
89
+ if (url) out.push(`<figure><img src="${escapeHtml(url)}" alt="${escapeHtml(img[1])}" loading="lazy" decoding="async">${img[3] ? `<figcaption>${inline(img[3])}</figcaption>` : ''}</figure>`);
90
+ continue;
91
+ }
92
+ if (/^\s{0,3}>/.test(line)) {
93
+ flush();
94
+ const quote = [];
95
+ for (; i < lines.length && /^\s{0,3}>/.test(lines[i]); i++) quote.push(lines[i].replace(/^\s{0,3}>\s?/, ''));
96
+ i -= 1;
97
+ out.push(`<blockquote>${renderMarkdown(quote.join('\n')).html}</blockquote>`);
98
+ continue;
99
+ }
100
+ const item = LIST.exec(line);
101
+ if (item) {
102
+ flush();
103
+ const ordered = /\d/.test(item[1]);
104
+ const items = [];
105
+ for (; i < lines.length; i++) {
106
+ const m = LIST.exec(lines[i]);
107
+ if (m && /\d/.test(m[1]) === ordered) items.push([m[2]]);
108
+ else if (items.length && /^\s{2,}\S/.test(lines[i])) items[items.length - 1].push(lines[i].trim());
109
+ else break;
110
+ }
111
+ i -= 1;
112
+ out.push(`<${ordered ? 'ol' : 'ul'}>${items.map((parts) => `<li>${inline(parts.join('\n'))}</li>`).join('')}</${ordered ? 'ol' : 'ul'}>`);
113
+ continue;
114
+ }
115
+ para.push(line);
116
+ }
117
+ flush();
118
+ const html = out.join('\n');
119
+ const text = unescapeHtml(html.replace(/<\/?(?:p|h\d|li|ul|ol|blockquote|pre|figure|figcaption|hr|br)\b[^>]*>/g, ' ').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
120
+ return { html, text };
121
+ }
package/lib/scaffold.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  * games/<id>/ one folder per game (game.json, index.html, src/)
10
10
  * music/ videos/ manifests in the repo; the big files go to the studio's own storage
11
11
  * (R2) once it is added with `homie-studio storage add`
12
- * posts/ words the studio publishes
13
- * site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
12
+ * posts/ the studio's news and drops (markdown; the site's Posts, with feeds)
13
+ * site/ the studio's Worker (pages, rooms), its look (theme.json), its D1 migrations (no R2
14
14
  * binding until the studio adds storage: a free Cloudflare account
15
15
  * without a payment method cannot use R2, and a new studio needs none)
16
16
  * .claude/skills/ skills only this studio uses
@@ -24,6 +24,7 @@ import { homedir } from 'node:os';
24
24
  import { dirname, join, relative, resolve } from 'node:path';
25
25
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
26
  import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
27
+ import { themeFile } from './site.mjs';
27
28
 
28
29
  export const COMPAT_DATE = '2026-06-01';
29
30
  export const WRANGLER_VERSION = '4.126.0';
@@ -73,8 +74,8 @@ directory lists its games; homie.rocks does not host them.
73
74
  | --- | --- |
74
75
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
75
76
  | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
76
- | \`posts/\` | Markdown the studio publishes. |
77
- | \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
77
+ | \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
78
+ | \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
78
79
  | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
79
80
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
80
81
 
@@ -116,6 +117,24 @@ studio's pinned copy, never a registry lookup of the bare name.
116
117
  (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
117
118
  nothing sent anywhere; house QA and \`check\` runs are not counted.
118
119
 
120
+ ## The site
121
+
122
+ \`npm run build\` makes the studio's site from what is in this folder
123
+ (\`node_modules/@homie-rocks/studio/site/SITE.md\` says all of it):
124
+
125
+ - **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
126
+ Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
127
+ page is not found.
128
+ - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
129
+ (\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
130
+ with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
131
+ live rooms, how to play, credits, and "Make a game like this". Give it words in game.json's \`landing\` block
132
+ (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
133
+ - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
134
+ page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
135
+ \`site/theme.css\` (\`site/README.md\`).
136
+ - Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
137
+
119
138
  ## Making games
120
139
 
121
140
  - Every game is multiplayer on the web through the netplay contract
@@ -181,6 +200,46 @@ CREATE TABLE IF NOT EXISTS rounds (
181
200
  CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
182
201
  `;
183
202
 
203
+ const POSTS_README = `# Posts
204
+
205
+ The studio's news and drops. One markdown file each; the site shows them at \`/posts/\` (newest first), on the home
206
+ page, and in two feeds: \`/posts/feed.xml\` (Atom) and \`/posts/feed.json\` (JSON Feed).
207
+
208
+ The file's name is its address: \`posts/2026-09-30-we-are-live.md\` is \`/posts/we-are-live/\`, dated by its prefix.
209
+
210
+ \`\`\`markdown
211
+ ---
212
+ title: We are live
213
+ date: 2026-09-30
214
+ summary: One line for the cards and the feeds.
215
+ game: crown-thief
216
+ image: /games/crown-thief/cover.jpg
217
+ ---
218
+
219
+ The body, in markdown: **bold**, *italic*, [links](https://homie.rocks/), images, lists, quotes.
220
+ \`\`\`
221
+
222
+ \`game:\`, \`song:\` and \`video:\` link one of this studio's games, songs or videos (by id or slug); the post shows
223
+ it with a Play, Listen or Watch button. \`draft: true\` keeps a post off the site. Raw HTML is shown as text.
224
+ Files README.md, and names starting with \`_\` or \`.\`, are not posts.
225
+ `;
226
+
227
+ const SITE_README = `# site/
228
+
229
+ The studio's site. \`npm run build\` makes every page from the studio (its games, music, videos and posts);
230
+ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site/SITE.md\`.
231
+
232
+ | Put it in | What it does |
233
+ | --- | --- |
234
+ | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
235
+ | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
236
+ | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
237
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`. |
238
+ | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). |
239
+
240
+ \`src/worker.mjs\`, \`migrations/\` and \`wrangler.jsonc\` are the Worker; \`dist/\` is the build (not committed).
241
+ `;
242
+
184
243
  const GITIGNORE = `node_modules/
185
244
  # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
186
245
  .studio/
@@ -192,6 +251,8 @@ site/.wrangler/
192
251
  *.log
193
252
  # Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
194
253
  games/*/.port/
254
+ # Screenshots from check and look runs.
255
+ .checks/
195
256
  # Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
196
257
  # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
197
258
  music/**/work/
@@ -237,7 +298,9 @@ export function studioFiles({ name, slug, homie }) {
237
298
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
238
299
  'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
239
300
  'videos/manifest.json': '{ "v": 1, "items": [] }\n',
240
- 'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
301
+ 'posts/README.md': POSTS_README,
302
+ 'site/theme.json': themeFile(slug),
303
+ 'site/README.md': SITE_README,
241
304
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
242
305
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
243
306
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';