@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
package/src/llms.ts ADDED
@@ -0,0 +1,160 @@
1
+ import type { GeneratedFile } from "./file.ts";
2
+ import { type HttpsUrl, joinUrl } from "./url.ts";
3
+
4
+ /**
5
+ * One link in an `llms.txt` section.
6
+ *
7
+ * `description` is optional because the format tolerates a bare link, but a list
8
+ * of titles is barely more use to a model than the sitemap it already has — the
9
+ * sentence is the part that says what the page is *for*.
10
+ */
11
+ export interface LlmsItem {
12
+ readonly name: string;
13
+ readonly url: HttpsUrl;
14
+ readonly description?: string;
15
+ }
16
+
17
+ /**
18
+ * One `##` section: a heading, optional prose, and a list of links.
19
+ *
20
+ * Both halves are optional so a section can be pure prose — opening hours, an
21
+ * address, a phone number — which is a large part of why the file exists and is
22
+ * exactly the kind of thing lib cannot derive.
23
+ */
24
+ export interface LlmsSection {
25
+ readonly heading: string;
26
+ /**
27
+ * Makes the heading itself a link — for a section whose pages sit under an
28
+ * index page, so that page is named once instead of appearing both as a
29
+ * heading and as an entry in some other list.
30
+ */
31
+ readonly url?: HttpsUrl;
32
+ /** Free markdown, rendered under the heading and above the links. */
33
+ readonly body?: string;
34
+ readonly items?: readonly LlmsItem[];
35
+ }
36
+
37
+ /**
38
+ * Declaring this on a site says it publishes an `llms.txt`.
39
+ *
40
+ * That is what puts `<link rel="describedby">` in every page's head, so the
41
+ * declaration is the switch rather than a second flag: a link to a file that was
42
+ * never generated is worse than no link, and lib cannot tell from `metaFor`
43
+ * whether `site.llms()` was called somewhere.
44
+ */
45
+ export interface LlmsConfig {
46
+ /** Filename it is served under. Defaults to `llms.txt`. */
47
+ readonly name?: string;
48
+ }
49
+
50
+ export interface LlmsInput {
51
+ readonly name: string;
52
+ readonly siteUrl: HttpsUrl;
53
+ /** `# ` heading — the site's own name. */
54
+ readonly title: string;
55
+ /** The `>` block under the title: one or two sentences on what this site is. */
56
+ readonly summary?: string;
57
+ /** Absolute URL of the sitemap, advertised the way `robots.txt` does. */
58
+ readonly sitemap: string;
59
+ /**
60
+ * The language the links are written in, and the others that exist.
61
+ *
62
+ * Stated rather than implied: every URL in the file is in one language, and
63
+ * a model that cannot tell which one — or that the others exist — will read
64
+ * the site as though it were monolingual.
65
+ */
66
+ readonly language: { readonly tag: string; readonly url: HttpsUrl };
67
+ readonly otherLanguages?: readonly {
68
+ readonly tag: string;
69
+ readonly url: HttpsUrl;
70
+ }[];
71
+ readonly sections: readonly LlmsSection[];
72
+ }
73
+
74
+ /**
75
+ * Renders `llms.txt` — what `robots.txt` is for crawlers, for readers that
76
+ * arrive wanting the content rather than the markup.
77
+ *
78
+ * Markdown, and deliberately small: the file competes for a context window, so
79
+ * it is a map of the site rather than a copy of it.
80
+ *
81
+ * Structure follows the proposal at https://llmstxt.org — an `#` title, a `>`
82
+ * summary block, then `##` sections of `- [name](url): description`.
83
+ *
84
+ * Two things there are not done here. The proposal also suggests a clean
85
+ * markdown copy of every *page* at `page.md`, advertised with
86
+ * `rel="alternate" type="text/markdown"`; lib generates no such copies, so it
87
+ * emits no such link. The companion `rel="describedby"` — which points at this
88
+ * file — is emitted, from `meta/`.
89
+ */
90
+ export function buildLlms(input: LlmsInput): GeneratedFile {
91
+ const lines: string[] = [`# ${input.title}`, ""];
92
+
93
+ if (input.summary !== undefined) {
94
+ lines.push(`> ${input.summary}`, "");
95
+ }
96
+
97
+ lines.push(`> Sitemap: ${input.sitemap}`);
98
+ lines.push(`> Language: ${input.language.tag} (${input.language.url})`);
99
+ for (const other of input.otherLanguages ?? []) {
100
+ lines.push(`> Also in: ${other.tag} (${other.url})`);
101
+ }
102
+ lines.push("");
103
+
104
+ // Which language the links are in, and how to get to the others — stated
105
+ // only when there *are* others.
106
+ //
107
+ // Not "swap the prefix": a slug is translated too, so the Greek `/about-us`
108
+ // is `/el-GR/sxetika-me-emas` and a reader told to substitute the prefix
109
+ // would construct a URL that 404s. The sitemap is where that mapping
110
+ // actually lives, one `xhtml:link` per page per language, so this points at
111
+ // it rather than describing a rule that does not hold.
112
+ if (input.otherLanguages !== undefined && input.otherLanguages.length > 0) {
113
+ lines.push(
114
+ `Every URL below is the ${input.language.tag} version. Other languages translate their slugs, so the same page is not this URL with a different prefix — the sitemap lists each page in every language.`,
115
+ ""
116
+ );
117
+ }
118
+
119
+ for (const section of input.sections) {
120
+ // A heading with nothing under it says a category exists and is empty,
121
+ // which is worse than not mentioning it: the file is a map, and a
122
+ // labelled blank is a gap a reader has to interpret.
123
+ //
124
+ // Skipped here rather than by each caller, because both kinds of
125
+ // section can arrive empty. `siteFor`'s derived "Pages" holds whatever
126
+ // is not nested under a prefix, and a site whose every route sits in a
127
+ // family leaves it with none; a project's own `sections` are a public
128
+ // slot and can be handed anything. One rule, at the point of writing.
129
+ //
130
+ // `body` counts as content — a section of pure prose is the reason both
131
+ // halves are optional, and an address under a heading is a real entry
132
+ // with no links in it.
133
+ const empty =
134
+ section.body === undefined && (section.items ?? []).length === 0;
135
+ if (empty) continue;
136
+
137
+ lines.push(
138
+ section.url === undefined
139
+ ? `## ${section.heading}`
140
+ : `## [${section.heading}](${section.url})`,
141
+ ""
142
+ );
143
+ if (section.body !== undefined) {
144
+ lines.push(section.body.trim(), "");
145
+ }
146
+ for (const item of section.items ?? []) {
147
+ const description =
148
+ item.description === undefined ? "" : `: ${item.description}`;
149
+ lines.push(`- [${item.name}](${item.url})${description}`);
150
+ }
151
+ lines.push("");
152
+ }
153
+
154
+ return {
155
+ name: input.name,
156
+ url: joinUrl(input.siteUrl, `/${input.name}`),
157
+ body: `${lines.join("\n").trimEnd()}\n`,
158
+ contentType: "text/markdown; charset=utf-8",
159
+ };
160
+ }
@@ -0,0 +1,190 @@
1
+ import type { ImageAsset } from "../image.ts";
2
+ import type {
3
+ BusinessId,
4
+ JsonLdNode,
5
+ OrganizationId,
6
+ } from "../jsonld/index.ts";
7
+ import type { IsoDate } from "../types.ts";
8
+ import type { RobotsPolicy } from "./robots.ts";
9
+ import type { ShareImage } from "./share-image.ts";
10
+
11
+ /**
12
+ * What only a consumer can supply: a page's words, and what kind of thing it is.
13
+ */
14
+
15
+ /**
16
+ * What kind of thing a page represents, for `og:type`.
17
+ *
18
+ * The whole vocabulary Open Graph defines, and a union for the reason
19
+ * `RobotsDirective` is one: it is small and closed, and a value outside it is
20
+ * not an error anywhere — a scraper that does not recognise `og:type` falls
21
+ * back to `website` and renders a perfectly ordinary card. So `"artcile"` costs
22
+ * you the article treatment and looks exactly like success.
23
+ *
24
+ * `website` is the default and the right answer for most pages. `article` is
25
+ * the one that earns anything extra: it is what tells a scraper this is a dated
26
+ * piece of writing rather than a section of the site.
27
+ *
28
+ * Reference: <https://ogp.me/#types>
29
+ */
30
+ export type OpenGraphType =
31
+ | "website"
32
+ | "article"
33
+ | "book"
34
+ | "profile"
35
+ | `music.${"song" | "album" | "playlist" | "radio_station"}`
36
+ | `video.${"movie" | "episode" | "tv_show" | "other"}`;
37
+
38
+ /**
39
+ * A page that is a dated piece of writing, said once.
40
+ *
41
+ * The half of `ArticleInput` a page actually knows. Everything else lib already
42
+ * holds and fills in: the URL is the canonical, the description is the page's,
43
+ * the image is the share image it has just resolved to an absolute URL, and the
44
+ * headline defaults to the title. Asking a view for those again would be asking
45
+ * it to repeat what it just handed over — and to repeat it *correctly*, which is
46
+ * the part that eventually fails.
47
+ *
48
+ * Supplying this is what emits the `Article` node, `og:type: article` and the
49
+ * `article:*` properties together. They are three statements of one fact, and a
50
+ * page that could make them separately is a page that could make two of them.
51
+ * That is why `MetaContent.type` cannot be set to `"article"`: saying a page is
52
+ * an article without saying when it was published is the half-statement this
53
+ * exists to prevent, and it is also the half that carries no benefit — the date
54
+ * is the whole reason the type is worth claiming.
55
+ */
56
+ export interface ArticleContent {
57
+ /**
58
+ * The headline, where it differs from the page title.
59
+ *
60
+ * Usually it does not, which is why this is optional. Worth setting when
61
+ * the `<title>` carries something the headline should not — a site name, a
62
+ * section — since Google reads this as the article's own name.
63
+ */
64
+ readonly headline?: string;
65
+ /** When it was published. The field that makes the whole thing worth it. */
66
+ readonly datePublished: IsoDate;
67
+ /** When it last changed, if it has. See `ArticleInput.dateModified`. */
68
+ readonly dateModified?: IsoDate;
69
+ /** Who wrote it, as an `@id` — the venue, or the brand. */
70
+ readonly author?: OrganizationId | BusinessId;
71
+ /** Who published it. Usually the same node as `author` — see `ArticleInput`. */
72
+ readonly publisher?: OrganizationId | BusinessId;
73
+ /**
74
+ * What it is about. One `article:tag` each, and `keywords` on the node.
75
+ *
76
+ * No `article:section`: that names the one part of a publication a piece
77
+ * lives in, and a site with a single feed would repeat its own URL on every
78
+ * post. It becomes worth having when the feed splits.
79
+ */
80
+ readonly tags?: readonly string[];
81
+ /** Which flavour of article. Defaults to `Article`. */
82
+ readonly kind?: "Article" | "NewsArticle" | "BlogPosting";
83
+ /**
84
+ * The post's own photograph, at each ratio a result may render it in.
85
+ *
86
+ * The one thing lib cannot derive from what it already holds. Everything
87
+ * else about this node comes from the head it just built — but the share
88
+ * image is a single 1200x630 card, often with type burnt into it, and
89
+ * Google asks for something else: *"multiple high-resolution images with
90
+ * the following aspect ratios: 16x9, 4x3, and 1x1"*. A result renders the
91
+ * picture in whichever shape its surface uses, so supplying one ratio
92
+ * leaves the other two cropped by rules nobody here controls.
93
+ *
94
+ * Pass `await photoSet(source)` from the package's `astro/images`, which cuts
95
+ * exactly those three from one import. Omit it and the node falls back to
96
+ * the share image, which is a worse picture and a real one — the same
97
+ * trade `llms.txt` makes when nobody supplies `describe`.
98
+ */
99
+ readonly images?: readonly ImageAsset[];
100
+ }
101
+
102
+ /**
103
+ * What kind of page this is: an ordinary one, or an article.
104
+ *
105
+ * A union rather than two optional fields, so the pair cannot both be set. They
106
+ * answer the same question — `article` decides `og:type` — and a page that
107
+ * supplied both would have one of them silently ignored, which is the shape of
108
+ * mistake this folder keeps removing rather than reporting.
109
+ *
110
+ * Both branches allow the fields to be absent, so an ordinary page writes
111
+ * neither and gets `website`.
112
+ */
113
+ export type PageKind =
114
+ | {
115
+ readonly type?: Exclude<OpenGraphType, "article">;
116
+ readonly article?: never;
117
+ }
118
+ | {
119
+ readonly type?: never;
120
+ /**
121
+ * This page's publication details, if it is a dated piece of writing.
122
+ *
123
+ * One field, three outputs — see `ArticleContent`.
124
+ */
125
+ readonly article: ArticleContent;
126
+ };
127
+
128
+ /** The fields every page has, whatever kind it is. */
129
+ export interface MetaContentBase {
130
+ readonly title: string;
131
+ /**
132
+ * Required: a page without a description leaves search engines and every
133
+ * share preview to invent one from the body copy.
134
+ */
135
+ readonly description: string;
136
+ /**
137
+ * Overrides the default `width=device-width, initial-scale=1`, for the rare
138
+ * page that needs `viewport-fit=cover` or similar.
139
+ */
140
+ readonly viewport?: string;
141
+ /**
142
+ * What a crawler may do with this page, e.g. `{ index: true, follow: true }`.
143
+ *
144
+ * Required, and per page: whether a page should be indexed is a decision,
145
+ * and leaving the tag off to inherit the crawler's default hides it.
146
+ * Site-wide crawl rules belong in `robots.txt`, which lib also generates.
147
+ */
148
+ readonly robots: RobotsPolicy;
149
+ /**
150
+ * This page's share image and its alt text, as one unit.
151
+ *
152
+ * Required, and supplied per page rather than looked up from a registry:
153
+ * routes can be generated dynamically, and a product page's image is part of
154
+ * the product, not of the route table.
155
+ */
156
+ readonly image: ShareImage;
157
+ /**
158
+ * Structured-data nodes for this page, added to the same graph as the
159
+ * breadcrumb.
160
+ *
161
+ * A slot for what lib cannot derive. lib knows the route table, so it can
162
+ * build a breadcrumb; it does not know the business, its address or its
163
+ * hours, and will not invent them. Nodes are built with the helpers in
164
+ * `jsonld/`, so this takes shaped data rather than arbitrary JSON.
165
+ *
166
+ * Mark up what the page shows. Google requires it outright — content not
167
+ * visible to a reader is a guidelines violation rather than a free extra
168
+ * signal — and it is also what decides whether anything else sees it: a
169
+ * model reading the page gets the rendered text, and a `<script>` block is
170
+ * what its extractor drops first. A fact that exists only here is a fact
171
+ * that mostly does not exist. Nothing checks this, because lib builds nodes
172
+ * and never sees the page they end up on.
173
+ */
174
+ readonly jsonLd?: readonly JsonLdNode[];
175
+ }
176
+
177
+ /**
178
+ * What a page passes to `metaFor`: its words, its picture, and what kind of
179
+ * thing it is.
180
+ *
181
+ * An intersection rather than one interface because `PageKind` is a union, and
182
+ * that union is the whole point — see it for why `type` and `article` cannot
183
+ * both be written.
184
+ */
185
+ export type MetaContent = MetaContentBase & PageKind;
186
+
187
+ /** Browser-UI tint. A single colour, or one per `prefers-color-scheme`. */
188
+ export type ThemeColor =
189
+ | string
190
+ | { readonly light: string; readonly dark: string };