@escape-game-over/atlas 0.1.1

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.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. package/src/xml.ts +103 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Share images, derived rather than hand-cut.
3
+ *
4
+ * Its own entry point, not part of `…/astro`: that module is imported by an
5
+ * Astro config, and `astro:assets` does not exist yet when a config is
6
+ * evaluated. Everything here runs inside the build, from a page or a component.
7
+ */
8
+
9
+ import { getImage } from "astro:assets";
10
+ import type { ImageAsset } from "../image.ts";
11
+ import type { ThemeColor } from "../meta/index.ts";
12
+ import type { Percentage } from "../types.ts";
13
+ import { warn } from "../warn.ts";
14
+
15
+ /**
16
+ * What every major platform crops a share image to — 1200×630, near enough to
17
+ * 1.91:1.
18
+ *
19
+ * Not an option by default, because it is not a preference: Open Graph,
20
+ * Twitter's `summary_large_image` and LinkedIn all target this box, and an image
21
+ * that misses it is letterboxed or centre-cropped by someone else's rules.
22
+ */
23
+ const SHARE_WIDTH = 1200;
24
+ const SHARE_HEIGHT = 630;
25
+
26
+ interface ShareImageBase {
27
+ /** Defaults to 1200. */
28
+ readonly width?: number;
29
+ /** Defaults to 630. */
30
+ readonly height?: number;
31
+ /**
32
+ * Defaults to `png`: lossless, and accepted by every scraper. `jpeg` is far
33
+ * smaller for photographs. Avoid `avif`, which several scrapers still
34
+ * cannot read.
35
+ */
36
+ readonly format?: "png" | "jpeg" | "webp";
37
+ /** Ignored by `png`, which is lossless. */
38
+ readonly quality?: Percentage;
39
+ }
40
+
41
+ /**
42
+ * Which part of an oversized source survives a `cover` crop.
43
+ *
44
+ * A closed union rather than a string, for the reason `RobotsDirective` is one:
45
+ * the underlying resizer ignores anything it does not recognise, so `"centre
46
+ * top"` or `"top center"` would crop from the middle and never say why.
47
+ *
48
+ * The nine placements are the CSS spellings. The resizer also accepts compass
49
+ * synonyms — `north` for `top`, `southeast` for `right bottom` — which are left
50
+ * out deliberately: two ways to say one thing, and neither reads better.
51
+ *
52
+ * `entropy` and `attention` are not placements but strategies, and are worth
53
+ * knowing about for photographs: they pick the crop by looking at the image,
54
+ * which beats guessing where a subject sits when the same rule has to serve
55
+ * every photo a venue supplies.
56
+ */
57
+ export type CropPosition =
58
+ | "center"
59
+ | "top"
60
+ | "right top"
61
+ | "right"
62
+ | "right bottom"
63
+ | "bottom"
64
+ | "left bottom"
65
+ | "left"
66
+ | "left top"
67
+ /** Crops to the busiest region — the most detail, by Shannon entropy. */
68
+ | "entropy"
69
+ /** Crops to what draws the eye: faces, skin tones, saturated colour. */
70
+ | "attention";
71
+
72
+ /**
73
+ * How a source that is not already 1.91:1 reaches the box.
74
+ *
75
+ * The two are not interchangeable, and the right one depends on what the image
76
+ * *is*:
77
+ *
78
+ * - `cover` — fill the box and crop the overflow. For photographs, where the
79
+ * edges are scenery and losing them costs nothing.
80
+ * - `contain` — fit the whole image in and pad the rest. For logos, screenshots
81
+ * and anything with a shape: a square logo under `cover` is cropped to a
82
+ * 1200×630 slice through its middle.
83
+ *
84
+ * `contain` requires a `background`, because the padding has to be *some*
85
+ * colour. Leaving it transparent only defers the choice to whatever composites
86
+ * the card, which is how a dark logo ends up invisible on a dark card.
87
+ */
88
+ export type ShareImageOptions =
89
+ | (ShareImageBase & {
90
+ readonly fit?: "cover";
91
+ /**
92
+ * Which part survives the crop — e.g. `"top"` to keep faces in
93
+ * frame on a tall source, or `"attention"` to let the resizer find
94
+ * them.
95
+ */
96
+ readonly position?: CropPosition;
97
+ })
98
+ | (ShareImageBase & {
99
+ readonly fit: "contain";
100
+ /**
101
+ * The padding colour.
102
+ *
103
+ * Takes a `ThemeColor`, so a project hands over `site.themeColor`
104
+ * rather than restating a hex value the head already carries. Given
105
+ * one colour per scheme, the light one is used: a share image is
106
+ * composited by someone else's client and has no scheme to follow.
107
+ */
108
+ readonly background: ThemeColor;
109
+ });
110
+
111
+ /** One colour from a `ThemeColor`; see `background` for why it is the light one. */
112
+ function resolveColor(color: ThemeColor): string {
113
+ return typeof color === "string" ? color : color.light;
114
+ }
115
+
116
+ /**
117
+ * Crops an imported image to the share box and returns it ready for `metaFor`.
118
+ *
119
+ * ```ts
120
+ * import hero from "~/assets/hero.png";
121
+ * import { shareImage } from "@escape-game-over/atlas/astro/images";
122
+ *
123
+ * const image = { asset: await shareImage(hero), alt: "…" };
124
+ * ```
125
+ *
126
+ * A square logo, which is the shape most product and room images come in, wants
127
+ * the other fit:
128
+ *
129
+ * ```ts
130
+ * await shareImage(roomLogo, { fit: "contain", background: "#1d51e0" });
131
+ * ```
132
+ *
133
+ * The point is that one source image serves both the page and the card: without
134
+ * this a project keeps a second, hand-cut file per page and nothing checks that
135
+ * the two still show the same thing — or, more often, it reuses whatever is to
136
+ * hand and ships a card with a logo floating in the middle of it.
137
+ *
138
+ * What this does *not* do is compose: no logo laid over a background, no title
139
+ * burnt into the image. That needs a renderer rather than a resizer — satori or
140
+ * `astro-og-canvas` — and is a different tool from this one.
141
+ */
142
+ export async function shareImage(
143
+ source: ImageMetadata,
144
+ options: ShareImageOptions = {}
145
+ ): Promise<ImageAsset> {
146
+ const width = options.width ?? SHARE_WIDTH;
147
+ const height = options.height ?? SHARE_HEIGHT;
148
+ const format = options.format ?? "png";
149
+ const fit = options.fit ?? "cover";
150
+
151
+ // Astro's image service never enlarges: ask for a box bigger than the
152
+ // source and it hands back the source, at its own size. So this cannot pad
153
+ // a small logo onto a large field however the options are written — the
154
+ // request is simply not honoured, and the only wrong thing to do is claim
155
+ // otherwise in `og:image:width`.
156
+ //
157
+ // Not warned about here. `metaFor` already warns about an undersized
158
+ // `og:image`, on every page and whether or not this helper made it, so
159
+ // saying it twice would only halve the chance either line is read.
160
+ //
161
+ // Measured as a scale factor, not by comparing dimensions, because the two
162
+ // fits reach the box differently. `cover` scales until *both* sides are
163
+ // covered, so a source short in either dimension would be enlarged.
164
+ // `contain` scales until the *first* side fits, so an 800x800 logo lands at
165
+ // 630x630 — a reduction, though it is narrower than 1200.
166
+ const scale =
167
+ fit === "cover"
168
+ ? Math.max(width / source.width, height / source.height)
169
+ : Math.min(width / source.width, height / source.height);
170
+ const image = await getImage({
171
+ src: source,
172
+ width,
173
+ height,
174
+ format,
175
+ fit,
176
+ ...(options.quality === undefined ? {} : { quality: options.quality }),
177
+ // The two fits take different extra options, and neither accepts the
178
+ // other's: `contain` pads, so it needs a colour; `cover` crops, so it
179
+ // needs to know what to keep.
180
+ ...((): { background?: string; position?: string } => {
181
+ if (options.fit === "contain") {
182
+ return { background: resolveColor(options.background) };
183
+ }
184
+ if (options.position === undefined) return {};
185
+ return { position: options.position };
186
+ })(),
187
+ });
188
+
189
+ // The size that came *back*, not the size asked for.
190
+ //
191
+ // Neither `attributes` nor `options` can be trusted here: both echo the
192
+ // request, and the request is exactly what was not honoured. What is known
193
+ // is the rule — Astro refuses to enlarge — so a source too small for the box
194
+ // comes back untouched, at its own size. Reporting the request instead would
195
+ // put an `og:image:width` in every head that the file does not match, which
196
+ // is worse than a small image: a scraper lays out a space and finds
197
+ // something else in it.
198
+ const produced =
199
+ scale > 1
200
+ ? { width: source.width, height: source.height }
201
+ : { width, height };
202
+ return { src: image.src, ...produced, format };
203
+ }
204
+
205
+ /**
206
+ * The aspect ratios Google asks a business to publish photos in.
207
+ *
208
+ * Three, and not negotiable per project: they exist because a result renders
209
+ * the photo in whichever shape that surface uses, and supplying one ratio means
210
+ * the other two are cropped by rules you do not control. 1200 wide throughout,
211
+ * which clears the 50 000-pixel floor by an order of magnitude and is a
212
+ * reasonable size to serve.
213
+ */
214
+ const PHOTO_RATIOS = [
215
+ { label: "16:9", width: 1200, height: 675 },
216
+ { label: "4:3", width: 1200, height: 900 },
217
+ { label: "1:1", width: 1200, height: 1200 },
218
+ ] as const;
219
+
220
+ /** The largest side any ratio above asks for, in either dimension. */
221
+ const PHOTO_SIDE = 1200;
222
+
223
+ export interface PhotoSetOptions {
224
+ /** Defaults to `jpeg`: these are photographs, and png would be enormous. */
225
+ readonly format?: "png" | "jpeg" | "webp";
226
+ /** Defaults to Astro's own. */
227
+ readonly quality?: Percentage;
228
+ /**
229
+ * Which part survives the crop.
230
+ *
231
+ * Worth setting on a photograph with a subject: the 1:1 crop takes a square
232
+ * out of the middle of a landscape shot, and a shopfront photographed from
233
+ * across the road can lose its own doorway. `"attention"` is the option to
234
+ * reach for when one setting has to serve every photo a venue supplies.
235
+ */
236
+ readonly position?: CropPosition;
237
+ }
238
+
239
+ /**
240
+ * One photograph, cropped to each ratio a search result may render it in.
241
+ *
242
+ * ```ts
243
+ * import frontage from "~/assets/venue/frontage.jpg";
244
+ * import { photoSet } from "@escape-game-over/atlas/astro/images";
245
+ *
246
+ * const crops = await photoSet(frontage, { position: "center top" });
247
+ * ```
248
+ *
249
+ * Derived rather than hand-cut for the same reason `shareImage` is: three files
250
+ * per photograph is three chances for one of them to be of something else, and
251
+ * nothing in a build would notice. `cover` throughout, with no option to
252
+ * `contain` — a padded photograph is not a photograph of anything, and these
253
+ * are the images that claim to depict the place.
254
+ *
255
+ * ## What the source has to be
256
+ *
257
+ * - **At least 1200 x 1200**, and *both* sides matter. The square crop needs
258
+ * 1200 of each, so a 3000x800 panorama is too short however wide it is. A
259
+ * photograph off any modern phone clears this several times over.
260
+ * - **Landscape**, or at least not tall. The three ratios are all wider than
261
+ * they are high except the square, so a portrait source is cropped hardest
262
+ * where it has least to give.
263
+ * - **Of this venue.** The room itself, the frontage, people playing. Not a
264
+ * logo, not a share card, not a stock interior — see `image` on
265
+ * `LocalBusinessInput` for why that distinction is the whole point.
266
+ * - **Framed with room around the subject.** Every ratio is cut from the same
267
+ * file, so anything tight against an edge is lost in one of them. A doorway
268
+ * centred with space either side survives all three; the same doorway at the
269
+ * far left survives the 16:9 and nothing else.
270
+ *
271
+ * Warns when the source cannot fill the boxes. Astro never enlarges, so a small
272
+ * source comes back at its own size and the set silently becomes three copies
273
+ * of one thumbnail — which is what the warning has to say, because the files
274
+ * are still produced and still look fine individually.
275
+ */
276
+ export async function photoSet(
277
+ source: ImageMetadata,
278
+ options: PhotoSetOptions = {}
279
+ ): Promise<readonly ImageAsset[]> {
280
+ const format = options.format ?? "jpeg";
281
+
282
+ // Both dimensions, because the 1:1 crop asks for 1200 of each. A landscape
283
+ // photograph 1200 tall covers every ratio here; one 1200 wide but shorter
284
+ // does not.
285
+ if (source.width < PHOTO_SIDE || source.height < PHOTO_SIDE) {
286
+ warn(
287
+ "photoSet",
288
+ `source is ${source.width}x${source.height}, short of the ${PHOTO_SIDE}x${PHOTO_SIDE} the square crop needs. Astro does not enlarge, so the set will be three copies of the same small image.`
289
+ );
290
+ }
291
+
292
+ return await Promise.all(
293
+ PHOTO_RATIOS.map(async (ratio) => {
294
+ const image = await getImage({
295
+ src: source,
296
+ width: ratio.width,
297
+ height: ratio.height,
298
+ format,
299
+ fit: "cover",
300
+ ...(options.quality === undefined
301
+ ? {}
302
+ : { quality: options.quality }),
303
+ ...(options.position === undefined
304
+ ? {}
305
+ : { position: options.position }),
306
+ });
307
+ return {
308
+ src: image.src,
309
+ width: ratio.width,
310
+ height: ratio.height,
311
+ format,
312
+ };
313
+ })
314
+ );
315
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The Astro half of the library — the only part that knows what framework this
3
+ * is. Everything outside `src/astro/` stays framework-free and is checked in
4
+ * isolation; this entry point is where the two are joined.
5
+ *
6
+ * ```ts
7
+ * // astro.config.ts
8
+ * import { publicFiles, siteRoutes } from "@escape-game-over/atlas/astro";
9
+ * import { redirects, site } from "./config/site.ts";
10
+ *
11
+ * export default defineConfig({
12
+ * integrations: [siteRoutes({ site, redirects }), publicFiles()],
13
+ * });
14
+ * ```
15
+ *
16
+ * Two integrations, and they share nothing but the hook API:
17
+ *
18
+ * - `site-routes.ts` writes the files a site owes the outside world — sitemap,
19
+ * `robots.txt`, `llms.txt`, `_redirects` — replacing the endpoint files every
20
+ * consuming repo would otherwise copy, and keeping their names and content
21
+ * types owned by the library rather than restated per project.
22
+ * - `public-files.ts` types the contents of `public/`, the one part of a site
23
+ * the compiler cannot otherwise see.
24
+ *
25
+ * The rest of this folder is imported directly rather than from here, because
26
+ * each is reached from a different place in a project: `…/astro/images` from
27
+ * a view or a config, `…/astro/consent` from a client-side script, and
28
+ * `…/astro/meta-tags` from a layout.
29
+ *
30
+ * That last one is a `.astro` component, and the only one lib carries. It is
31
+ * here rather than in a project's `src/` because it holds no design and no
32
+ * copy — it renders the `MetaTag` union and nothing else, which makes it the
33
+ * mirror of `asMetaTag` in `lib/meta/tag.ts` rather than a piece of a theme.
34
+ */
35
+
36
+ export {
37
+ type PublicFilesOptions,
38
+ publicFiles,
39
+ } from "./public-files.ts";
40
+ export {
41
+ type SiteFiles,
42
+ type SiteRoutesOptions,
43
+ siteRoutes,
44
+ } from "./site-routes.ts";
@@ -0,0 +1,129 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import type { AstroIntegration } from "astro";
4
+
5
+ /**
6
+ * The module the generated union is declared in.
7
+ *
8
+ * Types only, so nothing has to exist at runtime: a consumer reaches it with
9
+ * `import type`, which is erased before anything tries to resolve it.
10
+ */
11
+ const PUBLIC_MODULE = "atlas:public";
12
+
13
+ /** Files in `public/` that are not content: editor litter and placeholders. */
14
+ const IGNORED = new Set([".DS_Store", "Thumbs.db", ".gitkeep"]);
15
+
16
+ /** Every file under a directory, as root-relative POSIX paths. */
17
+ async function walk(dir: URL, prefix = ""): Promise<string[]> {
18
+ let entries: Dirent[];
19
+ try {
20
+ entries = await readdir(dir, { withFileTypes: true });
21
+ } catch {
22
+ // No `public/` at all is a normal state, not an error.
23
+ return [];
24
+ }
25
+
26
+ const found: string[] = [];
27
+ for (const entry of entries) {
28
+ if (IGNORED.has(entry.name)) continue;
29
+
30
+ const path = `${prefix}/${entry.name}`;
31
+ if (entry.isDirectory()) {
32
+ found.push(...(await walk(new URL(`${entry.name}/`, dir), path)));
33
+ } else {
34
+ found.push(path);
35
+ }
36
+ }
37
+ return found;
38
+ }
39
+
40
+ /**
41
+ * Types every file in `public/`, so linking to one is checked.
42
+ *
43
+ * ```ts
44
+ * import type { PublicFile } from "atlas:public";
45
+ *
46
+ * const statement: PublicFile = "/reports/annual-2026.pdf";
47
+ * ```
48
+ *
49
+ * `public/` is the one part of a site the compiler cannot see: files are copied
50
+ * to the output verbatim, so a link to one is a string nothing verifies, and a
51
+ * renamed or deleted file becomes a 404 that no build reports. Every other URL
52
+ * on the site is derived from the route registry and checked.
53
+ *
54
+ * Generated at config time and written through `injectTypes`, which puts it in
55
+ * `.astro/` where Astro already references it — so there is nothing to import,
56
+ * commit or add to a tsconfig. A file added while the dev server runs is picked
57
+ * up when it restarts.
58
+ */
59
+ export interface PublicFilesOptions {
60
+ /**
61
+ * How the consuming project imports this package.
62
+ *
63
+ * Defaults to the published name, which is what a project installing it
64
+ * from the registry writes. State it only where that is not the specifier
65
+ * in the import statements — a path alias, or a fork under another name.
66
+ *
67
+ * Needed because the generated file *augments* that module: the union has to
68
+ * reach `fileUrl` and the redirect target, and a declaration can only be
69
+ * merged into a module named the way the consumer names it.
70
+ */
71
+ readonly libModule?: string;
72
+ }
73
+
74
+ export function publicFiles(
75
+ options: PublicFilesOptions = {}
76
+ ): AstroIntegration {
77
+ const libModule = options.libModule ?? "@escape-game-over/atlas";
78
+
79
+ return {
80
+ name: "public-files",
81
+ hooks: {
82
+ "astro:config:done": async ({ config, injectTypes, logger }) => {
83
+ const files = (await walk(config.publicDir)).sort();
84
+ /**
85
+ * The union, one file per line, indented by `spaces`.
86
+ *
87
+ * A count rather than the whitespace itself: the two call sites
88
+ * sit at different depths in the emitted file, and passing the
89
+ * indent as a literal means counting spaces by eye in a string
90
+ * that looks identical whether it holds eight of them or nine.
91
+ */
92
+ const union = (spaces: number) =>
93
+ files.length === 0
94
+ ? "never"
95
+ : files
96
+ .map((file) => `"${file}"`)
97
+ .join(`\n${" ".repeat(spaces)}| `);
98
+
99
+ injectTypes({
100
+ filename: "public-files.d.ts",
101
+ // `export {}` is load-bearing: without a top-level import or
102
+ // export this file is a *script*, and `declare module …`
103
+ // then declares an ambient module that replaces the real one
104
+ // rather than merging into it — silently untyping every other
105
+ // export lib has.
106
+ content: `export {};
107
+
108
+ declare module "${PUBLIC_MODULE}" {
109
+ /** Every file in \`public/\`, as it is served. */
110
+ export type PublicFile =
111
+ | ${union(8)};
112
+ }
113
+
114
+ declare module "${libModule}" {
115
+ /** Merged so \`fileUrl\` and redirect targets accept only these. */
116
+ interface PublicFileRegistry {
117
+ path:
118
+ | ${union(12)};
119
+ }
120
+ }
121
+ `,
122
+ });
123
+ logger.info(
124
+ `typed ${files.length} file${files.length === 1 ? "" : "s"} in public/`
125
+ );
126
+ },
127
+ },
128
+ };
129
+ }