@homie-rocks/studio 0.26.0 → 0.27.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/CHANGELOG.md +67 -1
- package/README.md +8 -0
- package/lib/build.mjs +65 -1
- package/lib/media.mjs +7 -0
- package/lib/scaffold.mjs +5 -2
- package/lib/schema-check.mjs +227 -0
- package/lib/schema-vocab.json +1 -0
- package/lib/site.mjs +15 -0
- package/lib/template-history.json +78 -0
- package/media/MEDIA.md +5 -0
- package/package.json +1 -1
- package/site/SITE.md +67 -2
- package/worker/discover.mjs +232 -0
- package/worker/index.mjs +41 -4
- package/worker/schema.mjs +321 -0
- package/worker/site.mjs +46 -12
- package/worker/version.mjs +1 -1
package/lib/site.mjs
CHANGED
|
@@ -378,6 +378,20 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
378
378
|
}
|
|
379
379
|
if (!hero.wideImage) hero.wideImage = (trailer && hero.fromTrailer ? fileUrl(trailer, 'poster') : null) ?? cover;
|
|
380
380
|
for (const k of Object.keys(hero)) if (!hero[k]) delete hero[k];
|
|
381
|
+
// Screenshots (0.27.0): game.json landing.screenshots (paths in the game's folder), else the pictures in
|
|
382
|
+
// games/<id>/screenshots/, by name; at most eight. The landing shows them and its VideoGame names them.
|
|
383
|
+
let screenshots = [];
|
|
384
|
+
if (Array.isArray(L.screenshots)) {
|
|
385
|
+
screenshots = L.screenshots.slice(0, 8).map((rel) => {
|
|
386
|
+
const abs = typeof rel === 'string' && IMAGE.test(rel) ? inside(g.dir, rel) : null;
|
|
387
|
+
const url = abs ? serve(abs, `screenshot ${rel}`) : null;
|
|
388
|
+
if (!url) log(`warning: games/${g.id}/game.json landing.screenshots "${String(rel).slice(0, 60)}" is not a picture in the game's folder`);
|
|
389
|
+
return url;
|
|
390
|
+
}).filter(Boolean);
|
|
391
|
+
} else if (existsSync(join(g.dir, 'screenshots')) && statSync(join(g.dir, 'screenshots')).isDirectory()) {
|
|
392
|
+
screenshots = readdirSync(join(g.dir, 'screenshots')).filter((n) => IMAGE.test(n) && !n.startsWith('.')).sort().slice(0, 8)
|
|
393
|
+
.map((n) => serve(join(g.dir, 'screenshots', n), `screenshots/${n}`)).filter(Boolean);
|
|
394
|
+
}
|
|
381
395
|
const focus = /^\d{1,3}% \d{1,3}%$/.test(String(H.focus ?? '')) ? String(H.focus) : null;
|
|
382
396
|
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
397
|
const alt = str(H.alt, 300);
|
|
@@ -446,6 +460,7 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
446
460
|
hero: { ...hero, ...(focus ? { focus } : {}), ...(alt ? { alt } : {}), ...(tint !== null ? { tint } : {}) },
|
|
447
461
|
headline: str(L.headline, 120),
|
|
448
462
|
cover,
|
|
463
|
+
...(screenshots.length ? { screenshots } : {}),
|
|
449
464
|
words: words && (words.one || words.many) ? words : null,
|
|
450
465
|
credits: { people, original, parts, texts: texts.length },
|
|
451
466
|
trailer: trailer ? trailer.slug : null,
|
|
@@ -1273,6 +1273,84 @@
|
|
|
1273
1273
|
}
|
|
1274
1274
|
}
|
|
1275
1275
|
}
|
|
1276
|
+
},
|
|
1277
|
+
"0.26.1": {
|
|
1278
|
+
"files": {
|
|
1279
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1280
|
+
"README.md": "684b3cca8001673c",
|
|
1281
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1282
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1283
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1284
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1285
|
+
"posts/README.md": "37841aae476bd540",
|
|
1286
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1287
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1288
|
+
},
|
|
1289
|
+
"sections": {
|
|
1290
|
+
"AGENTS.md": {
|
|
1291
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1292
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1293
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1294
|
+
"## The site": "ab922bb6bde23293",
|
|
1295
|
+
"## Making games": "abf2f0d59341512d",
|
|
1296
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1297
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1298
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1299
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1300
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1301
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1302
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1303
|
+
"## Beta": "9d02638207a26e73"
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
"named": {
|
|
1307
|
+
"Homie Arcade": {
|
|
1308
|
+
"sections": {
|
|
1309
|
+
"AGENTS.md": {
|
|
1310
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
"0.27.0": {
|
|
1317
|
+
"files": {
|
|
1318
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1319
|
+
"README.md": "684b3cca8001673c",
|
|
1320
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1321
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1322
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1323
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1324
|
+
"posts/README.md": "37841aae476bd540",
|
|
1325
|
+
"site/README.md": "2928ca4f079ab76b",
|
|
1326
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1327
|
+
},
|
|
1328
|
+
"sections": {
|
|
1329
|
+
"AGENTS.md": {
|
|
1330
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1331
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1332
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1333
|
+
"## The site": "366ecce1a424837a",
|
|
1334
|
+
"## Making games": "abf2f0d59341512d",
|
|
1335
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1336
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1337
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1338
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1339
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1340
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1341
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1342
|
+
"## Beta": "9d02638207a26e73"
|
|
1343
|
+
}
|
|
1344
|
+
},
|
|
1345
|
+
"named": {
|
|
1346
|
+
"Homie Arcade": {
|
|
1347
|
+
"sections": {
|
|
1348
|
+
"AGENTS.md": {
|
|
1349
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1276
1354
|
}
|
|
1277
1355
|
}
|
|
1278
1356
|
}
|
package/media/MEDIA.md
CHANGED
|
@@ -25,6 +25,7 @@ show and why anything is left out.
|
|
|
25
25
|
"title": "Night Owls Theme",
|
|
26
26
|
"blurb": "The studio's theme: a short sung hook over a driving synth line.",
|
|
27
27
|
"published": true,
|
|
28
|
+
"date": "2026-09-30",
|
|
28
29
|
"duration": 16.0,
|
|
29
30
|
"bpm": 120,
|
|
30
31
|
"key": "A minor",
|
|
@@ -57,6 +58,10 @@ show and why anything is left out.
|
|
|
57
58
|
`poster`, `captions` (WebVTT), `master`. `"public": false` keeps a file off the site (masters,
|
|
58
59
|
raw captures). Loops and stems are offered for download on the song page, so another game can
|
|
59
60
|
use them.
|
|
61
|
+
- `date` (0.27.0): the day (or day and time, UTC) it came out; without one, `made.at`. A video's page gives it to
|
|
62
|
+
search engines as the video's upload date, and they show no video without one (the build says when a video has
|
|
63
|
+
neither). A music manifest's top-level `"album": "Night Route"` (or an entry's own `album`) makes its songs one
|
|
64
|
+
album in the pages' structured data (site/SITE.md, "Search engines and AI agents").
|
|
60
65
|
- `rights` and `credits` are shown on the page, in plain words. Say what the provider's terms
|
|
61
66
|
say for the plan the file was made on; nothing on the site claims more.
|
|
62
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/site/SITE.md
CHANGED
|
@@ -97,10 +97,17 @@ game.json's `landing` block, every key optional:
|
|
|
97
97
|
"credits": [{ "role": "Music", "name": "Low Tide", "url": "https://example.com" }],
|
|
98
98
|
"theme": { "accent": "#ff3bd4", "glow": "#00eaff" },
|
|
99
99
|
"scheme": "light",
|
|
100
|
+
"screenshots": ["shots/rocks.jpg", "shots/finish.jpg"],
|
|
100
101
|
"tv": true
|
|
101
102
|
}
|
|
102
103
|
```
|
|
103
104
|
|
|
105
|
+
**Screenshots** (0.27.0): `landing.screenshots` (pictures in the game's folder), else every picture in
|
|
106
|
+
`games/<id>/screenshots/` by name, at most eight. The landing shows them in a band of their own, and its VideoGame
|
|
107
|
+
names them. Beside the `landing` block, game.json takes `"genre"` (a word, or up to three: `["Racing", "Party"]`,
|
|
108
|
+
shown with the room facts), `"released"` (the day it came out, else the commit that added its game.json) and
|
|
109
|
+
`"schema"` (structured data of the owner's own: [Search engines and AI agents](#search-engines-and-ai-agents)).
|
|
110
|
+
|
|
104
111
|
## Posts: `posts/*.md`
|
|
105
112
|
|
|
106
113
|
One markdown file each. The name is the address, `posts/2026-09-30-we-are-live.md` is `/posts/we-are-live/`, dated
|
|
@@ -141,8 +148,8 @@ atproto later. `/.well-known/homie-studio.json` lists the latest posts for the h
|
|
|
141
148
|
| `site/theme.json` | The tokens every page uses: `bg`, `fg`, `accent`, `glow`, `panel`, `accentInk` (colours: `#hex`, `rgb()`, `hsl()`, `oklch()`), `display`, `text`, `mono` (font lists), `fonts` (`[{ "family", "src": "/fonts/x.woff2", "weight": "400 800" }]`, files in `site/public`), `radius` (0 to 40), `mark` (a logo), `icon`, `social` (the share picture), `wordmark`, `uppercase: false`, `scheme: "light"`. Or `palette`: neon, dock, gold, acid, ember, orchid, tide, candy. A value that is not what it should be is dropped, with a warning. |
|
|
142
149
|
| `site/theme.css` | CSS after the site's own, on every generated page: restyle anything. |
|
|
143
150
|
| `site/partials/<name>.html` | A piece of every generated page: `head` (in `<head>`), `header` (the top line and its tabs), `footer` (the "Made with Homie" footer), `home` (a band on Home), `game` (a band on every landing), `game-<id>` (a band on one), `post` (after a post). `{{studio.name}}`, `{{game.name}}`, `{{game.id}}`, `{{game.play}}`, `{{year}}` are filled in. A band partial sits in one of the page's bands, unless it is its own `<section class="band">`. |
|
|
144
|
-
| `site/pages/<path>/index.html` (or `site/pages/<path>.html`) | A whole page at `/<path>/`, served as it is, instead of the generated one (`site/pages/index.html` is Home, `site/pages/<id>/index.html` a game's landing) or beside them (`/about/`). It may borrow the site's parts: `<!-- homie:style -->`, `<!-- homie:header -->`, `<!-- homie:footer -->`, `<!-- homie:script
|
|
145
|
-
| `site/public/` | Files served as they are at the same path: fonts, a logo, hero footage, a page's pictures. |
|
|
151
|
+
| `site/pages/<path>/index.html` (or `site/pages/<path>.html`) | A whole page at `/<path>/`, served as it is, instead of the generated one (`site/pages/index.html` is Home, `site/pages/<id>/index.html` a game's landing) or beside them (`/about/`). It may borrow the site's parts: `<!-- homie:style -->`, `<!-- homie:header -->`, `<!-- homie:footer -->`, `<!-- homie:script -->`, and in its `<head>` `<!-- homie:schema -->` (the structured data the generated page there would carry: Home's, a landing's, else the studio's Organization). The rooms, the game files, the API and the owner's pages are never a page's. |
|
|
152
|
+
| `site/public/` | Files served as they are at the same path: fonts, a logo, hero footage, a page's pictures. A `robots.txt`, `sitemap.xml`, `llms.txt` or `llms-full.txt` here replaces the one the site makes. |
|
|
146
153
|
| `site/src/worker.mjs` | The Worker. A studio that keeps a whole brand site of its own (with its own routes) wraps the template's Worker here, as the house brands do; everything it does not answer goes to the template. |
|
|
147
154
|
|
|
148
155
|
A studio with its own landing in `site/` keeps it: the template never replaces what the studio made.
|
|
@@ -306,6 +313,64 @@ goes there as a POST. The site answers at its root and under every game with `{
|
|
|
306
313
|
"not-a-homie" }` (JSON, CORS-open, 200, a preflight answered), so the game stops asking and plays on its own; no
|
|
307
314
|
studio needs a Worker of its own for it.
|
|
308
315
|
|
|
316
|
+
## Search engines and AI agents
|
|
317
|
+
|
|
318
|
+
From 0.27.0 the site tells search engines and AI agents what it is, from its own files, and nothing more
|
|
319
|
+
(worker/schema.mjs, worker/discover.mjs).
|
|
320
|
+
|
|
321
|
+
**Structured data.** Every generated page carries one `<script type="application/ld+json">` in its `<head>`, a
|
|
322
|
+
schema.org `@graph`:
|
|
323
|
+
|
|
324
|
+
| Page | Types |
|
|
325
|
+
| --- | --- |
|
|
326
|
+
| Home | `Organization` (the studio: name, address, `site/theme.json` `mark` as its logo, `social` as its image, the tagline) and `WebSite` |
|
|
327
|
+
| `/games/`, `/rooms/` | `BreadcrumbList`, `ItemList` of the public games' landings (the Rooms page's data never carries a live count) |
|
|
328
|
+
| `/<id>/` | `BreadcrumbList`, `VideoGame` co-typed `WebApplication`: name, description, the pitch as `abstract`, its pictures and screenshots, `genre`, `numberOfPlayers` (min and max), `playMode`, `gamePlatform`, the studio as author and publisher, `license` (the source's licence, linked to its SPDX page), `isBasedOn` (a remix's original page, a port's original), `trailer` (a `VideoObject`), `datePublished` and `dateModified`, a `PlayAction`, and one free-to-play `Offer` (price 0) |
|
|
329
|
+
| `/<id>/credits`, `/<id>/servers/`, a server's page | `BreadcrumbList` |
|
|
330
|
+
| `/music/`, `/music/<slug>/` | `MusicAlbum` (every song names one album) or an `ItemList`; `MusicRecording` (`byArtist` the studio, its duration, its `AudioObject`, its cover, `inAlbum`, `musicalKey`, its date) |
|
|
331
|
+
| `/videos/`, `/videos/<slug>/` | `ItemList`; `VideoObject` (`thumbnailUrl`, `uploadDate`, `duration`, `contentUrl`: the file at its own address, from R2 or the site) |
|
|
332
|
+
| `/posts/`, `/posts/<slug>/` | `Blog`; `BlogPosting` (headline, dates, the author it names or the studio, the publisher, its picture, the game it is about) |
|
|
333
|
+
|
|
334
|
+
Never: a rating or a review (the page shows none), a price for anything the shop does not sell, or a number that is
|
|
335
|
+
only true this minute. While the studio's shop really sells (shop/SHOP.md: shop.json checked, its key, webhook
|
|
336
|
+
secret and tables in place), each item sold in a game is an `addOn` of that game's free offer, in real money; a tip
|
|
337
|
+
has no price and is left out. A game that is not public yet (private, or an invite-only beta) offers nothing, and its
|
|
338
|
+
landing answers `x-robots-tag: noindex`.
|
|
339
|
+
|
|
340
|
+
A video's `uploadDate` is its manifest entry's `"date"`, else `made.at`; a video with neither is said by the build,
|
|
341
|
+
because search engines show no video without it. A song's date is the same, and a music manifest's `"album"` (or an
|
|
342
|
+
entry's own) makes the songs a `MusicAlbum`. A game's `datePublished` is game.json `"released"`, else the commit that
|
|
343
|
+
added its game.json; `dateModified` is the last commit that touched its folder (nothing from a shallow clone).
|
|
344
|
+
|
|
345
|
+
**The owner's own.** game.json `"schema"` adds schema.org properties to the game's `VideoGame`, and studio.json
|
|
346
|
+
`"site": { "schema": { … } }` to the studio's `Organization` (its `sameAs`, say):
|
|
347
|
+
|
|
348
|
+
```json
|
|
349
|
+
"site": { "schema": { "sameAs": ["https://github.com/night-owls", "https://www.youtube.com/@nightowls"], "foundingDate": "2026" } }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Homie's own properties always win; `aggregateRating`, `review` and `offers` are refused, and the build says so, with
|
|
353
|
+
any property schema.org does not put on that type. A whole block of the owner's own still goes in
|
|
354
|
+
`site/partials/head.html`.
|
|
355
|
+
|
|
356
|
+
**Files for crawlers and agents**, made from the public catalogue: a private or invite-only game is in none of them.
|
|
357
|
+
|
|
358
|
+
- `/robots.txt`: every public page may be crawled; `/_studio/`, `/api/`, `/account/`, the shop's private steps
|
|
359
|
+
and each game's play, TV, watch, live, invite, API and socket doors may not; then the sitemap's address. A
|
|
360
|
+
Preview answers `Disallow: /`.
|
|
361
|
+
- `/sitemap.xml`: Home, Games and each landing (and its credits), Rooms, Music, Videos, Posts and every page in
|
|
362
|
+
`site/pages`, with `lastmod` only where a real date says when it changed.
|
|
363
|
+
- `/llms.txt`, following the llms.txt convention (an H1, a `>` summary, then `##` sections of links): what the
|
|
364
|
+
studio is; each game with its pitch, players, rounds, Play, Watch and the big screen; which games are open to remix,
|
|
365
|
+
with their licence, their `source.json` and the words to say to Claude Code or Codex with Homie (the plugin's
|
|
366
|
+
`game_remix` tool, or `homie-studio game remix <source> --id <new-id>`); songs; videos; posts with their Atom and
|
|
367
|
+
JSON feeds; the studio's own pages (a changelog among them); and homie.rocks's own llms.txt.
|
|
368
|
+
- `/llms-full.txt`: the same, with each game's whole description, how to play, controls and credits, and every
|
|
369
|
+
post's text.
|
|
370
|
+
|
|
371
|
+
A game is offered for remixing only when its source is shared, its owner's Remixable switch is on and its licence
|
|
372
|
+
allows it, as for the directory. They are cached for five minutes. A file of the studio's own in `site/public` wins.
|
|
373
|
+
|
|
309
374
|
## Headers
|
|
310
375
|
|
|
311
376
|
Every HTML answer is `no-transform` (a custom domain's edge injects nothing: Web Analytics' automatic beacon
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW SEARCH ENGINES AND AI AGENTS FIND A STUDIO (0.27.0): four plain files the site makes from its own catalogue,
|
|
3
|
+
* so they are never out of date and never name what is not public.
|
|
4
|
+
*
|
|
5
|
+
* /robots.txt every public page may be crawled; the owner's pages (/_studio/), the APIs, accounts, the shop's
|
|
6
|
+
* private steps and each game's play, TV, watch and socket doors may not; the sitemap's address.
|
|
7
|
+
* A Preview (a branch under review) asks not to be crawled at all.
|
|
8
|
+
* /sitemap.xml every public page (Home, Games and each landing, Music, Videos, Rooms, Posts, the studio's own
|
|
9
|
+
* pages), with lastmod only where a real date says when it changed
|
|
10
|
+
* /llms.txt the llms.txt convention (an H1, a > summary, then ## sections of links): what the studio is, each
|
|
11
|
+
* public game with its pitch, players, Play, Watch, the big screen and whether and how it can be
|
|
12
|
+
* remixed (the licence, the source, the words to say to Claude Code or Codex with Homie), songs,
|
|
13
|
+
* videos, posts with their feeds, the studio's own pages, and a link to homie.rocks's llms.txt
|
|
14
|
+
* /llms-full.txt the same, with each game's whole description, how to play, controls and credits, and every
|
|
15
|
+
* post's text
|
|
16
|
+
*
|
|
17
|
+
* Only public games are ever named (a private or invite-only game is in none of them), and a game is offered for
|
|
18
|
+
* remixing only when its owner's remix switch is on, its source is shared and its licence allows it. A file of the
|
|
19
|
+
* studio's own in site/public (robots.txt, sitemap.xml, llms.txt, llms-full.txt) wins over the made one.
|
|
20
|
+
*/
|
|
21
|
+
import { licenseOf, LICENSE_KINDS, remixRow } from './license.mjs';
|
|
22
|
+
|
|
23
|
+
export const DISCOVERY_FILES = ['/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt'];
|
|
24
|
+
|
|
25
|
+
const studioName = (cat) => cat?.studio?.name ?? 'Studio';
|
|
26
|
+
const one = (v, n = 400) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
27
|
+
/** Text for a markdown link's words: brackets escaped, one line. */
|
|
28
|
+
const label = (v) => one(v, 120).replace(/([[\]\\])/g, '\\$1');
|
|
29
|
+
const xml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
30
|
+
const dateOf = (v) => (typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : null);
|
|
31
|
+
const newest = (list) => list.map(dateOf).filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
|
|
32
|
+
const day = (v) => (dateOf(v) ? new Date(Date.parse(v)).toISOString().slice(0, 10) : null);
|
|
33
|
+
const clock = (s) => (Number.isFinite(s) && s > 0 ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : null);
|
|
34
|
+
const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
|
|
35
|
+
const players = (g) => { const min = g.players?.min ?? 1; const max = g.players?.max ?? 8; return max <= 1 ? '1 player' : min === max ? `${max} players` : `${min}–${max} players`; };
|
|
36
|
+
const rounds = (s) => (Number.isFinite(s) && s > 0 ? (s % 60 ? `${s}-second rounds` : `${s / 60}-minute rounds`) : null);
|
|
37
|
+
const watchable = (g) => !(g.watch === false || g.watch === 'off');
|
|
38
|
+
const tv = (g) => g.landing?.tv !== false;
|
|
39
|
+
const gameDate = (g) => dateOf(g.dates?.modified) ?? dateOf(g.dates?.published);
|
|
40
|
+
const mediaDate = (e) => dateOf(e.date) ?? dateOf(e.made?.at);
|
|
41
|
+
const postDate = (p) => dateOf(p.updated) ?? dateOf(p.date);
|
|
42
|
+
|
|
43
|
+
/** The studio's own pages (site/pages) the sitemap and llms.txt name: not Home, not a game's landing, never the site's own places. */
|
|
44
|
+
function ownPages(cat, all) {
|
|
45
|
+
const ids = new Set((all?.games ?? cat.games ?? []).map((g) => g.id));
|
|
46
|
+
return (cat.site?.pages ?? []).filter((p) => typeof p === 'string' && /^\/[a-z0-9][a-z0-9/_-]*\/$/i.test(p) && p !== '/')
|
|
47
|
+
.filter((p) => !ids.has(p.split('/')[1]) && !/^\/(?:games|music|videos|rooms|posts|account|shop|api|_studio|_homie|_site|media)\//.test(p));
|
|
48
|
+
}
|
|
49
|
+
const pageName = (p) => p.split('/').filter(Boolean).map((s) => s.replace(/[-_]+/g, ' ')).join(' · ').replace(/^./, (c) => c.toUpperCase());
|
|
50
|
+
|
|
51
|
+
/* ------------------------------------------------------------------ robots.txt */
|
|
52
|
+
|
|
53
|
+
export function robotsTxt(cat, origin, { preview = false } = {}) {
|
|
54
|
+
if (preview) {
|
|
55
|
+
return `# ${one(studioName(cat), 80)}: a Preview (a branch under review), not the studio's site. Nothing here is for search.\nUser-agent: *\nDisallow: /\n`;
|
|
56
|
+
}
|
|
57
|
+
const games = (cat.games ?? []).map((g) => g.id);
|
|
58
|
+
const lines = [
|
|
59
|
+
`# ${one(studioName(cat), 80)}, made with Homie (https://homie.rocks/studio/).`,
|
|
60
|
+
'# Every public page may be crawled. The owner\'s pages, the APIs, accounts, the shop\'s private steps and each game\'s',
|
|
61
|
+
'# play, TV, watch and socket doors may not (they open live rooms). AI agents: /llms.txt says what is here.',
|
|
62
|
+
'User-agent: *',
|
|
63
|
+
'Allow: /',
|
|
64
|
+
'Disallow: /_studio/',
|
|
65
|
+
'Disallow: /api/',
|
|
66
|
+
'Disallow: /account/',
|
|
67
|
+
'Disallow: /shop/thanks',
|
|
68
|
+
'Disallow: /shop/parent/',
|
|
69
|
+
'Disallow: /__homie',
|
|
70
|
+
...games.flatMap((id) => [`Disallow: /${id}/play`, `Disallow: /${id}/tv`, `Disallow: /${id}/watch`, `Disallow: /${id}/live`, `Disallow: /${id}/invite`, `Disallow: /${id}/api/`, `Disallow: /${id}/__`, `Disallow: /${id}/s/*/play`, `Disallow: /${id}/s/*/tv`, `Disallow: /${id}/s/*/home`]),
|
|
71
|
+
'',
|
|
72
|
+
`Sitemap: ${origin}/sitemap.xml`,
|
|
73
|
+
];
|
|
74
|
+
return `${lines.join('\n')}\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------------ sitemap.xml */
|
|
78
|
+
|
|
79
|
+
/** [{ path, lastmod }] for every public page, Home first. */
|
|
80
|
+
export function sitemapEntries(cat, { all = null } = {}) {
|
|
81
|
+
const games = cat.games ?? [];
|
|
82
|
+
const songs = cat.songs ?? [];
|
|
83
|
+
const videos = cat.videos ?? [];
|
|
84
|
+
const posts = cat.posts ?? [];
|
|
85
|
+
const out = [];
|
|
86
|
+
const add = (path, lastmod = null) => { if (!out.some((x) => x.path === path)) out.push({ path, lastmod: dateOf(lastmod) }); };
|
|
87
|
+
add('/', newest([...games.map(gameDate), ...songs.map(mediaDate), ...videos.map(mediaDate), ...posts.map(postDate)]));
|
|
88
|
+
if (games.length) {
|
|
89
|
+
add('/games/', newest(games.map(gameDate)));
|
|
90
|
+
for (const g of games) {
|
|
91
|
+
add(`/${g.id}/`, gameDate(g));
|
|
92
|
+
if (g.landing?.credits?.texts) add(`/${g.id}/credits`, gameDate(g));
|
|
93
|
+
}
|
|
94
|
+
add('/rooms/');
|
|
95
|
+
}
|
|
96
|
+
if (songs.length) { add('/music/', newest(songs.map(mediaDate))); for (const e of songs) add(`/music/${e.slug}/`, mediaDate(e)); }
|
|
97
|
+
if (videos.length) { add('/videos/', newest(videos.map(mediaDate))); for (const e of videos) add(`/videos/${e.slug}/`, mediaDate(e)); }
|
|
98
|
+
if (posts.length) { add('/posts/', newest(posts.map(postDate))); for (const p of posts) add(`/posts/${p.slug}/`, postDate(p)); }
|
|
99
|
+
for (const p of ownPages(cat, all)) add(p);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function sitemapXml(cat, origin, opts = {}) {
|
|
104
|
+
const urls = sitemapEntries(cat, opts).map((u) => ` <url><loc>${xml(`${origin}${u.path}`)}</loc>${u.lastmod ? `<lastmod>${xml(new Date(Date.parse(u.lastmod)).toISOString().replace(/\.\d{3}Z$/, 'Z'))}</lastmod>` : ''}</url>`);
|
|
105
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ------------------------------------------------------------------ llms.txt */
|
|
109
|
+
|
|
110
|
+
const HOMIE_INSTALL = 'in Claude Code, `/plugin marketplace add homie-rocks/homie` and then `/plugin install homie@homie` (Codex takes the same marketplace)';
|
|
111
|
+
|
|
112
|
+
function licenceWords(g) {
|
|
113
|
+
const l = licenseOf(g.license);
|
|
114
|
+
const name = `${LICENSE_KINDS[l.kind]}${l.spdx ? `, ${l.spdx}` : ''}`;
|
|
115
|
+
if (l.kind === 'remix-freely') return `${name}: a credit is welcome, not asked for`;
|
|
116
|
+
return `${name}: a remix says "Remix of ${one(g.name, 80)} by ${one(g.studio, 80)}" with a link back`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function summaryOf(cat, origin) {
|
|
120
|
+
const name = studioName(cat);
|
|
121
|
+
const tag = one(cat.studio?.tagline, 140);
|
|
122
|
+
const games = (cat.games ?? []).length;
|
|
123
|
+
// What it has, never what it might: a studio with no game says only what it publishes (a music studio makes no game).
|
|
124
|
+
const has = [(cat.songs ?? []).length ? 'songs' : null, (cat.videos ?? []).length ? 'videos' : null, (cat.posts ?? []).length ? 'posts' : null].filter(Boolean);
|
|
125
|
+
const kinds = has.length > 1 ? `${has.slice(0, -1).join(', ')} and ${has.at(-1)}` : has[0];
|
|
126
|
+
const what = games
|
|
127
|
+
? 'It makes free multiplayer games that play in a web browser on a phone, a computer or a TV; press Play and you are in a live public room with whoever is playing, and bots hold the empty seats.'
|
|
128
|
+
: has.length ? `It publishes ${kinds}.` : 'Its first game is coming soon.';
|
|
129
|
+
return [
|
|
130
|
+
`# ${one(name, 120)}`,
|
|
131
|
+
'',
|
|
132
|
+
`> ${one(name, 120)} is a studio made with Homie${tag ? ` (${tag})` : ''}. ${what} Its site is ${origin}/.`,
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** /llms.txt. `remixable(g)`: the owner's remix switch is on, the source is shared, and the licence allows it. */
|
|
137
|
+
export function llmsTxt(cat, origin, { remixable = () => false, directory = 'https://homie.rocks', full = false, posts = null, all = null } = {}) {
|
|
138
|
+
const name = studioName(cat);
|
|
139
|
+
const games = (cat.games ?? []).map((g) => ({ ...g, studio: name }));
|
|
140
|
+
const songs = cat.songs ?? [];
|
|
141
|
+
const videos = cat.videos ?? [];
|
|
142
|
+
const list = cat.posts ?? [];
|
|
143
|
+
const out = summaryOf(cat, origin);
|
|
144
|
+
out.push('', full
|
|
145
|
+
? `Everything ${one(name, 120)} has published, in full: each game's whole description, how to play, controls and credits, every song and video, and every post's text. The short version with links only is ${origin}/llms.txt.`
|
|
146
|
+
: `${games.length ? 'Every game below is free and needs no download or account. A game\'s page has its trailer, how to play and credits; its Play link puts a visitor straight into a live public room. ' : ''}Whole descriptions and every post's text: ${origin}/llms-full.txt.`);
|
|
147
|
+
if (games.length) {
|
|
148
|
+
out.push('', '## Games', '');
|
|
149
|
+
for (const g of games) {
|
|
150
|
+
const L = g.landing ?? {};
|
|
151
|
+
const doors = [`Play: ${origin}/${g.id}/play`, ...(watchable(g) ? [`Watch a live room: ${origin}/${g.id}/watch`] : []), ...(tv(g) ? [`On a TV: ${origin}/${g.id}/tv`] : [])];
|
|
152
|
+
const facts = [players(g), rounds(g.roundSeconds), ...(Array.isArray(g.genre) ? g.genre : g.genre ? [g.genre] : [])].filter(Boolean).join(', ');
|
|
153
|
+
const lineage = remixRow(g.remixOf);
|
|
154
|
+
const open = remixable(g);
|
|
155
|
+
out.push(`- [${label(g.name)}](${origin}/${g.id}/): ${one(L.pitch || g.blurb, 240)}${/[.!?]$/.test(one(L.pitch || g.blurb)) ? '' : '.'} ${facts}.${lineage ? ` A remix of ${one(lineage.name, 80)}${lineage.studio ? ` by ${one(lineage.studio, 80)}` : ''}.` : ''} ${doors.join('. ')}. ${open ? `Open to remix (${LICENSE_KINDS[licenseOf(g.license).kind]}${licenseOf(g.license).spdx ? `, ${licenseOf(g.license).spdx}` : ''}): see Remix below.` : 'Not open to remix.'}`);
|
|
156
|
+
if (full) {
|
|
157
|
+
const C = L.credits ?? {};
|
|
158
|
+
const lines = [];
|
|
159
|
+
if (g.blurb && one(g.blurb) !== one(L.pitch)) lines.push(one(g.blurb, 600));
|
|
160
|
+
if (L.about) lines.push(one(L.about, 1200));
|
|
161
|
+
if ((L.how ?? []).length) lines.push(`How to play: ${L.how.map((x) => one(x, 240)).join(' ')}`);
|
|
162
|
+
const ctl = ['phone', 'computer', 'tv'].filter((k) => L.controls?.[k]).map((k) => `${k === 'tv' ? 'TV' : k}: ${one(L.controls[k], 300)}`);
|
|
163
|
+
if (ctl.length) lines.push(`Controls. ${ctl.join('. ')}.`);
|
|
164
|
+
const credits = [`Made by ${one(name, 80)}`, ...(C.people ?? []).map((c) => `${c.role ? `${one(c.role, 60)}: ` : ''}${one(c.name, 80)}`)];
|
|
165
|
+
if (C.original && !C.original.house && C.original.title) credits.push(`Based on ${one(C.original.title, 120)}${C.original.author ? ` by ${one(C.original.author, 120)}` : ''}${C.original.year ? ` (${one(C.original.year, 12)})` : ''}${C.original.licence ? `, ${one(C.original.licence, 60)} licence` : ''}${C.original.url ? ` (${C.original.url})` : ''}`);
|
|
166
|
+
for (const p of C.parts ?? []) credits.push(`${one(p.what, 160)}${p.author ? `: ${one(p.author, 120)}` : ''}${p.licence ? `, ${one(p.licence, 60)}` : ''}`);
|
|
167
|
+
lines.push(`Credits: ${credits.join('; ')}.`);
|
|
168
|
+
if (C.texts) lines.push(`Licence texts: ${origin}/${g.id}/credits`);
|
|
169
|
+
for (const l of lines) out.push(` ${l}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const open = games.filter((g) => remixable(g));
|
|
173
|
+
if (open.length) {
|
|
174
|
+
out.push('', '## Remix', '');
|
|
175
|
+
out.push(`Homie is a plugin for Claude Code and Codex that makes multiplayer games in a studio of your own, on your own free Cloudflare account. To remix one of these games, ${HOMIE_INSTALL}, then say "Remix <game> from <its source address> into a game of my own in my Homie studio". The plugin's \`game_remix\` tool (or \`npx --no-install homie-studio game remix <source address> --id <new-id>\` in a studio's folder) copies it in and writes the credit its licence asks for.`, '');
|
|
176
|
+
for (const g of open) out.push(`- [${label(g.name)} source](${origin}/games/${g.id}/source.json): ${licenceWords(g)}. Say: "Remix ${one(g.name, 80)} from ${origin}/games/${g.id}/source.json into a game of my own in my Homie studio".`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (songs.length) {
|
|
180
|
+
out.push('', '## Music', '');
|
|
181
|
+
for (const e of songs) {
|
|
182
|
+
out.push(`- [${label(e.title)}](${origin}/music/${e.slug}/): ${[KIND_WORD[e.kind] ?? 'Music', clock(e.duration), e.bpm ? `${e.bpm} BPM` : null, e.key, e.album ? `from ${one(e.album, 120)}` : null].filter(Boolean).join(', ')}.${e.blurb ? ` ${one(e.blurb, 300)}` : ''}`);
|
|
183
|
+
if (full && e.credits) out.push(` ${one(e.credits, 600)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (videos.length) {
|
|
187
|
+
out.push('', '## Videos', '');
|
|
188
|
+
for (const e of videos) {
|
|
189
|
+
out.push(`- [${label(e.title)}](${origin}/videos/${e.slug}/): ${[KIND_WORD[e.kind] ?? 'Video', clock(e.duration)].filter(Boolean).join(', ')}.${e.blurb ? ` ${one(e.blurb, 300)}` : ''}`);
|
|
190
|
+
if (full && e.credits) out.push(` ${one(e.credits, 600)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (list.length) {
|
|
194
|
+
out.push('', '## Posts', '');
|
|
195
|
+
out.push(`- [Atom feed](${origin}/posts/feed.xml): every post, newest first, with its full text`);
|
|
196
|
+
out.push(`- [JSON Feed](${origin}/posts/feed.json): the same, as JSON`);
|
|
197
|
+
for (const p of list.slice(0, full ? 200 : 20)) out.push(`- [${label(p.title)}](${origin}/posts/${p.slug}/): ${day(p.date) ?? ''}${p.summary ? `. ${one(p.summary, 300)}` : ''}`);
|
|
198
|
+
if (full && posts?.length) {
|
|
199
|
+
for (const p of posts) {
|
|
200
|
+
const textOf = p.record?.text;
|
|
201
|
+
if (!textOf) continue;
|
|
202
|
+
out.push('', `### ${one(p.title, 140)} (${day(p.date) ?? ''})`, '', `${origin}/posts/${p.slug}/`, '', String(textOf).trim().slice(0, 30000));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const pages = ownPages(cat, all);
|
|
207
|
+
if (pages.length) {
|
|
208
|
+
out.push('', '## Pages', '');
|
|
209
|
+
for (const p of pages) out.push(`- [${label(pageName(p))}](${origin}${p})`);
|
|
210
|
+
}
|
|
211
|
+
out.push('', '## Optional', '');
|
|
212
|
+
if (games.length) out.push(`- [Rooms](${origin}/rooms/): every public room playing now (${origin}/api/rooms is the same as JSON)`);
|
|
213
|
+
out.push(`- [Studio manifest](${origin}/.well-known/homie-studio.json): the games, songs, videos and posts as JSON`);
|
|
214
|
+
out.push(`- [Sitemap](${origin}/sitemap.xml): every public page`);
|
|
215
|
+
if (!full) out.push(`- [Everything, in full](${origin}/llms-full.txt): each game's whole description, how to play and credits, and every post's text`);
|
|
216
|
+
const hub = /^https:\/\/[a-z0-9.-]+$/i.test(String(directory ?? '')) ? directory : 'https://homie.rocks';
|
|
217
|
+
out.push(`- [Homie](${hub}/llms.txt): what Homie is, and how to make a studio like this one`);
|
|
218
|
+
return `${out.join('\n')}\n`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The plain-text answer every one of these files is sent with. */
|
|
222
|
+
export function discoveryResponse(body, type, { method = 'GET' } = {}) {
|
|
223
|
+
return new Response(method === 'HEAD' ? null : body, {
|
|
224
|
+
headers: {
|
|
225
|
+
'content-type': `${type}; charset=utf-8`,
|
|
226
|
+
// A game the owner makes private leaves these within five minutes.
|
|
227
|
+
'cache-control': 'public, max-age=300',
|
|
228
|
+
'x-content-type-options': 'nosniff',
|
|
229
|
+
'access-control-allow-origin': '*',
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
* /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
|
|
37
37
|
* knocks for a Homie box, so it stops knocking (a studio site is not a box)
|
|
38
38
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
39
|
+
* /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt for search engines and AI agents, made from the public
|
|
40
|
+
* catalogue (worker/discover.mjs); every generated page also carries schema.org JSON-LD
|
|
41
|
+
* (worker/schema.mjs)
|
|
39
42
|
* /games/<id>/source.json a public game's source for remixing, with its credit and licence (worker/license.mjs)
|
|
40
43
|
* /media/<key> a loose file in the studio's R2 (`media put`), with byte ranges
|
|
41
44
|
* /music/<slug>/<file>, /videos/<slug>/<file> a song's or video's files: from the site's own files, or, for a
|
|
@@ -93,7 +96,7 @@ import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
|
93
96
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
94
97
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
95
98
|
import {
|
|
96
|
-
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
|
|
99
|
+
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homeLd, homePage, jsonFeed, landingLd, mediaArt, mediaIndexPage,
|
|
97
100
|
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage, watchOf,
|
|
98
101
|
} from './site.mjs';
|
|
99
102
|
import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
@@ -108,7 +111,9 @@ import {
|
|
|
108
111
|
// every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
|
|
109
112
|
usePlayers(playerAccounts);
|
|
110
113
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
111
|
-
import { roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
|
|
114
|
+
import { readiness, roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
|
|
115
|
+
import { DISCOVERY_FILES, discoveryResponse, llmsTxt, robotsTxt, sitemapXml } from './discover.mjs';
|
|
116
|
+
import { ldScript, studioNode } from './schema.mjs';
|
|
112
117
|
import { arrivalCookie, manifestReferrals } from './referrals.mjs';
|
|
113
118
|
import { licenseOf, remixAllowed, remixRow } from './license.mjs';
|
|
114
119
|
|
|
@@ -143,6 +148,14 @@ async function catalogue(env, origin) {
|
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
/** The posts with their HTML (site/dist/_site/posts.json), for a post's page and the feeds. */
|
|
151
|
+
/** The studio's shop when it really sells (shop.json checked, the key, the webhook secret and its tables in): its items
|
|
152
|
+
* are a landing's offers (worker/schema.mjs). Else null: a landing never names a price nobody can pay. */
|
|
153
|
+
async function sellingShop(env, cat) {
|
|
154
|
+
const shop = shopOf(cat);
|
|
155
|
+
if (!shop || shop.broken) return null;
|
|
156
|
+
try { return (await readiness(env, shop)).ready ? shop : null; } catch { return null; }
|
|
157
|
+
}
|
|
158
|
+
|
|
146
159
|
async function postsOf(env, origin) {
|
|
147
160
|
try {
|
|
148
161
|
const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
|
|
@@ -701,6 +714,23 @@ async function route(request, env, ctx) {
|
|
|
701
714
|
}
|
|
702
715
|
}
|
|
703
716
|
|
|
717
|
+
// Search engines and AI agents (0.27.0, worker/discover.mjs): robots.txt, sitemap.xml, llms.txt and llms-full.txt,
|
|
718
|
+
// made from the public catalogue (a private or invite-only game is in none of them); a file of the studio's own in
|
|
719
|
+
// site/public wins.
|
|
720
|
+
if (read && DISCOVERY_FILES.includes(path)) {
|
|
721
|
+
const own = await env.ASSETS.fetch(new Request(`${url.origin}${path}`));
|
|
722
|
+
if (own.ok) return own;
|
|
723
|
+
const cat = await getCat();
|
|
724
|
+
const method = request.method;
|
|
725
|
+
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
|
|
726
|
+
const all = await getAll();
|
|
727
|
+
if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
|
|
728
|
+
const settings = await settingsOf(env);
|
|
729
|
+
const remixable = (g) => g.landing?.source !== false && remixOf(g, settings) && remixAllowed(g.license);
|
|
730
|
+
const full = path === '/llms-full.txt';
|
|
731
|
+
return discoveryResponse(llmsTxt(cat, url.origin, { remixable, directory: directoryOf(cat), full, posts: full ? await postsOf(env, url.origin) : null, all }), 'text/plain', { method });
|
|
732
|
+
}
|
|
733
|
+
|
|
704
734
|
// A page the studio made itself (site/pages) wins at its address.
|
|
705
735
|
if (read && (path.endsWith('/') || !path.includes('.'))) {
|
|
706
736
|
const cat = await getCat();
|
|
@@ -714,7 +744,14 @@ async function route(request, env, ctx) {
|
|
|
714
744
|
const game = (cat.games ?? []).find((g) => g.id === top);
|
|
715
745
|
await countVisit(request, env, ctx, parts.length === 0 ? 'home' : game && parts.length === 1 ? game.id : parts.join('/').slice(0, 80));
|
|
716
746
|
const active = game ? 'games' : sectionsOf(cat).some((s) => s.key === top) ? top : null;
|
|
717
|
-
|
|
747
|
+
const html = await res.text();
|
|
748
|
+
// <!-- homie:schema -->: the structured data the generated page here would carry (Home's, a landing's), else the
|
|
749
|
+
// studio's own Organization (an About page).
|
|
750
|
+
const schema = !/<!--\s*homie:schema\s*-->/.test(html) ? ''
|
|
751
|
+
: want === '/' ? ldScript(homeLd(cat, url.origin))
|
|
752
|
+
: game && parts.length === 1 ? ldScript(landingLd(cat, game, url.origin, { shop: await sellingShop(env, cat) }))
|
|
753
|
+
: ldScript([studioNode(cat, url.origin, { full: true })]);
|
|
754
|
+
return customPage(cat, html, { active, schema });
|
|
718
755
|
}
|
|
719
756
|
}
|
|
720
757
|
}
|
|
@@ -825,7 +862,7 @@ async function route(request, env, ctx) {
|
|
|
825
862
|
const { rooms, live } = await roomsOf(env, [meta], { servers: { [game]: servers } });
|
|
826
863
|
const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
|
|
827
864
|
const band = servers.some((x) => x.id !== 'public' && x.listed && x.state === 'open') ? await serverLive(env, meta, servers, url.origin, lobby) : null;
|
|
828
|
-
return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band });
|
|
865
|
+
return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week, remix: launch === 'public' && remixOf(meta, settings), servers: band, listed: launch === 'public', shop: launch === 'public' ? await sellingShop(env, cat) : null });
|
|
829
866
|
}
|
|
830
867
|
if (sub === 'live') {
|
|
831
868
|
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|