@homie-rocks/studio 0.5.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 +54 -2
- package/bin/homie-studio.mjs +75 -11
- package/lib/build.mjs +72 -9
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +115 -8
- package/lib/site.mjs +437 -0
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +43 -1
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +416 -136
- package/worker/pages.mjs +151 -208
- package/worker/room.mjs +37 -3
- package/worker/seats.mjs +20 -0
- package/worker/site.mjs +1052 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +314 -0
- package/worker/version.mjs +2 -0
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/media.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
|
|
114
|
-
export function recordUpload(root, rel, key, bytes
|
|
114
|
+
export function recordUpload(root, rel, key, bytes) {
|
|
115
115
|
const kind = rel.startsWith('videos/') ? 'videos' : 'music';
|
|
116
116
|
const m = readManifest(root, kind);
|
|
117
117
|
let hit = false;
|
|
@@ -120,7 +120,7 @@ export function recordUpload(root, rel, key, bytes, siteUrl) {
|
|
|
120
120
|
if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
-
if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url:
|
|
123
|
+
if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: `/media/${key.split('/').map(encodeURIComponent).join('/')}`, at: new Date().toISOString() }];
|
|
124
124
|
writeManifest(root, kind, m);
|
|
125
125
|
return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
|
|
126
126
|
}
|
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
|
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
* lists every file it writes, so the person sees exactly what changed.
|
|
20
20
|
*/
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
23
23
|
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
|
+
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
27
|
+
import { themeFile } from './site.mjs';
|
|
26
28
|
|
|
27
29
|
export const COMPAT_DATE = '2026-06-01';
|
|
28
30
|
export const WRANGLER_VERSION = '4.126.0';
|
|
@@ -72,9 +74,9 @@ directory lists its games; homie.rocks does not host them.
|
|
|
72
74
|
| --- | --- |
|
|
73
75
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
74
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. |
|
|
75
|
-
| \`posts/\` |
|
|
76
|
-
| \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
|
|
77
|
-
| \`studio.json\` | The studio's name, slug
|
|
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\`. |
|
|
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. |
|
|
78
80
|
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
79
81
|
|
|
80
82
|
## Commands (all through the pinned CLI in node_modules)
|
|
@@ -107,6 +109,31 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
107
109
|
is a separate step the person agrees to; nothing else needs it.
|
|
108
110
|
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
109
111
|
(or call the Homie MCP tool \`studio_publish\`).
|
|
112
|
+
- \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
|
|
113
|
+
rooms, the most people playing at once, rounds, songs played, videos watched, and where visitors came
|
|
114
|
+
from (homie.rocks, other studios, search, the web). \`stats link\` gives the owner a one-time link to the
|
|
115
|
+
private page \`/_studio/stats\` in their own browser; \`stats key\` a short read key for the Homie MCP tool
|
|
116
|
+
\`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
|
|
117
|
+
(played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
|
|
118
|
+
nothing sent anywhere; house QA and \`check\` runs are not counted.
|
|
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.
|
|
110
137
|
|
|
111
138
|
## Making games
|
|
112
139
|
|
|
@@ -125,6 +152,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
125
152
|
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
126
153
|
keychain. Never write a key, token or password into this repository.
|
|
127
154
|
- Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
|
|
155
|
+
- The site's workers.dev address names the Cloudflare account (often after its owner): \`deploy\` keeps it in
|
|
156
|
+
\`.studio/local.json\`, which git ignores. Never copy it into a committed file. A custom domain goes in
|
|
157
|
+
studio.json as \`cloudflare.domain\`.
|
|
158
|
+
- A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
|
|
128
159
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
129
160
|
|
|
130
161
|
## Beta
|
|
@@ -169,7 +200,49 @@ CREATE TABLE IF NOT EXISTS rounds (
|
|
|
169
200
|
CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
|
|
170
201
|
`;
|
|
171
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
|
+
|
|
172
243
|
const GITIGNORE = `node_modules/
|
|
244
|
+
# This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
|
|
245
|
+
.studio/
|
|
173
246
|
site/dist/
|
|
174
247
|
site/.wrangler/
|
|
175
248
|
.wrangler/
|
|
@@ -178,6 +251,8 @@ site/.wrangler/
|
|
|
178
251
|
*.log
|
|
179
252
|
# Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
|
|
180
253
|
games/*/.port/
|
|
254
|
+
# Screenshots from check and look runs.
|
|
255
|
+
.checks/
|
|
181
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.
|
|
182
257
|
# Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
|
|
183
258
|
music/**/work/
|
|
@@ -197,7 +272,12 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
197
272
|
name, slug,
|
|
198
273
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
199
274
|
// r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
|
|
200
|
-
|
|
275
|
+
// `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
|
|
276
|
+
// goes here: it names the Cloudflare account, so deploy keeps it in .studio/local.json (git-ignored).
|
|
277
|
+
cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, domain: null, created: [] },
|
|
278
|
+
// The site counts visits, plays, rooms, rounds and songs for the owner only (`homie-studio stats`); `share`
|
|
279
|
+
// also tells the homie.rocks directory "played this week" (two numbers) for the hub.
|
|
280
|
+
stats: { share: false },
|
|
201
281
|
};
|
|
202
282
|
const pkg = {
|
|
203
283
|
name: `${slug}-studio`,
|
|
@@ -218,17 +298,44 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
218
298
|
'music/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
219
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',
|
|
220
300
|
'videos/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
221
|
-
'posts/README.md':
|
|
301
|
+
'posts/README.md': POSTS_README,
|
|
302
|
+
'site/theme.json': themeFile(slug),
|
|
303
|
+
'site/README.md': SITE_README,
|
|
222
304
|
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
|
|
223
305
|
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
224
306
|
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
225
307
|
`,
|
|
226
308
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
309
|
+
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
227
310
|
'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
|
|
228
311
|
'.claude/skills/.gitkeep': '',
|
|
229
312
|
};
|
|
230
313
|
}
|
|
231
314
|
|
|
315
|
+
/**
|
|
316
|
+
* A studio made before 0.6.0 has no stats migration: add it (the template owns site/migrations/), so the next
|
|
317
|
+
* `d1 migrations apply` makes the counters. Returns the file it wrote, or null when it was there.
|
|
318
|
+
*/
|
|
319
|
+
export function ensureStatsMigration(root) {
|
|
320
|
+
const file = join(root, 'site', 'migrations', STATS_MIGRATION_FILE);
|
|
321
|
+
if (existsSync(file)) return null;
|
|
322
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
323
|
+
writeFileSync(file, STATS_MIGRATION);
|
|
324
|
+
return `site/migrations/${STATS_MIGRATION_FILE}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
|
|
329
|
+
* deploy writes the workers.dev address there. Returns true when it added the line.
|
|
330
|
+
*/
|
|
331
|
+
export function ensureLocalIgnored(root) {
|
|
332
|
+
const file = join(root, '.gitignore');
|
|
333
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
334
|
+
if (/^\/?\.studio\/?$/m.test(text)) return false;
|
|
335
|
+
writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This computer's own state: the site's workers.dev address (it names the Cloudflare account).\n.studio/\n`);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
232
339
|
function insideGit(dir) {
|
|
233
340
|
try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
|
|
234
341
|
}
|