@jtakeit/astro 0.1.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
@@ -0,0 +1,19 @@
1
+ import { defineCollection } from 'astro:content';
2
+ import { COLLECTIONS } from './content/blocks';
3
+ import { entries } from './lib/entryLoader';
4
+
5
+ /**
6
+ * The collections this site has, read from the one place that declares them.
7
+ *
8
+ * `src/content/blocks.ts` says which collections exist — that file is the
9
+ * catalogue, and the admin draws its screens from the same declaration. This
10
+ * derives the Astro side of it rather than repeating it, so adding a collection
11
+ * is one entry in one array and never two lists that drift.
12
+ *
13
+ * The reading itself is `entryLoader.ts`, and its header is where the argument
14
+ * lives: an entry is a document of blocks now, so the prose is a string that
15
+ * has to go through the markdown pipeline rather than a file Astro can glob.
16
+ */
17
+ export const collections = Object.fromEntries(
18
+ COLLECTIONS.map((collection) => [collection.name, defineCollection({ loader: entries(collection) })]),
19
+ );
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Every sentence a visitor reads — read from `jtk/content/index.json`
3
+ * rather than written here.
4
+ *
5
+ * The strings live in the repository's content document so that the studio's
6
+ * admin can edit them: a commit under `jtk/` **is** the published state,
7
+ * and publishing writes that file. This module is the adapter between that
8
+ * document and the shape the components already import, which is why the
9
+ * exports below look ordinary — nothing in a component has to know where its
10
+ * text came from.
11
+ *
12
+ * Two things did not move, because they are not content:
13
+ * META — the `<html lang>` value and the title suffix
14
+ * A11Y — the skip link
15
+ *
16
+ * Comments and identifiers stay English. The values are the client's language.
17
+ *
18
+ * ── the document, and the file that ships with this scaffold ────────────────
19
+ *
20
+ * `jtk/content/index.json` is committed with empty strings in it, and that
21
+ * is deliberate: a fresh project must `npm run build` on a laptop the minute
22
+ * `fl-init` finishes, and a static import of a file that is not there is a build
23
+ * error rather than an empty page. The first publish from the admin replaces it
24
+ * wholesale.
25
+ *
26
+ * `jtk/catalogue.json` is **not** shipped. It describes what blocks a site
27
+ * has, the admin seeds it from the registry on the first publish, and a copy
28
+ * here would be a second one to keep in step by hand.
29
+ */
30
+ import page from '../../jtk/content/index.json';
31
+ import { LOCALE } from '../content/blocks';
32
+
33
+ export const META = {
34
+ /**
35
+ * The `<html lang>` value and `inLanguage` in structured data.
36
+ *
37
+ * From `LOCALE` in `src/content/blocks.ts`, not written again here: that file
38
+ * is what declares the site's languages to the platform, and a language
39
+ * spelt in two places is a language that is eventually spelt two ways.
40
+ */
41
+ lang: LOCALE,
42
+ /** `og:locale`, e.g. uk_UA, de_CH, en_GB. */
43
+ ogLocale: '{{OG_LOCALE}}',
44
+ /** Appended to every page title except the home page's. */
45
+ titleSuffix: ' — {{NAME}}',
46
+ } as const;
47
+
48
+ /**
49
+ * What the site says on its own behalf, rather than what the business says.
50
+ *
51
+ * Written by `fl-init` from the locale, not left blank: a skip link with no
52
+ * words is a skip link a screen reader announces as nothing.
53
+ */
54
+ /**
55
+ * The privacy page's words.
56
+ *
57
+ * Short on purpose, and shorter than it was. What the **platform** does with an
58
+ * enquiry — how long it keeps it, where — used to be six paragraphs here, in
59
+ * every language a site is ever built in, going stale the day any of it
60
+ * changed. It is one link now, and the text lives where the thing it describes
61
+ * lives.
62
+ *
63
+ * What is left is the business's own: what their form asks for, that the site
64
+ * runs on somebody, and how to reach them. The heading under `ownTitle` is left
65
+ * empty for whoever is building the site — a generated paragraph about somebody
66
+ * else's company is a guess wearing a legal shape.
67
+ *
68
+ * **None of it is required.** The platform asks for one link on a page that
69
+ * opens and reads nothing around it; these words are a default so that a new
70
+ * site has something true on day one, not a form anybody has to keep to.
71
+ */
72
+ export const PRIVACY = {
73
+ title: '{{UI_PRIVACY_TITLE}}',
74
+ intro: '{{UI_PRIVACY_INTRO}}',
75
+
76
+ formTitle: '{{UI_PRIVACY_FORM_TITLE}}',
77
+ formBody: '{{UI_PRIVACY_FORM_BODY}}',
78
+
79
+ /** Ends in a colon: the link follows it. */
80
+ whoTitle: '{{UI_PRIVACY_WHO_TITLE}}',
81
+ whoBody: '{{UI_PRIVACY_WHO_BODY}}',
82
+
83
+ askTitle: '{{UI_PRIVACY_ASK_TITLE}}',
84
+ askBody: '{{UI_PRIVACY_ASK_BODY}}',
85
+
86
+ /** The business's own section. Empty on purpose: it is theirs to write. */
87
+ ownTitle: '{{UI_PRIVACY_OWN_TITLE}}',
88
+ } as const;
89
+
90
+ export const A11Y = {
91
+ skipToContent: '{{UI_SKIP_TO_CONTENT}}',
92
+ } as const;
93
+
94
+ type Block = Record<string, unknown> & { type: string };
95
+ type Item = Record<string, unknown>;
96
+
97
+ const blocks = page.blocks as unknown as Block[];
98
+
99
+ /**
100
+ * A block by type, with its index.
101
+ *
102
+ * The index is what an annotation path is built from, so it has to come from the
103
+ * document rather than be counted by hand in the markup — a block inserted above
104
+ * this one moves it, and a hand-written path would then point at somebody else's
105
+ * text.
106
+ *
107
+ * A block the document does not have is not an error: the admin can remove one,
108
+ * and a page that threw at build time because a section was deleted would make
109
+ * removing a section a deploy incident.
110
+ */
111
+ export function block(type: string): { at: number; of: Block } {
112
+ const at = blocks.findIndex((candidate) => candidate.type === type);
113
+ return at === -1 ? { at: -1, of: { type } } : { at, of: blocks[at]! };
114
+ }
115
+
116
+ export const str = (of: Block, key: string): string => (of[key] as string) ?? '';
117
+ export const rows = (of: Block, key: string): Item[] => (of[key] as Item[]) ?? [];
118
+
119
+ /**
120
+ * One picture of a gallery, as a component wants it.
121
+ *
122
+ * `name` is what `<Shot>` resolves — a slot in `src/assets` for a picture the
123
+ * repository ships, or a key like `media/<site>/<hash>.jpg` for one the owner
124
+ * uploaded. Both are local files by build time and both go through
125
+ * `astro:assets`; nothing downstream has to know which it was given.
126
+ */
127
+ export interface Shown {
128
+ readonly name: string;
129
+ readonly alt: string;
130
+ /** `data-jtk-path` for this picture: `blocks[3].work[7].src`. */
131
+ readonly path?: string;
132
+ /** A clip's poster — the still every refused autoplay lands on. */
133
+ readonly poster?: string;
134
+ }
135
+
136
+ /**
137
+ * One picture of a block, as a component wants it.
138
+ *
139
+ * ── every photograph on the page is one of two things ───────────────────────
140
+ *
141
+ * A gallery item, or a field of its own. There is no third kind, and the
142
+ * difference is not how it is stored — it is what an owner can do about it.
143
+ * A photograph that is neither is a photograph only we can change, and "can you
144
+ * swap the picture of me" becomes a message and a deploy.
145
+ *
146
+ * So the rule is: **a photograph that is on the page on purpose gets a field.**
147
+ * Declare it, put the slot it already uses in the content document, and pass
148
+ * the path to `<Shot>`:
149
+ *
150
+ * const { at, of } = block('artist');
151
+ * const portrait = picture(of, at, 'portrait');
152
+ * <Shot name={portrait.name} alt={portrait.alt} path={portrait.path} ratio="4 / 5" />
153
+ *
154
+ * The value stays the slot the repository already ships — `artist-at-work` —
155
+ * so nothing about the page changes until somebody chooses to change it.
156
+ *
157
+ * The alt text is a sibling field, `<key>_alt`, marked `no_tap_target`: a media
158
+ * field's value is one string with nowhere to keep words, and there is nothing
159
+ * on the page that *is* the alt text to tap. (A gallery item keeps its own alt
160
+ * inside it, because a collection has room for one.)
161
+ *
162
+ * What stays out: a photograph that is structure rather than subject — a
163
+ * texture, a rule, a shape behind a heading. That is design, and the catalogue
164
+ * is the place where the difference is written down.
165
+ */
166
+ export function picture(of: Block, at: number, key: string): Shown {
167
+ return {
168
+ name: str(of, key),
169
+ alt: str(of, `${key}_alt`),
170
+ path: pathAt(at, key),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * A gallery field, as pictures.
176
+ *
177
+ * ── this function is the seam ───────────────────────────────────────────────
178
+ *
179
+ * The catalogue declares one field holding many pictures; the document stores
180
+ * them as `[{ src, alt }]`, in the order they appear on the page; the admin
181
+ * annotates each one at `blocks[3].work[7].src`. All three of those facts live
182
+ * here and nowhere else, so a component takes a list and renders it:
183
+ *
184
+ * const { at, of } = block('plates');
185
+ * <Gallery arrangement="wall" items={gallery(of, at, 'work')} />
186
+ *
187
+ * **Every picture carries its own path.** That is the half that cannot be
188
+ * skipped: without it the owner sees their work on the page and cannot touch
189
+ * it, `fl-catalogue` fails on exactly that, and a gallery is the longest list
190
+ * on the site to have to fix afterwards.
191
+ *
192
+ * A field the document does not have is an empty list rather than an error. It
193
+ * is what a site looks like on a laptop before it has ever been attached, and a
194
+ * build that threw there could not be checked or pushed.
195
+ */
196
+ export function gallery(of: Block, at: number, key: string): Shown[] {
197
+ return rows(of, key).flatMap((item, i) => {
198
+ const src = (item.src as string) ?? '';
199
+ if (!src) return [];
200
+
201
+ return [
202
+ {
203
+ name: src,
204
+ alt: (item.alt as string) ?? '',
205
+ poster: (item.poster as string) || undefined,
206
+ path: pathAt(at, key, i, 'src'),
207
+ },
208
+ ];
209
+ });
210
+ }
211
+
212
+ /**
213
+ * The path of a field, for `data-jtk-path`.
214
+ *
215
+ * Returns undefined when annotations are off, so Astro drops the attribute
216
+ * entirely and a production build carries no trace of the editor — the same
217
+ * switch the build contract calls PUBLIC_JTK_ANNOTATE.
218
+ *
219
+ * **Every element whose text or image comes from content must carry one.** That
220
+ * is not a convention: the annotation lint fails the build without it, because
221
+ * an element that loses its path stops opening the editor *silently*, and
222
+ * nobody finds out for a week.
223
+ */
224
+ const ANNOTATE = import.meta.env.PUBLIC_JTK_ANNOTATE !== 'false';
225
+
226
+ export function flPath(
227
+ type: string,
228
+ field: string,
229
+ item?: number,
230
+ subField?: string,
231
+ ): string | undefined {
232
+ if (!ANNOTATE) return undefined;
233
+
234
+ const { at } = block(type);
235
+ if (at === -1) return undefined;
236
+
237
+ return item === undefined
238
+ ? `blocks[${at}].${field}`
239
+ : `blocks[${at}].${field}[${item}].${subField}`;
240
+ }
241
+
242
+ /**
243
+ * Every block, in the document's own order.
244
+ *
245
+ * The page renders this rather than looking blocks up by name, because a page is
246
+ * a sequence and a generated one repeats types: two feature grids and three
247
+ * prose sections are an ordinary landing page, and `block('feature_grid')` can
248
+ * only ever find the first.
249
+ *
250
+ * The index is the block's own, which is what an annotation path is built from.
251
+ */
252
+ export const PAGE: readonly Block[] = blocks;
253
+
254
+ /** The path of a field on the block at `at`, for `data-jtk-path`. */
255
+ export function pathAt(at: number, field: string, item?: number, subField?: string): string | undefined {
256
+ if (!ANNOTATE) return undefined;
257
+ if (at < 0 || at >= blocks.length) return undefined;
258
+
259
+ return item === undefined
260
+ ? `blocks[${at}].${field}`
261
+ : `blocks[${at}].${field}[${item}].${subField}`;
262
+ }
263
+
264
+ const hero = block('hero').of;
265
+ const cta = block('cta_banner').of;
266
+
267
+ export const HOME = {
268
+ /** Complete on its own — the home page passes suffix={false}. */
269
+ title: (page.seo?.title as string) ?? '',
270
+ /** ~150–160 characters, a sentence a person would read in a result list. */
271
+ description: (page.seo?.description as string) ?? '',
272
+
273
+ hero: {
274
+ eyebrow: str(hero, 'eyebrow'),
275
+ heading: str(hero, 'title'),
276
+ lead: str(hero, 'lead'),
277
+ cta: str(hero, 'cta_label'),
278
+ ctaHref: str(hero, 'cta_href') || '#contact',
279
+ image: str(hero, 'image'),
280
+ imageAlt: str(hero, 'image_alt'),
281
+ },
282
+ } as const;
283
+
284
+ /**
285
+ * The lead form.
286
+ *
287
+ * Its labels belong to the business; its validation strings are interface, and
288
+ * the catalogue marks those `client_editable: false` — there is nothing a
289
+ * business owner wants to say in "Sending…".
290
+ *
291
+ * Which is why `fl-init` writes them from the locale rather than leaving them
292
+ * blank. They were blank, in every language, and the form said nothing while
293
+ * it sent, nothing when it had sent, and nothing when a field was empty — a
294
+ * defect that no build catches, because an empty string is a valid string.
295
+ *
296
+ * Server-side validation has its own copy on the platform, behind `/api/lead`: the
297
+ * browser never sees those strings until the server answers, so they cannot be
298
+ * shared from here.
299
+ */
300
+ export const FORM = {
301
+ title: str(cta, 'title'),
302
+ lead: str(cta, 'lead'),
303
+
304
+ // The questions, which are the business's own — see blocks.ts.
305
+ name: str(cta, 'name_label'),
306
+ contact: str(cta, 'contact_label'),
307
+ message: str(cta, 'message_label'),
308
+
309
+ required: '{{UI_REQUIRED}}',
310
+ requiredNote: str(cta, 'required_note'),
311
+ privacy: '{{UI_PRIVACY}}',
312
+
313
+ submit: str(cta, 'cta_label'),
314
+ sending: '{{UI_SENDING}}',
315
+ success: '{{UI_SUCCESS}}',
316
+ error: '{{UI_ERROR}}',
317
+
318
+ invalidName: '{{UI_INVALID_NAME}}',
319
+ invalidContact: '{{UI_INVALID_CONTACT}}',
320
+ invalidMessage: '{{UI_INVALID_MESSAGE}}',
321
+
322
+ /** Shown while the Telegram bot is not connected. Preview only. */
323
+ demoNote: '{{UI_DEMO_NOTE}}',
324
+ } as const;
@@ -0,0 +1,137 @@
1
+ import { under } from '../lib/under';
2
+
3
+ /**
4
+ * {{NAME}} — the business's own facts, and the one switch that controls
5
+ * indexability.
6
+ *
7
+ * Nothing here is copy. It is the data that appears in structured data, in the
8
+ * footer, in `tel:` links, in the sitemap and in llms.txt, and it must be
9
+ * identical everywhere it is rendered — which is why it is rendered from here
10
+ * and never typed into a component.
11
+ *
12
+ * Only confirmed facts. Everything on this page is asserted to Google, and to
13
+ * the assistants that quote it, as fact: a wrong opening hour or an invented
14
+ * rating is worse than a missing one, and it carries the client's name.
15
+ */
16
+
17
+ /**
18
+ * The host this build claims, read back from the `site` option in
19
+ * astro.config.mjs rather than written down a second time. A preview build sets
20
+ * SITE_URL there and every canonical link follows it.
21
+ */
22
+ export const SITE_URL = import.meta.env.SITE;
23
+
24
+ /**
25
+ * Whether the robots meta tag, robots.txt, the sitemap and llms.txt say
26
+ * "index me". Always: there is nothing here to decide.
27
+ *
28
+ * On the platform there is one kind of build, made for the site's address.
29
+ * The preview shows the same build under `/p/<slug>/`, behind a session,
30
+ * and the platform itself keeps it out of the index with a header; the
31
+ * launch moves a pointer. A switch in this file that somebody flipped at
32
+ * launch was the shape that shipped sites telling search engines to go away,
33
+ * and a build that says noindex is refused (JTK_E_NOINDEX). The constant
34
+ * stays so the four files that read it keep one name for one fact.
35
+ */
36
+ export const INDEXABLE = true;
37
+
38
+ export const BUSINESS = {
39
+ name: '{{NAME}}',
40
+
41
+ /** One sentence: what this business is, where, for whom. */
42
+ description: '',
43
+
44
+ /** Address in parts. A single string is not parseable by anything. */
45
+ street: '',
46
+ postalCode: '',
47
+ city: '',
48
+ region: '',
49
+ /** ISO 3166-1 alpha-2. */
50
+ country: '',
51
+
52
+ /** What a visitor reads — the form the local market recognises. */
53
+ phoneDisplay: '',
54
+ /** What a phone dials. E.164. Never the same string as the one above. */
55
+ phoneDial: '',
56
+
57
+ email: '',
58
+
59
+ /** Every profile that is really theirs. Feeds `sameAs`. */
60
+ instagram: '',
61
+ facebook: '',
62
+
63
+ /** Google's coarse bucket, e.g. '€€' or 'CHF 3–130'. Not a price list. */
64
+ priceRange: '',
65
+
66
+ mapsUrl: '',
67
+ } as const;
68
+
69
+ /**
70
+ * The one query this page is for: **[service] [city]**.
71
+ *
72
+ * Not a keyword list. One service, named the way a customer would say it out
73
+ * loud, and the city they would say it in. A landing page that tries to rank
74
+ * for six things ranks for none of them, and a business with several services
75
+ * gets one page per service later — not six phrases on this one.
76
+ *
77
+ * Nothing renders this. It is the target the copy is written *at*, and
78
+ * `fl-check` reads it back to confirm that the title, the h1 and the
79
+ * description actually serve it — a page that has to have the phrase inserted
80
+ * to pass the check has already missed the point.
81
+ *
82
+ * The page still has to read as though written for a person: the query belongs
83
+ * in sentences about this business and this person, and the moment it is
84
+ * repeated for the crawler's benefit it is working against both audiences. See
85
+ * `docs/pages.md` of @jtakeit/astro.
86
+ */
87
+ export const SEARCH: {
88
+ /** What a customer types. Their word, not the trade's. */
89
+ service: string;
90
+ /** The city, as a customer names it. Empty where the business is not local. */
91
+ city: string;
92
+ /**
93
+ * The other forms of those two words that the copy legitimately uses.
94
+ * Ukrainian, German and Polish all decline: «Львів» is «у Львові» in the one
95
+ * sentence where it reads naturally, and a check that does not know that
96
+ * would report a correctly written page as missing its city.
97
+ */
98
+ forms: readonly string[];
99
+ } = {
100
+ service: '',
101
+ city: '',
102
+ forms: [],
103
+ };
104
+
105
+ /**
106
+ * The narrowest schema.org type that is true — Restaurant, NailSalon, Dentist,
107
+ * LegalService, HomeAndConstructionBusiness… A wrong narrow type is worse than
108
+ * a correct broad one; `LocalBusiness` is always honest.
109
+ */
110
+ export const SCHEMA_TYPE = '{{SCHEMA_TYPE}}';
111
+
112
+ /**
113
+ * Every page of the site, written by hand rather than generated, so that adding
114
+ * a page and forgetting to list it is a visible omission in one file instead of
115
+ * a silent one across the build. Feeds the sitemap and llms.txt.
116
+ */
117
+ export const PAGES: readonly { path: string; priority: number; summary: string }[] = [
118
+ { path: '/', priority: 1.0, summary: '' },
119
+ ];
120
+
121
+ export const fullAddress = [
122
+ BUSINESS.street,
123
+ [BUSINESS.postalCode, BUSINESS.city].filter(Boolean).join(' '),
124
+ ]
125
+ .filter(Boolean)
126
+ .join(', ');
127
+
128
+ /**
129
+ * The site's origin, then the path under this build's base. `SITE` already
130
+ * carries the base on a preview build — `https://preview…/p/<slug>` — so
131
+ * resolving `under(path)` against it wrote the slug twice, and resolving the
132
+ * bare path against it dropped the slug. The origin is the one part of it
133
+ * that is the same in both readings.
134
+ */
135
+ export function canonicalFor(path: string): string {
136
+ return new URL(under(path), new URL(SITE_URL).origin).href;
137
+ }