@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 +17 -1
- package/bin/homie-studio.mjs +26 -2
- package/lib/build.mjs +38 -12
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/scaffold.mjs +68 -5
- package/lib/site.mjs +437 -0
- package/lib/studio.mjs +1 -1
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +297 -143
- package/worker/pages.mjs +149 -212
- package/worker/room.mjs +28 -1
- package/worker/site.mjs +1052 -0
- package/worker/stats.mjs +8 -5
- package/worker/version.mjs +2 -0
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.
|
|
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`). |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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,
|
|
9
|
-
* files
|
|
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:
|
|
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
|
+
}
|
package/lib/markdown.mjs
ADDED
|
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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+"([^&]*)")?\)/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(/<(https:\/\/[^\s&]+)>/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/
|
|
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/\` |
|
|
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':
|
|
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';
|