@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/site.mjs
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The studio site's own parts, gathered at build time (site/SITE.md says all of it):
|
|
3
|
+
*
|
|
4
|
+
* site/theme.json the studio's look as tokens (colours, fonts, corner radius, its mark)
|
|
5
|
+
* site/theme.css any CSS on top (restyle anything, the "Made with Homie" footer included)
|
|
6
|
+
* site/partials/<n>.html a piece of every generated page: head, header, footer, home, game, game-<id>, post
|
|
7
|
+
* site/pages/.../*.html a whole page of the studio's own, served instead of (or beside) the generated one
|
|
8
|
+
* site/public/... files served as they are (fonts, a logo, hero footage), at the same path
|
|
9
|
+
* posts/*.md the studio's news and drops (a title, a date, a body, links to a game, song or video)
|
|
10
|
+
* games/<id>/ each game's landing: game.json `landing`, credits.json, hero/ footage, its cover
|
|
11
|
+
*
|
|
12
|
+
* Nothing here runs on Cloudflare: it writes site/dist (the Worker reads games.json and site/dist/_site/).
|
|
13
|
+
*/
|
|
14
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { basename, dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
16
|
+
import { renderMarkdown } from './markdown.mjs';
|
|
17
|
+
|
|
18
|
+
/** One file the site serves itself may be at most 25 MiB (Workers static assets). */
|
|
19
|
+
const MAX_FILE = 25 * 1024 * 1024;
|
|
20
|
+
const MAX_PARTIAL = 64 * 1024;
|
|
21
|
+
const MAX_CSS = 256 * 1024;
|
|
22
|
+
const MAX_POST = 256 * 1024;
|
|
23
|
+
|
|
24
|
+
/* ------------------------------------------------------------------ theme */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Curated looks a new studio starts from (picked from its slug, so two new studios rarely match). Each is dark,
|
|
28
|
+
* like the house brands' landings, and passes contrast for text on the background and on the accent.
|
|
29
|
+
*/
|
|
30
|
+
export const PALETTES = {
|
|
31
|
+
neon: { bg: '#05070d', fg: '#dffcff', accent: '#ff3bd4', glow: '#00eaff' },
|
|
32
|
+
dock: { bg: '#070612', fg: '#eafcff', accent: '#ff8a3d', glow: '#7df0ff' },
|
|
33
|
+
gold: { bg: '#0b0c12', fg: '#f1f3f9', accent: '#ffcf5a', glow: '#7dffb0' },
|
|
34
|
+
acid: { bg: '#060905', fg: '#efffe6', accent: '#b6ff3b', glow: '#3bffd0' },
|
|
35
|
+
ember: { bg: '#0d0706', fg: '#fff1ea', accent: '#ff5a36', glow: '#ffc36b' },
|
|
36
|
+
orchid: { bg: '#0a0612', fg: '#f6ecff', accent: '#c77dff', glow: '#ff7dd8' },
|
|
37
|
+
tide: { bg: '#041014', fg: '#e6fbff', accent: '#2ee6c9', glow: '#6fb8ff' },
|
|
38
|
+
candy: { bg: '#120814', fg: '#fff0f7', accent: '#ff6fa8', glow: '#ffd36f' },
|
|
39
|
+
};
|
|
40
|
+
export const DEFAULT_THEME = { palette: 'gold', ...PALETTES.gold };
|
|
41
|
+
|
|
42
|
+
/** The palette a new studio gets: stable for its slug. */
|
|
43
|
+
export function paletteFor(slug) {
|
|
44
|
+
const names = Object.keys(PALETTES);
|
|
45
|
+
let h = 2166136261;
|
|
46
|
+
for (const c of String(slug ?? '')) h = Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0;
|
|
47
|
+
return names[h % names.length];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\([0-9a-z.,%\s/+-]{1,80}\))$/i;
|
|
51
|
+
const FAMILY = /^[A-Za-z0-9 ,"'_.-]{1,240}$/;
|
|
52
|
+
const ASSET = /^\/[A-Za-z0-9._~/-]{1,200}$/;
|
|
53
|
+
const httpsUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'https:' && !/["'<>\s]/.test(u.href) ? u.href : null; } catch { return null; } };
|
|
54
|
+
const sitePath = (v) => (ASSET.test(String(v ?? '')) && !String(v).includes('..') ? String(v) : null);
|
|
55
|
+
|
|
56
|
+
/** Relative luminance of a #rgb/#rrggbb colour (0..1), or null for any other colour syntax. */
|
|
57
|
+
function luminance(hex) {
|
|
58
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex));
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
const full = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
|
|
61
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
62
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* site/theme.json as the tokens the pages use. Unknown keys and unsafe values are dropped (a warning each),
|
|
67
|
+
* so a theme can never break out of the page's stylesheet.
|
|
68
|
+
*/
|
|
69
|
+
export function readTheme(root, { log = () => {} } = {}) {
|
|
70
|
+
const file = join(root, 'site', 'theme.json');
|
|
71
|
+
let raw = {};
|
|
72
|
+
if (existsSync(file)) {
|
|
73
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { log(`warning: site/theme.json is not JSON (${error.message}); the default look is used`); raw = {}; }
|
|
74
|
+
}
|
|
75
|
+
if (raw.palette && !PALETTES[raw.palette]) log(`warning: site/theme.json palette "${raw.palette}" is not one of ${Object.keys(PALETTES).join(', ')}`);
|
|
76
|
+
const base = PALETTES[raw.palette] ?? DEFAULT_THEME;
|
|
77
|
+
const theme = { palette: PALETTES[raw.palette] ? raw.palette : (existsSync(file) ? 'custom' : DEFAULT_THEME.palette) };
|
|
78
|
+
for (const k of ['bg', 'fg', 'accent', 'glow', 'panel', 'accentInk']) {
|
|
79
|
+
const v = raw[k] ?? (k === 'accent' ? raw.hot : undefined) ?? base[k];
|
|
80
|
+
if (v === undefined) continue;
|
|
81
|
+
if (COLOR.test(String(v))) theme[k] = String(v);
|
|
82
|
+
else { log(`warning: site/theme.json ${k} "${String(v).slice(0, 40)}" is not a colour; left out`); if (base[k]) theme[k] = base[k]; }
|
|
83
|
+
}
|
|
84
|
+
if (!theme.accentInk) {
|
|
85
|
+
const l = luminance(theme.accent);
|
|
86
|
+
if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff';
|
|
87
|
+
}
|
|
88
|
+
const bgL = luminance(theme.bg);
|
|
89
|
+
theme.scheme = raw.scheme === 'light' || (raw.scheme !== 'dark' && bgL !== null && bgL > 0.5) ? 'light' : 'dark';
|
|
90
|
+
for (const k of ['display', 'text', 'mono']) {
|
|
91
|
+
if (raw[k] === undefined) continue;
|
|
92
|
+
if (FAMILY.test(String(raw[k]))) theme[k] = String(raw[k]);
|
|
93
|
+
else log(`warning: site/theme.json ${k} is not a font list; left out`);
|
|
94
|
+
}
|
|
95
|
+
if (raw.radius !== undefined) {
|
|
96
|
+
const r = Number(raw.radius);
|
|
97
|
+
if (Number.isFinite(r) && r >= 0 && r <= 40) theme.radius = r; else log('warning: site/theme.json radius is a number from 0 to 40; left out');
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(raw.fonts)) {
|
|
100
|
+
theme.fonts = raw.fonts.slice(0, 6).map((f) => {
|
|
101
|
+
const family = String(f?.family ?? '');
|
|
102
|
+
const src = sitePath(f?.src) ?? httpsUrl(f?.src);
|
|
103
|
+
if (!/^[A-Za-z0-9 _-]{1,60}$/.test(family) || !src) { log(`warning: site/theme.json fonts: "${family.slice(0, 30)}" needs a family (letters, digits, spaces) and a src (a /path in site/public, or https://); left out`); return null; }
|
|
104
|
+
const weight = /^\d{3}( \d{3})?$/.test(String(f.weight ?? '')) ? String(f.weight) : '400';
|
|
105
|
+
const style = f.style === 'italic' ? 'italic' : 'normal';
|
|
106
|
+
return { family, src, weight, style };
|
|
107
|
+
}).filter(Boolean);
|
|
108
|
+
if (!theme.fonts.length) delete theme.fonts;
|
|
109
|
+
}
|
|
110
|
+
for (const k of ['mark', 'icon', 'social']) {
|
|
111
|
+
if (raw[k] === undefined) continue;
|
|
112
|
+
const v = sitePath(raw[k]) ?? httpsUrl(raw[k]);
|
|
113
|
+
if (v) theme[k] = v; else log(`warning: site/theme.json ${k} is a /path in site/public or an https:// address; left out`);
|
|
114
|
+
}
|
|
115
|
+
if (typeof raw.wordmark === 'string') theme.wordmark = raw.wordmark.slice(0, 40);
|
|
116
|
+
if (raw.uppercase === false) theme.uppercase = false;
|
|
117
|
+
return theme;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What `homie-studio new` writes into site/theme.json: the studio's palette, spelled out so it can be changed. */
|
|
121
|
+
export function themeFile(slug) {
|
|
122
|
+
const palette = paletteFor(slug);
|
|
123
|
+
return `${JSON.stringify({ palette, ...PALETTES[palette], display: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', radius: 18 }, null, 2)}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ------------------------------------------------------------------ posts */
|
|
127
|
+
|
|
128
|
+
export const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
129
|
+
const slugOf = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
130
|
+
|
|
131
|
+
/** `---` frontmatter: `key: value` lines (quotes optional); returns [fields, body]. */
|
|
132
|
+
export function frontmatter(text) {
|
|
133
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
134
|
+
if (!m) return [{}, text];
|
|
135
|
+
const fields = {};
|
|
136
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
137
|
+
const kv = /^([A-Za-z][A-Za-z0-9_-]{0,30})\s*:\s*(.*)$/.exec(line);
|
|
138
|
+
if (!kv) continue;
|
|
139
|
+
let v = kv[2].trim();
|
|
140
|
+
if (/^(["']).*\1$/.test(v)) v = v.slice(1, -1);
|
|
141
|
+
if (v === 'true' || v === 'false') v = v === 'true';
|
|
142
|
+
fields[kv[1].toLowerCase()] = v;
|
|
143
|
+
}
|
|
144
|
+
return [fields, text.slice(m[0].length)];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
148
|
+
/** A post's date as an ISO timestamp (a bare day is midnight UTC), or null. */
|
|
149
|
+
export function isoDate(value) {
|
|
150
|
+
const m = DAY.exec(String(value ?? '').trim());
|
|
151
|
+
if (!m) return null;
|
|
152
|
+
const t = Date.parse(m[4] ? String(value).trim().replace(' ', 'T') + (m[7] ? '' : 'Z') : `${m[1]}-${m[2]}-${m[3]}T00:00:00Z`);
|
|
153
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* posts/*.md, newest first. The filename is the address (`posts/2026-09-30-we-are-live.md` is
|
|
158
|
+
* `/posts/we-are-live/`, dated by its prefix unless the frontmatter says `date:`). A post links to at most one
|
|
159
|
+
* game, one song and one video of this studio (frontmatter `game:`, `song:`, `video:`); `draft: true` keeps it
|
|
160
|
+
* off the site. The record beside each (`record`) is the shape a post takes when it is published as an atproto
|
|
161
|
+
* record later: nothing in it depends on this site's HTML.
|
|
162
|
+
*/
|
|
163
|
+
export function readPosts(root, { games = [], songs = [], videos = [], log = () => {} } = {}) {
|
|
164
|
+
const dir = join(root, 'posts');
|
|
165
|
+
const posts = [];
|
|
166
|
+
const skipped = [];
|
|
167
|
+
if (!existsSync(dir)) return { posts, skipped };
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
for (const name of readdirSync(dir).sort()) {
|
|
170
|
+
if (!/\.md$/i.test(name) || /^readme\.md$/i.test(name) || /^[._]/.test(name)) continue;
|
|
171
|
+
const file = join(dir, name);
|
|
172
|
+
if (!statSync(file).isFile()) continue;
|
|
173
|
+
if (statSync(file).size > MAX_POST) { skipped.push({ post: name, why: 'over 256 KB: split it, and put pictures in site/public' }); continue; }
|
|
174
|
+
const [fm, body] = frontmatter(readFileSync(file, 'utf8'));
|
|
175
|
+
const stem = name.replace(/\.md$/i, '');
|
|
176
|
+
const prefix = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(stem);
|
|
177
|
+
const slug = fm.slug && POST_SLUG.test(String(fm.slug)) ? String(fm.slug) : slugOf(prefix ? prefix[2] : stem);
|
|
178
|
+
if (!POST_SLUG.test(slug)) { skipped.push({ post: name, why: 'its name gives no address: name it like 2026-09-30-we-are-live.md' }); continue; }
|
|
179
|
+
if (seen.has(slug)) { skipped.push({ post: name, why: `a second post with the address /posts/${slug}/` }); continue; }
|
|
180
|
+
if (fm.draft === true) { skipped.push({ post: name, why: 'a draft (draft: true)' }); continue; }
|
|
181
|
+
const date = isoDate(fm.date) ?? (prefix ? isoDate(prefix[1]) : null);
|
|
182
|
+
if (!date) { skipped.push({ post: name, why: 'no date: add `date: 2026-09-30` to its frontmatter, or start its name with the day' }); continue; }
|
|
183
|
+
const { html, text } = renderMarkdown(body);
|
|
184
|
+
const heading = /^#\s+(.+)$/m.exec(body)?.[1];
|
|
185
|
+
const title = String(fm.title ?? heading ?? '').trim().slice(0, 140);
|
|
186
|
+
if (!title) { skipped.push({ post: name, why: 'no title: add `title:` to its frontmatter' }); continue; }
|
|
187
|
+
const links = {};
|
|
188
|
+
for (const [kind, list, key] of [['game', games, 'id'], ['song', songs, 'slug'], ['video', videos, 'slug']]) {
|
|
189
|
+
const want = fm[kind];
|
|
190
|
+
if (!want) continue;
|
|
191
|
+
if (list.some((x) => x[key] === want)) links[kind] = String(want);
|
|
192
|
+
else log(`warning: posts/${name} links ${kind} "${want}", which this studio's site does not have; the link is left out`);
|
|
193
|
+
}
|
|
194
|
+
const image = fm.image ? (sitePath(fm.image) ?? httpsUrl(fm.image)) : null;
|
|
195
|
+
if (fm.image && !image) log(`warning: posts/${name} image is a /path on the site or an https:// address; left out`);
|
|
196
|
+
const summary = String(fm.summary ?? '').trim().slice(0, 300) || (text.length > 220 ? `${text.slice(0, 217).replace(/\s+\S*$/, '')}…` : text);
|
|
197
|
+
seen.add(slug);
|
|
198
|
+
posts.push({
|
|
199
|
+
slug, title, date, updated: isoDate(fm.updated) ?? null, summary, image, author: fm.author ? String(fm.author).slice(0, 80) : null, links, html,
|
|
200
|
+
record: {
|
|
201
|
+
$type: 'rocks.homie.studio.post',
|
|
202
|
+
title, text: body.trim().slice(0, 30000), createdAt: date,
|
|
203
|
+
...(summary ? { summary } : {}),
|
|
204
|
+
links: Object.entries(links).map(([kind, id]) => ({ kind, id })),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
posts.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)));
|
|
209
|
+
for (const s of skipped) log(`posts/${s.post}: ${s.why}`);
|
|
210
|
+
return { posts, skipped };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ------------------------------------------------------------------ site/ overrides */
|
|
214
|
+
|
|
215
|
+
export const PARTIALS = ['head', 'header', 'footer', 'home', 'game', 'post'];
|
|
216
|
+
/** Paths a page of the studio's own can never take: the rooms, the game files, the API and the owner's pages. */
|
|
217
|
+
const RESERVED_PAGE = /^\/(?:api|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
|
|
218
|
+
/** Top-level names site/public must not take (the build's own output). */
|
|
219
|
+
const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media']);
|
|
220
|
+
|
|
221
|
+
function walk(dir, fn, rel = '') {
|
|
222
|
+
for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
223
|
+
if (e.name.startsWith('.')) continue;
|
|
224
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
225
|
+
if (e.isDirectory()) walk(dir, fn, r); else if (e.isFile()) fn(r);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* site/public, site/pages, site/partials and site/theme.css into site/dist. Returns what the Worker needs:
|
|
231
|
+
* the page paths (served from _site/pages), the partials' text, the extra CSS, and what was left out.
|
|
232
|
+
*/
|
|
233
|
+
export function buildSiteFiles(root, dist, { gameIds = [], log = () => {} } = {}) {
|
|
234
|
+
const site = join(root, 'site');
|
|
235
|
+
const res = { pages: [], partials: {}, css: null, public: [], skipped: [] };
|
|
236
|
+
const skip = (what, why) => { res.skipped.push({ what, why }); log(`${what}: ${why}`); };
|
|
237
|
+
|
|
238
|
+
const pub = join(site, 'public');
|
|
239
|
+
if (existsSync(pub)) {
|
|
240
|
+
walk(pub, (rel) => {
|
|
241
|
+
const top = rel.split('/')[0];
|
|
242
|
+
if (RESERVED_PUBLIC.has(top)) return skip(`site/public/${rel}`, `/${top}/ is the site's own; put it somewhere else`);
|
|
243
|
+
const bytes = statSync(join(pub, rel)).size;
|
|
244
|
+
if (bytes > MAX_FILE) return skip(`site/public/${rel}`, `${Math.round(bytes / 1048576)} MiB is over the 25 MiB a site serves itself`);
|
|
245
|
+
mkdirSync(dirname(join(dist, rel)), { recursive: true });
|
|
246
|
+
cpSync(join(pub, rel), join(dist, rel));
|
|
247
|
+
res.public.push(`/${rel}`);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const pages = join(site, 'pages');
|
|
252
|
+
if (existsSync(pages)) {
|
|
253
|
+
walk(pages, (rel) => {
|
|
254
|
+
if (!/\.html$/.test(rel)) return skip(`site/pages/${rel}`, 'only .html pages go in site/pages (files go in site/public)');
|
|
255
|
+
const path = `/${rel.replace(/(^|\/)index\.html$/, '$1').replace(/\.html$/, '/')}`;
|
|
256
|
+
if (RESERVED_PAGE.test(path)) return skip(`site/pages/${rel}`, `${path} belongs to the site itself (rooms, game files, the API)`);
|
|
257
|
+
if (!/^\/([a-z0-9][a-z0-9._-]*\/)*$/.test(path)) return skip(`site/pages/${rel}`, 'a page address is lowercase letters, digits, dots and hyphens');
|
|
258
|
+
const bytes = statSync(join(pages, rel)).size;
|
|
259
|
+
if (bytes > MAX_FILE) return skip(`site/pages/${rel}`, 'over 25 MiB');
|
|
260
|
+
const dest = join(dist, '_site', 'pages', path, 'index.html');
|
|
261
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
262
|
+
cpSync(join(pages, rel), dest);
|
|
263
|
+
res.pages.push(path);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const partials = join(site, 'partials');
|
|
268
|
+
if (existsSync(partials)) {
|
|
269
|
+
for (const name of readdirSync(partials).sort()) {
|
|
270
|
+
if (!name.endsWith('.html') || name.startsWith('.')) continue;
|
|
271
|
+
const key = name.slice(0, -5);
|
|
272
|
+
const known = PARTIALS.includes(key) || (/^game-[a-z0-9][a-z0-9-]{0,39}$/.test(key) && gameIds.includes(key.slice(5)));
|
|
273
|
+
if (!known) { skip(`site/partials/${name}`, `not a partial the pages use (${PARTIALS.join(', ')}, or game-<id> for one of this studio's games)`); continue; }
|
|
274
|
+
const text = readFileSync(join(partials, name), 'utf8');
|
|
275
|
+
if (text.length > MAX_PARTIAL) { skip(`site/partials/${name}`, 'over 64 KB'); continue; }
|
|
276
|
+
res.partials[key] = text;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const css = join(site, 'theme.css');
|
|
281
|
+
if (existsSync(css)) {
|
|
282
|
+
const text = readFileSync(css, 'utf8');
|
|
283
|
+
if (text.length > MAX_CSS) skip('site/theme.css', 'over 256 KB');
|
|
284
|
+
else res.css = text.replace(/<\/(style)/gi, '<\\/$1');
|
|
285
|
+
}
|
|
286
|
+
res.pages.sort();
|
|
287
|
+
return res;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ------------------------------------------------------------------ a game's landing */
|
|
291
|
+
|
|
292
|
+
const inside = (base, rel) => {
|
|
293
|
+
if (typeof rel !== 'string' || !rel || rel.startsWith('/') || /^[a-z]+:/i.test(rel)) return null;
|
|
294
|
+
const abs = resolve(base, normalize(rel));
|
|
295
|
+
return abs.startsWith(`${resolve(base)}${sep}`) ? abs : null;
|
|
296
|
+
};
|
|
297
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
298
|
+
const str = (v, n) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, n) : null);
|
|
299
|
+
const IMAGE = /\.(jpe?g|png|webp|avif|gif)$/i;
|
|
300
|
+
const VIDEO = /\.(mp4|webm|m4v|mov)$/i;
|
|
301
|
+
|
|
302
|
+
/** Hero footage by the house brands' names: hero/wide.mp4, hero/tall.mp4, their .av1.mp4 cuts, hero/wide.jpg, hero/tall.jpg. */
|
|
303
|
+
const HERO_NAMES = { wide: ['wide.mp4', 'wide.webm'], tall: ['tall.mp4', 'tall.webm'], wideAv1: ['wide.av1.mp4'], tallAv1: ['tall.av1.mp4'], wideImage: ['wide.jpg', 'wide.webp', 'wide.png', 'wide.avif'], tallImage: ['tall.jpg', 'tall.webp', 'tall.png', 'tall.avif'] };
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Everything a game's landing shows, from the game's own files: game.json (`landing` block, name, blurb, players,
|
|
307
|
+
* round length, cover, share), credits.json, hero/ footage, and the studio's trailer and music for it. Files the
|
|
308
|
+
* landing shows are copied into the game's built folder under _landing/ (the cover beside the build when the
|
|
309
|
+
* build left it out), so the site serves them. Nothing is invented: a landing without footage uses the cover.
|
|
310
|
+
*/
|
|
311
|
+
export function landingOf(g, out, { videos = [], songs = [], log = () => {} } = {}) {
|
|
312
|
+
const L = g.landing && typeof g.landing === 'object' ? g.landing : {};
|
|
313
|
+
const credits = readJson(join(g.dir, 'credits.json')) ?? {};
|
|
314
|
+
const landDir = join(out, '_landing');
|
|
315
|
+
const copied = new Map();
|
|
316
|
+
const serve = (abs, label) => {
|
|
317
|
+
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) return null;
|
|
318
|
+
if (copied.has(abs)) return copied.get(abs);
|
|
319
|
+
const bytes = statSync(abs).size;
|
|
320
|
+
if (bytes > MAX_FILE) { log(`warning: games/${g.id}: ${label} is ${Math.round(bytes / 1048576)} MiB, over the 25 MiB a site serves itself; the landing leaves it out`); return null; }
|
|
321
|
+
// Already in the game's build (a static game copies its whole folder): served from there, not twice.
|
|
322
|
+
const rel = relative(g.dir, abs);
|
|
323
|
+
const beside = join(out, rel);
|
|
324
|
+
if (!rel.startsWith('..') && existsSync(beside) && statSync(beside).size === bytes) {
|
|
325
|
+
const url = `/games/${g.id}/${rel.split(sep).map(encodeURIComponent).join('/')}`;
|
|
326
|
+
copied.set(abs, url);
|
|
327
|
+
return url;
|
|
328
|
+
}
|
|
329
|
+
mkdirSync(landDir, { recursive: true });
|
|
330
|
+
let name = basename(abs).replace(/[^A-Za-z0-9._-]/g, '-');
|
|
331
|
+
if ([...copied.values()].some((u) => u.endsWith(`/${name}`))) name = `${copied.size}-${name}`;
|
|
332
|
+
cpSync(abs, join(landDir, name));
|
|
333
|
+
const url = `/games/${g.id}/_landing/${name}`;
|
|
334
|
+
copied.set(abs, url);
|
|
335
|
+
return url;
|
|
336
|
+
};
|
|
337
|
+
const pick = (explicit, names, label) => {
|
|
338
|
+
if (explicit) {
|
|
339
|
+
const abs = inside(g.dir, explicit);
|
|
340
|
+
const url = abs ? serve(abs, label) : null;
|
|
341
|
+
if (!url) log(`warning: games/${g.id}/game.json landing ${label} "${String(explicit).slice(0, 60)}" is not a file in the game's folder`);
|
|
342
|
+
return url;
|
|
343
|
+
}
|
|
344
|
+
for (const n of names) { const url = serve(join(g.dir, 'hero', n), `hero/${n}`); if (url) return url; }
|
|
345
|
+
return null;
|
|
346
|
+
};
|
|
347
|
+
const H = L.hero && typeof L.hero === 'object' ? L.hero : {};
|
|
348
|
+
const hero = {
|
|
349
|
+
wide: pick(H.video ?? H.wide, HERO_NAMES.wide, 'hero video'),
|
|
350
|
+
tall: pick(H.tall, HERO_NAMES.tall, 'hero tall video'),
|
|
351
|
+
wideAv1: H.video || H.wide ? null : pick(null, HERO_NAMES.wideAv1, 'hero AV1 video'),
|
|
352
|
+
tallAv1: H.tall ? null : pick(null, HERO_NAMES.tallAv1, 'hero tall AV1 video'),
|
|
353
|
+
wideImage: pick(H.image, HERO_NAMES.wideImage, 'hero image'),
|
|
354
|
+
tallImage: pick(H.tallImage, HERO_NAMES.tallImage, 'hero tall image'),
|
|
355
|
+
};
|
|
356
|
+
// The cover: the game's card everywhere, and the hero still when there is no footage.
|
|
357
|
+
// game.json `cover` is a path in the game's build: already there (a static game, or a bundled game's public/,
|
|
358
|
+
// where the art skill writes it), or beside game.json (copied into the build).
|
|
359
|
+
let cover = null;
|
|
360
|
+
if (g.cover) {
|
|
361
|
+
const built = inside(out, g.cover);
|
|
362
|
+
const beside = inside(g.dir, g.cover);
|
|
363
|
+
const pub = inside(join(g.dir, 'public'), g.cover);
|
|
364
|
+
const from = [beside, pub].find((p) => p && existsSync(p) && statSync(p).isFile());
|
|
365
|
+
if (built && !existsSync(built) && from) { mkdirSync(dirname(built), { recursive: true }); cpSync(from, built); }
|
|
366
|
+
if (built && existsSync(built)) cover = `/games/${g.id}/${relative(out, built).split(sep).map(encodeURIComponent).join('/')}`;
|
|
367
|
+
else log(`warning: games/${g.id}/game.json cover "${g.cover}" is not a file in the game's folder or its public/`);
|
|
368
|
+
}
|
|
369
|
+
// A trailer of this game from videos/manifest.json: the hero's footage when the game has none of its own.
|
|
370
|
+
const forGame = (list) => list.filter((e) => e.for?.game === g.id);
|
|
371
|
+
const trailers = forGame(videos).sort((a, b) => (a.kind === 'trailer' ? -1 : 0) - (b.kind === 'trailer' ? -1 : 0));
|
|
372
|
+
const trailer = trailers[0] ?? null;
|
|
373
|
+
const fileUrl = (e, role) => (e?.files ?? []).find((f) => f.role === role)?.url ?? null;
|
|
374
|
+
if (trailer && !hero.wide && !hero.tall) {
|
|
375
|
+
hero.wide = fileUrl(trailer, 'video');
|
|
376
|
+
hero.tall = fileUrl(trailer, 'vertical');
|
|
377
|
+
hero.fromTrailer = trailer.slug;
|
|
378
|
+
}
|
|
379
|
+
if (!hero.wideImage) hero.wideImage = (trailer && hero.fromTrailer ? fileUrl(trailer, 'poster') : null) ?? cover;
|
|
380
|
+
for (const k of Object.keys(hero)) if (!hero[k]) delete hero[k];
|
|
381
|
+
const focus = /^\d{1,3}% \d{1,3}%$/.test(String(H.focus ?? '')) ? String(H.focus) : null;
|
|
382
|
+
const tint = Number.isFinite(Number(H.tint)) && H.tint !== null && H.tint !== '' ? Math.max(0, Math.min(80, Math.round(Number(H.tint)))) : null;
|
|
383
|
+
const alt = str(H.alt, 300);
|
|
384
|
+
|
|
385
|
+
// How to play: game.json landing.controls, else credits.json controls.
|
|
386
|
+
const controls = {};
|
|
387
|
+
for (const k of ['phone', 'computer', 'tv']) {
|
|
388
|
+
const v = str(L.controls?.[k], 300) ?? str(credits.controls?.[k], 300);
|
|
389
|
+
if (v) controls[k] = v;
|
|
390
|
+
}
|
|
391
|
+
const how = Array.isArray(L.howToPlay) ? L.howToPlay.map((s) => str(s, 240)).filter(Boolean).slice(0, 6) : [];
|
|
392
|
+
|
|
393
|
+
// Credits: who made it (game.json landing.credits), the original it is based on and the parts inside it
|
|
394
|
+
// (credits.json, as a port records them), with the licence texts on /<id>/credits.
|
|
395
|
+
const people = (Array.isArray(L.credits) ? L.credits : Array.isArray(g.credits) ? g.credits : []).slice(0, 24).map((c) => ({
|
|
396
|
+
role: str(c?.role, 60), name: str(c?.name, 80), url: httpsUrl(c?.url),
|
|
397
|
+
})).filter((c) => c.name);
|
|
398
|
+
const o = credits.original && typeof credits.original === 'object' ? credits.original : null;
|
|
399
|
+
const original = o ? {
|
|
400
|
+
title: str(o.title, 120), author: str(o.author, 120), year: str(String(o.year ?? ''), 12), url: httpsUrl(o.url),
|
|
401
|
+
licence: str(o.licence, 60), house: o.house === true,
|
|
402
|
+
} : null;
|
|
403
|
+
const parts = (Array.isArray(credits.parts) ? credits.parts : []).slice(0, 40).map((p) => ({
|
|
404
|
+
what: str(p?.what, 160), author: str(p?.author, 120), url: httpsUrl(p?.url), licence: str(p?.licence, 60), licenceUrl: httpsUrl(p?.licenceUrl), note: str(p?.note, 200),
|
|
405
|
+
})).filter((p) => p.what);
|
|
406
|
+
// The licence texts, for /<id>/credits: CREDITS.md and every licence file credits.json names.
|
|
407
|
+
const texts = [];
|
|
408
|
+
for (const f of ['CREDITS.md', o?.licenceFile, ...(credits.parts ?? []).map((p) => p?.licenceFile)]) {
|
|
409
|
+
if (typeof f !== 'string' || texts.some((t) => t.file === f)) continue;
|
|
410
|
+
const abs = inside(g.dir, f);
|
|
411
|
+
if (!abs || !existsSync(abs) || statSync(abs).size > 256 * 1024) continue;
|
|
412
|
+
const text = readFileSync(abs, 'utf8');
|
|
413
|
+
if (text.includes('\u0000')) continue;
|
|
414
|
+
texts.push({ file: f, text });
|
|
415
|
+
}
|
|
416
|
+
if (texts.length) { mkdirSync(landDir, { recursive: true }); writeFileSync(join(landDir, 'credits.json'), `${JSON.stringify({ v: 1, texts })}\n`); }
|
|
417
|
+
|
|
418
|
+
const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
|
|
419
|
+
return {
|
|
420
|
+
pitch: str(L.pitch, 240) ?? str(credits.tagline, 240),
|
|
421
|
+
kicker: str(L.kicker, 120),
|
|
422
|
+
about: str(L.about, 1200) ?? str(credits.about, 1200),
|
|
423
|
+
controls, how,
|
|
424
|
+
hero: { ...hero, ...(focus ? { focus } : {}), ...(alt ? { alt } : {}), ...(tint !== null ? { tint } : {}) },
|
|
425
|
+
headline: str(L.headline, 120),
|
|
426
|
+
cover,
|
|
427
|
+
words: words && (words.one || words.many) ? words : null,
|
|
428
|
+
credits: { people, original, parts, texts: texts.length },
|
|
429
|
+
trailer: trailer ? trailer.slug : null,
|
|
430
|
+
videos: trailers.map((e) => e.slug),
|
|
431
|
+
music: forGame(songs).map((e) => e.slug),
|
|
432
|
+
source: g.share?.source !== false,
|
|
433
|
+
tv: g.screen?.tv !== false && L.tv !== false,
|
|
434
|
+
theme: L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
package/lib/stats.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio stats` — the studio's own numbers, for its owner (worker/stats.mjs says what is counted).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>]
|
|
5
|
+
* the numbers, read with a key that lives ten minutes and is dropped after
|
|
6
|
+
* homie-studio stats key [--hours 1] a read key for the Homie MCP tool `studio_stats` (up to 30 days)
|
|
7
|
+
* homie-studio stats link a one-time link that opens /_studio/stats in the owner's browser
|
|
8
|
+
* homie-studio stats revoke every key and page session ends now
|
|
9
|
+
* homie-studio stats share on|off tell the homie.rocks directory "played this week" (two numbers), or stop
|
|
10
|
+
*
|
|
11
|
+
* THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN. A key is minted here, on this computer, and only
|
|
12
|
+
* its SHA-256 goes into the studio's D1 (through Wrangler, which only the studio's owner is signed in to). The key
|
|
13
|
+
* itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
|
|
14
|
+
* locally (`npm run dev`, a loopback --url) uses the local D1.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
import { runner } from './cloudflare.mjs';
|
|
18
|
+
import { readStudio, siteUrl, writeStudio } from './studio.mjs';
|
|
19
|
+
|
|
20
|
+
const HOUR = 3600_000;
|
|
21
|
+
const RANGES = new Set(['1d', '7d', '30d', '90d']);
|
|
22
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
|
+
|
|
24
|
+
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
25
|
+
|
|
26
|
+
function siteOf(root, url) {
|
|
27
|
+
const studio = readStudio(root);
|
|
28
|
+
const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
|
|
29
|
+
if (!/^https?:\/\//.test(site)) return { studio, site: null };
|
|
30
|
+
return { studio, site };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Put a key's hash into the studio's D1 (remote, or local for a loopback site). The key never leaves this machine. */
|
|
34
|
+
function mint(root, studio, kind, ttlMs, { local }) {
|
|
35
|
+
const key = `hsk_${randomBytes(24).toString('hex')}`;
|
|
36
|
+
const hash = createHash('sha256').update(key).digest('hex');
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
39
|
+
const sql = `DELETE FROM stats_keys WHERE expires_at < ${now}; INSERT INTO stats_keys (hash, kind, expires_at) VALUES ('${hash}', '${kind}', ${now + ttlMs});`;
|
|
40
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', sql]);
|
|
41
|
+
if (res.code !== 0) {
|
|
42
|
+
const why = /no such table: stats_keys/i.test(res.out)
|
|
43
|
+
? 'this studio\'s D1 has no stats tables yet: run `npm run deploy` (it applies migration 0002_studio_stats.sql), or `npm run dev` for a local site'
|
|
44
|
+
: /not logged in|login|authenticat/i.test(res.out) ? 'Wrangler is not signed in to this studio\'s Cloudflare: run `npx wrangler login` (the person approves once)'
|
|
45
|
+
: `could not write the key's hash to D1: ${res.out.trim().split('\n').slice(-3).join(' ')}`;
|
|
46
|
+
return { ok: false, why };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function drop(root, studio, hash, { local }) {
|
|
52
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
53
|
+
w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function statsQuery({ range, game, song, video } = {}) {
|
|
57
|
+
const q = new URLSearchParams();
|
|
58
|
+
q.set('range', RANGES.has(range) ? range : '7d');
|
|
59
|
+
if (game && SLUG.test(game)) q.set('game', game);
|
|
60
|
+
else if (song && SLUG.test(song)) q.set('song', song);
|
|
61
|
+
else if (video && SLUG.test(video)) q.set('video', video);
|
|
62
|
+
return q;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The numbers, read with a ten-minute key that is dropped as soon as they are in. */
|
|
66
|
+
export async function statsShow(root, { url, range, game, song, video } = {}) {
|
|
67
|
+
const { studio, site } = siteOf(root, url);
|
|
68
|
+
if (!site) return { ok: false, command: 'stats', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
69
|
+
const local = loopback(site);
|
|
70
|
+
const k = mint(root, studio, 'read', 10 * 60_000, { local });
|
|
71
|
+
if (!k.ok) return { ok: false, command: 'stats', why: k.why };
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${site}/api/stats?${statsQuery({ range, game, song, video })}`, { headers: { authorization: `Bearer ${k.key}` }, signal: AbortSignal.timeout(20_000) });
|
|
74
|
+
const body = await res.json().catch(() => ({}));
|
|
75
|
+
if (!res.ok || !body.ok) return { ok: false, command: 'stats', why: body.message ?? `${site}/api/stats answered ${res.status}` };
|
|
76
|
+
return { command: 'stats', ...body, ok: true };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, command: 'stats', why: `could not read ${site}/api/stats: ${error.message}` };
|
|
79
|
+
} finally { drop(root, studio, k.hash, { local }); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A read key for `studio_stats` (the Homie MCP): 1 hour by default, 30 days at most. */
|
|
83
|
+
export function statsKey(root, { url, hours } = {}) {
|
|
84
|
+
const { studio, site } = siteOf(root, url);
|
|
85
|
+
if (!site) return { ok: false, command: 'stats key', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
86
|
+
const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
|
|
87
|
+
const k = mint(root, studio, 'read', h * HOUR, { local: loopback(site) });
|
|
88
|
+
if (!k.ok) return { ok: false, command: 'stats key', why: k.why };
|
|
89
|
+
return {
|
|
90
|
+
ok: true, command: 'stats key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
|
|
91
|
+
use: `Call the Homie MCP tool studio_stats with { "site": "${site}", "key": "<this key>" } (and range, game, song or video). The key only reads this studio's numbers, ends at ${k.expiresAt}, and \`homie-studio stats revoke\` ends it sooner. Never paste it anywhere else.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A one-time sign-in link (30 minutes) that opens the private stats page in the owner's own browser. */
|
|
96
|
+
export function statsLink(root, { url } = {}) {
|
|
97
|
+
const { studio, site } = siteOf(root, url);
|
|
98
|
+
if (!site) return { ok: false, command: 'stats link', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
99
|
+
const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
|
|
100
|
+
if (!k.ok) return { ok: false, command: 'stats link', why: k.why };
|
|
101
|
+
return {
|
|
102
|
+
ok: true, command: 'stats link', site, link: `${site}/_studio/signin?k=${k.key}`, expiresAt: k.expiresAt,
|
|
103
|
+
use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in to the stats page for 30 days. It is theirs alone; do not post it anywhere.',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function statsRevoke(root, { url } = {}) {
|
|
108
|
+
const { studio, site } = siteOf(root, url);
|
|
109
|
+
const local = site ? loopback(site) : false;
|
|
110
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
111
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', 'DELETE FROM stats_keys;']);
|
|
112
|
+
return res.code === 0 ? { ok: true, command: 'stats revoke', site, message: 'Every stats key and page session has ended.' } : { ok: false, command: 'stats revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function statsShare(root, on) {
|
|
116
|
+
const studio = readStudio(root);
|
|
117
|
+
const share = on === 'on' || on === true || on === 'true';
|
|
118
|
+
writeStudio(root, { ...studio, stats: { ...(studio.stats ?? {}), share } });
|
|
119
|
+
return {
|
|
120
|
+
ok: true, command: 'stats share', share,
|
|
121
|
+
message: share
|
|
122
|
+
? 'studio.json stats.share is on. After `npm run deploy`, the site tells the homie.rocks directory two numbers for the whole studio: Play presses and rounds with people in them, over the last 7 days. Nothing else leaves the studio.'
|
|
123
|
+
: 'studio.json stats.share is off. After `npm run deploy`, the site tells the directory nothing.',
|
|
124
|
+
};
|
|
125
|
+
}
|
package/lib/studio.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
-
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts']);
|
|
11
|
+
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed']);
|
|
12
12
|
|
|
13
13
|
export function findStudio(from = process.cwd()) {
|
|
14
14
|
let at = resolve(from);
|
|
@@ -29,6 +29,48 @@ export function requireStudio(from) {
|
|
|
29
29
|
export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
|
|
30
30
|
export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
|
|
31
31
|
|
|
32
|
+
/*
|
|
33
|
+
* WHERE THE SITE'S ADDRESS LIVES. studio.json is committed, and a workers.dev address carries the Cloudflare
|
|
34
|
+
* account's subdomain, which is often the person's own name. So studio.json keeps only a CUSTOM domain
|
|
35
|
+
* (`cloudflare.domain`, e.g. "night-owls.example"); the workers.dev address `deploy` gets back stays on this
|
|
36
|
+
* computer in .studio/local.json, which the studio's .gitignore leaves out.
|
|
37
|
+
*/
|
|
38
|
+
export const LOCAL_STATE = '.studio/local.json';
|
|
39
|
+
export const isWorkersDev = (url) => { try { return /\.workers\.dev$/i.test(new URL(url).hostname); } catch { return false; } };
|
|
40
|
+
|
|
41
|
+
/** `cloudflare.domain` as an origin: "example.com" or "https://example.com/" become "https://example.com"; else null. */
|
|
42
|
+
export function domainOrigin(value) {
|
|
43
|
+
const raw = String(value ?? '').trim();
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
47
|
+
if (u.protocol !== 'https:' || !u.hostname.includes('.') || isWorkersDev(u.origin)) return null;
|
|
48
|
+
return u.origin;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readLocal(root) {
|
|
53
|
+
try { return JSON.parse(readFileSync(join(root, LOCAL_STATE), 'utf8')); } catch { return {}; }
|
|
54
|
+
}
|
|
55
|
+
export function writeLocal(root, patch) {
|
|
56
|
+
const path = join(root, LOCAL_STATE);
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
const next = { ...readLocal(root), ...patch };
|
|
59
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The studio site's public address: its custom domain (studio.json `cloudflare.domain`, or a non-workers.dev
|
|
65
|
+
* `cloudflare.url` an older studio wrote by hand), else the workers.dev address this computer's deploy got back,
|
|
66
|
+
* else an older studio.json's workers.dev `url` (until the next deploy moves it out).
|
|
67
|
+
*/
|
|
68
|
+
export function siteUrl(root, studio = readStudio(root)) {
|
|
69
|
+
const cf = studio.cloudflare ?? {};
|
|
70
|
+
const custom = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
|
|
71
|
+
return custom ?? readLocal(root).url ?? cf.url ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
export function listGames(root) {
|
|
33
75
|
const dir = join(root, 'games');
|
|
34
76
|
if (!existsSync(dir)) return [];
|