@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.
- package/README.md +364 -0
- package/bin/use-project.mjs +131 -0
- package/docs/NOT-BUILT.md +329 -0
- package/docs/checks.md +139 -0
- package/docs/share-images.md +52 -0
- package/docs/toolchain.md +83 -0
- package/package.json +51 -0
- package/src/analytics/google.ts +351 -0
- package/src/analytics/index.ts +102 -0
- package/src/analytics/tags.ts +57 -0
- package/src/analytics/umami.ts +285 -0
- package/src/astro/MetaTags.astro +87 -0
- package/src/astro/consent.ts +165 -0
- package/src/astro/images.ts +315 -0
- package/src/astro/index.ts +44 -0
- package/src/astro/public-files.ts +129 -0
- package/src/astro/site-routes.ts +307 -0
- package/src/config.ts +218 -0
- package/src/contact.ts +233 -0
- package/src/file.ts +16 -0
- package/src/files.ts +39 -0
- package/src/hours.ts +312 -0
- package/src/i18n/define.ts +217 -0
- package/src/i18n/placeholders.ts +94 -0
- package/src/i18n/translate.ts +190 -0
- package/src/image.ts +29 -0
- package/src/index.ts +222 -0
- package/src/jsonld/article.ts +165 -0
- package/src/jsonld/breadcrumb.ts +34 -0
- package/src/jsonld/business.ts +196 -0
- package/src/jsonld/ids.ts +106 -0
- package/src/jsonld/index.ts +59 -0
- package/src/jsonld/node.ts +78 -0
- package/src/jsonld/organization.ts +154 -0
- package/src/jsonld/place.ts +96 -0
- package/src/jsonld/product.ts +172 -0
- package/src/jsonld/quantity.ts +55 -0
- package/src/jsonld/service.ts +237 -0
- package/src/jsonld/video.ts +239 -0
- package/src/jsonld/website.ts +58 -0
- package/src/llms.ts +160 -0
- package/src/meta/content.ts +190 -0
- package/src/meta/index.ts +432 -0
- package/src/meta/robots.ts +212 -0
- package/src/meta/share-image.ts +232 -0
- package/src/meta/tag.ts +133 -0
- package/src/meta/verification.ts +53 -0
- package/src/money.ts +237 -0
- package/src/project.ts +249 -0
- package/src/redirects.ts +266 -0
- package/src/robots.ts +80 -0
- package/src/routes/define.ts +412 -0
- package/src/routes/family.ts +251 -0
- package/src/routes/resolve.ts +266 -0
- package/src/site/api.ts +354 -0
- package/src/site/create.ts +660 -0
- package/src/site/index.ts +32 -0
- package/src/site/page.ts +148 -0
- package/src/sitemap.ts +257 -0
- package/src/types.ts +160 -0
- package/src/url.ts +144 -0
- package/src/warn.ts +88 -0
- 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
|
+
}
|