@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/jtk.mjs +41 -0
- package/docs/booking.md +164 -0
- package/docs/catalogue.md +459 -0
- package/docs/collections.md +249 -0
- package/docs/css.md +86 -0
- package/docs/gallery.md +127 -0
- package/docs/hero-motion.md +189 -0
- package/docs/kit.md +454 -0
- package/docs/languages.md +182 -0
- package/docs/lead-form.md +109 -0
- package/docs/pages.md +193 -0
- package/docs/photos.md +314 -0
- package/docs/scaffold.md +75 -0
- package/docs/shapes.md +140 -0
- package/docs/surface.md +187 -0
- package/lib/catalogue.mjs +1678 -0
- package/lib/codes.mjs +171 -0
- package/lib/create.mjs +282 -0
- package/package.json +16 -0
- package/template/astro.config.mjs +84 -0
- package/template/figures.mjs +122 -0
- package/template/gitignore +16 -0
- package/template/jtakeit-meta.mjs +112 -0
- package/template/jtk/content/index.json +38 -0
- package/template/jtk/design.json +24 -0
- package/template/markdown.mjs +36 -0
- package/template/package-lock.json +5320 -0
- package/template/package.json +26 -0
- package/template/specimens.mjs +46 -0
- package/template/src/components/Blocks.astro +151 -0
- package/template/src/components/BookingForm.astro +506 -0
- package/template/src/components/Clip.astro +155 -0
- package/template/src/components/Hero.astro +66 -0
- package/template/src/components/LeadForm.astro +347 -0
- package/template/src/components/OpeningHours.astro +69 -0
- package/template/src/components/Pile.astro +185 -0
- package/template/src/components/Shot.astro +472 -0
- package/template/src/components/gallery/Gallery.astro +381 -0
- package/template/src/components/gallery/galleries.ts +139 -0
- package/template/src/components/motion/HeroField.astro +520 -0
- package/template/src/components/motion/fields.ts +430 -0
- package/template/src/components/surface/Pattern.astro +278 -0
- package/template/src/components/surface/patterns.ts +187 -0
- package/template/src/content/blocks.ts +758 -0
- package/template/src/content.config.ts +19 -0
- package/template/src/copy/LOCALE.ts +324 -0
- package/template/src/data/site.ts +137 -0
- package/template/src/layouts/Layout.astro +282 -0
- package/template/src/lib/alive.ts +49 -0
- package/template/src/lib/entries.ts +106 -0
- package/template/src/lib/entryLoader.ts +315 -0
- package/template/src/lib/noise.ts +26 -0
- package/template/src/lib/page.ts +287 -0
- package/template/src/lib/photos.ts +168 -0
- package/template/src/lib/under.ts +32 -0
- package/template/src/lib/uploads.ts +85 -0
- package/template/src/pages/[...entry].astro +207 -0
- package/template/src/pages/[...feed].xml.ts +64 -0
- package/template/src/pages/index.astro +90 -0
- package/template/src/pages/llms.txt.ts +50 -0
- package/template/src/pages/privacy.astro +59 -0
- package/template/src/pages/robots.txt.ts +21 -0
- package/template/src/pages/sitemap.xml.ts +50 -0
- package/template/src/styles/global.css +411 -0
- package/template/src/styles/surface.css +375 -0
- package/template/tsconfig.json +5 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the admin may edit on this site.
|
|
3
|
+
*
|
|
4
|
+
* ── the one file that decides whether a bespoke site is editable ────────────
|
|
5
|
+
*
|
|
6
|
+
* The studio's admin edits a site by tapping the text on it. It can do that for
|
|
7
|
+
* any site, however that site is built, because it reads a *catalogue* — this
|
|
8
|
+
* file, emitted to `jtk/catalogue.json` — rather than knowing anything
|
|
9
|
+
* about these components. That is the whole reason a site built here can look
|
|
10
|
+
* like nothing else and still be handed to its owner.
|
|
11
|
+
*
|
|
12
|
+
* So the division is:
|
|
13
|
+
*
|
|
14
|
+
* src/ the design. Yours, or the agent's. Anything at all.
|
|
15
|
+
* jtk/ the values. The owner's, edited in the admin.
|
|
16
|
+
* this file which values there are, and what each one is
|
|
17
|
+
*
|
|
18
|
+
* **Declare a field here and the owner can edit it. Leave it out and it is part
|
|
19
|
+
* of the design.** Both are legitimate — a headline is content, a section's
|
|
20
|
+
* layout is not — and that decision is yours to make per field rather than
|
|
21
|
+
* something a framework makes for you.
|
|
22
|
+
*
|
|
23
|
+
* `fl-catalogue` emits the JSON and then checks it against the built HTML: every
|
|
24
|
+
* field declared here has to appear on the page carrying its `data-jtk-path`,
|
|
25
|
+
* and every annotated path on the page has to be declared here. The two cannot
|
|
26
|
+
* drift, which is what stops an owner tapping a heading that never opens.
|
|
27
|
+
*
|
|
28
|
+
* ── a label is the words ────────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* The words themselves, in the site's language, and the admin shows them as
|
|
31
|
+
* written. There are no locale keys anywhere any more — the admin's own blocks
|
|
32
|
+
* carried them until 30 August 2026 and now do the same as this file, so our
|
|
33
|
+
* blocks and yours are drawn by one code path.
|
|
34
|
+
*
|
|
35
|
+
* A site in more than one language writes the label per language instead:
|
|
36
|
+
*
|
|
37
|
+
* label: 'Заголовок'
|
|
38
|
+
* label: { uk: 'Заголовок', en: 'Heading' }
|
|
39
|
+
*
|
|
40
|
+
* Which languages a site has is `LOCALE` and `LOCALES` at the foot of this
|
|
41
|
+
* file; a label naming a language the site does not have is simply never shown.
|
|
42
|
+
*
|
|
43
|
+
* ── the labels below are English, and that is the scaffold speaking ─────────
|
|
44
|
+
*
|
|
45
|
+
* They arrive in English whatever `--locale` the site was scaffolded with,
|
|
46
|
+
* because they are a **starting point rather than an answer**: the design pass
|
|
47
|
+
* rewrites this file for the business it is building, and it writes the labels
|
|
48
|
+
* in the language the owner will read them in.
|
|
49
|
+
*
|
|
50
|
+
* They used to arrive in Ukrainian, which was invisible while every site was
|
|
51
|
+
* Ukrainian and wrong the first time one was not — a Swiss client opening the
|
|
52
|
+
* panel found their blocks labelled in a language they do not read, and
|
|
53
|
+
* nothing in the build said so. English is not a translation, it is an
|
|
54
|
+
* admission that the scaffold does not know yet.
|
|
55
|
+
*
|
|
56
|
+
* The strings a visitor sees are the other way round: those are in
|
|
57
|
+
* `src/copy/`, they are interface rather than seed, nothing rewrites them, and
|
|
58
|
+
* `fl-init` fills them in per locale.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The words on a control: written once for a site in one language, and per
|
|
63
|
+
* language for a site in several. See the note on labels above.
|
|
64
|
+
*/
|
|
65
|
+
export type Text = string | Record<string, string>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A `Text` as one string, for the places that can only take one.
|
|
69
|
+
*
|
|
70
|
+
* A feed's `<title>`, an attribute, anything written into markup: those cannot
|
|
71
|
+
* hold a map of languages, and handing them one produced `[object Object]` in
|
|
72
|
+
* the output and a type error at the call site. `words()` is where that choice
|
|
73
|
+
* is made once.
|
|
74
|
+
*
|
|
75
|
+
* The order is the site's language first, then the site's other languages in
|
|
76
|
+
* the order they are declared, then whatever the map happens to hold. The last
|
|
77
|
+
* step matters more than it looks: a label written only in a language the site
|
|
78
|
+
* has since dropped is still better than an empty title.
|
|
79
|
+
*/
|
|
80
|
+
export function words(text: Text | undefined, locale: string = LOCALE): string {
|
|
81
|
+
if (text === undefined) return '';
|
|
82
|
+
if (typeof text === 'string') return text;
|
|
83
|
+
|
|
84
|
+
const found = text[locale];
|
|
85
|
+
if (found !== undefined && found !== '') return found;
|
|
86
|
+
|
|
87
|
+
for (const other of LOCALES) {
|
|
88
|
+
const next = text[other];
|
|
89
|
+
if (next !== undefined && next !== '') return next;
|
|
90
|
+
}
|
|
91
|
+
return Object.values(text).find((one) => one !== '') ?? '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The shapes a field can take. The admin has a control for each. */
|
|
95
|
+
export type Kind =
|
|
96
|
+
| 'text'
|
|
97
|
+
| 'textarea'
|
|
98
|
+
| 'richtext_lite'
|
|
99
|
+
| 'number'
|
|
100
|
+
/**
|
|
101
|
+
* An amount of money, as a whole number of **minor units**: 25000 is 250.00.
|
|
102
|
+
*
|
|
103
|
+
* Never a decimal. A JSON number is a float on both sides of the wire, and
|
|
104
|
+
* money that is a float is money that is wrong the day somebody sums a
|
|
105
|
+
* column; minor units are also what Stripe is handed later, so the value
|
|
106
|
+
* stored is the value passed on rather than a conversion to remember.
|
|
107
|
+
*
|
|
108
|
+
* The currency is the site's and is not part of the value — two prices on one
|
|
109
|
+
* page must not be able to disagree about it. The template writes the symbol
|
|
110
|
+
* beside the amount it renders.
|
|
111
|
+
*/
|
|
112
|
+
| 'money'
|
|
113
|
+
/**
|
|
114
|
+
* A length of time in whole **minutes**: 90 is an hour and a half.
|
|
115
|
+
*
|
|
116
|
+
* Minutes because that is the unit slot arithmetic works in, and a unit
|
|
117
|
+
* nothing converts is a unit nothing converts wrongly. The panel reads it
|
|
118
|
+
* back in hours beneath the box, so a mistyped 900 looks like fifteen hours
|
|
119
|
+
* rather than like any other number.
|
|
120
|
+
*/
|
|
121
|
+
| 'duration'
|
|
122
|
+
/**
|
|
123
|
+
* A time on a clock face, as whole minutes from local midnight: 540 is nine
|
|
124
|
+
* in the morning.
|
|
125
|
+
*
|
|
126
|
+
* Its own kind rather than a duration because the control is the whole
|
|
127
|
+
* difference: a duration answers "9 год" to "when do you open", which is
|
|
128
|
+
* true of a length and false of a time. No zone travels with it — the zone
|
|
129
|
+
* is the site's, declared once.
|
|
130
|
+
*/
|
|
131
|
+
| 'time_of_day'
|
|
132
|
+
| 'tel'
|
|
133
|
+
| 'url'
|
|
134
|
+
| 'email'
|
|
135
|
+
| 'date'
|
|
136
|
+
| 'select'
|
|
137
|
+
| 'bool'
|
|
138
|
+
| 'media'
|
|
139
|
+
| 'list'
|
|
140
|
+
/**
|
|
141
|
+
* A document rather than a field: the body of an entry in a collection.
|
|
142
|
+
*
|
|
143
|
+
* Markdown, and stored as markdown — the owner never sees the syntax (the
|
|
144
|
+
* admin's editor shows the result and puts pictures in at the cursor), but
|
|
145
|
+
* what lands in `jtk/content/` is a file with a diff a person can read,
|
|
146
|
+
* which an agent can write and Astro's own pipeline renders.
|
|
147
|
+
*
|
|
148
|
+
* A picture inside one is ordinary markdown, and its address is a media key
|
|
149
|
+
* exactly as everywhere else:
|
|
150
|
+
*
|
|
151
|
+
* 
|
|
152
|
+
*
|
|
153
|
+
* The alt text is for whoever cannot see it; the title is the caption. Where
|
|
154
|
+
* on the page a picture sits is the *design's* answer, not the owner's —
|
|
155
|
+
* they choose which paragraph it follows and nothing else.
|
|
156
|
+
*/
|
|
157
|
+
| 'markdown';
|
|
158
|
+
|
|
159
|
+
export interface Field {
|
|
160
|
+
/** `[a-z][a-z0-9_]*`, and permanent: it is the key in the content document. */
|
|
161
|
+
key: string;
|
|
162
|
+
/** What the owner sees above the control, in their language. */
|
|
163
|
+
label: Text;
|
|
164
|
+
kind: Kind;
|
|
165
|
+
/** A sentence under the control, where the field needs one. */
|
|
166
|
+
hint?: Text;
|
|
167
|
+
required?: boolean;
|
|
168
|
+
/** Characters for text, items for a list, the value itself for a number. */
|
|
169
|
+
max?: number;
|
|
170
|
+
min?: number;
|
|
171
|
+
/** For `select`, and only for it. */
|
|
172
|
+
options?: string[];
|
|
173
|
+
/** The words a select shows for each option, keyed by the option's value. */
|
|
174
|
+
option_labels?: Record<string, Text>;
|
|
175
|
+
/** For `list`: the fields of one row. */
|
|
176
|
+
of?: Field[];
|
|
177
|
+
/**
|
|
178
|
+
* For `media`, and only for it: what this slot holds.
|
|
179
|
+
*
|
|
180
|
+
* `image` is the default and the assumption everywhere it is left out.
|
|
181
|
+
* `video` is a short silent loop — see the clips of `docs/photos.md` — and it is declared
|
|
182
|
+
* because what the page does with one is different: it autoplays, it loops,
|
|
183
|
+
* it carries no audio track at all, and it needs a poster frame to fail to.
|
|
184
|
+
*
|
|
185
|
+
* **`video` is always `multiple`**, one clip included: a clip is stored with
|
|
186
|
+
* its poster, and a single media field is one key with nowhere to keep one.
|
|
187
|
+
* One clip is `multiple: true, max: 1`.
|
|
188
|
+
*/
|
|
189
|
+
accept?: 'image' | 'video';
|
|
190
|
+
/**
|
|
191
|
+
* For `media`, and only for it: the frame this picture is shown in — '3:2',
|
|
192
|
+
* '1:1', '16:9'.
|
|
193
|
+
*
|
|
194
|
+
* ── the frame is yours and the subject is theirs ─────────────────────────
|
|
195
|
+
*
|
|
196
|
+
* A client uploads a photograph in whatever shape their camera gave it and
|
|
197
|
+
* the page has a slot of its own. Declaring the slot is how a row of cards
|
|
198
|
+
* stops being pushed apart by one tall portrait — and it is declared *here*
|
|
199
|
+
* rather than chosen in the admin because how a page is proportioned is the
|
|
200
|
+
* design's answer, not something two pages of one site should differ over.
|
|
201
|
+
*
|
|
202
|
+
* What the owner says is which part of their picture must survive it: a
|
|
203
|
+
* framed field gives every item a `focus`, and they press the subject. There
|
|
204
|
+
* is no stretching, ever — a photograph with its geometry changed is a
|
|
205
|
+
* spoiled photograph.
|
|
206
|
+
*
|
|
207
|
+
* **A framed field is always `multiple`**, one picture included, for the same
|
|
208
|
+
* reason a clip is: the focus is stored beside the picture.
|
|
209
|
+
*/
|
|
210
|
+
ratio?: string;
|
|
211
|
+
/**
|
|
212
|
+
* For `media`, and only for it: this slot holds *many*, in order.
|
|
213
|
+
*
|
|
214
|
+
* A gallery is one thing, not thirty-seven things. Declared as a `list` of
|
|
215
|
+
* rows it becomes a repeater — add a row, open the row, choose a file, close
|
|
216
|
+
* the row, thirty-seven times — which is the wrong shape for what it actually
|
|
217
|
+
* is: a collection you drop pictures into and take pictures out of.
|
|
218
|
+
*
|
|
219
|
+
* So it is one field, and its value is an ordered array of items:
|
|
220
|
+
*
|
|
221
|
+
* work: [{ src: 'media/<site>/<hash>.jpg', alt: 'Дракон, передпліччя' }]
|
|
222
|
+
* clips: [{ src: 'media/<site>/<hash>.mp4',
|
|
223
|
+
* poster: 'media/<site>/<hash>.jpg', alt: 'Стенсіл' }]
|
|
224
|
+
*
|
|
225
|
+
* `src`, `alt`, `poster`, and nothing else — the three keys are the admin's,
|
|
226
|
+
* not this file's, so they cannot be renamed per site. Each item carries its
|
|
227
|
+
* own `alt` because a photograph with no alt text is announced as a file
|
|
228
|
+
* name. Order is position: the array is the page.
|
|
229
|
+
*
|
|
230
|
+
* One picture is annotated as `blocks[3].work[7].src`, and the container is
|
|
231
|
+
* not annotated at all. `alt` and `poster` never appear on the page as
|
|
232
|
+
* themselves, so they carry no annotation either.
|
|
233
|
+
*
|
|
234
|
+
* A `list` of rows is still the right shape where every piece has *words*
|
|
235
|
+
* beside it and the words are the point — see the `index` arrangement in
|
|
236
|
+
* references/gallery.md. The difference is whether the owner is editing
|
|
237
|
+
* entries or filling a bag.
|
|
238
|
+
*/
|
|
239
|
+
multiple?: boolean;
|
|
240
|
+
/**
|
|
241
|
+
* Whether the *client* may edit it, as opposed to the studio.
|
|
242
|
+
*
|
|
243
|
+
* A headline is theirs. A button's destination usually is not — it points at
|
|
244
|
+
* a section of a page they did not build.
|
|
245
|
+
*/
|
|
246
|
+
client_editable?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* A field whose value never appears as its own element: a message a script
|
|
249
|
+
* writes later, a string that lands in an attribute, a label only a screen
|
|
250
|
+
* reader hears. It is edited in the panel beside the preview, so the
|
|
251
|
+
* annotation check must not look for a tap target.
|
|
252
|
+
*/
|
|
253
|
+
no_tap_target?: boolean;
|
|
254
|
+
/** Shown with a warning that changing it moves the page in search results. */
|
|
255
|
+
seo_sensitive?: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface Block {
|
|
259
|
+
/** `[a-z][a-z0-9_]*`, and permanent: it is written into the content. */
|
|
260
|
+
type: string;
|
|
261
|
+
/** Bump when a field's meaning changes, never when one is added. */
|
|
262
|
+
v: number;
|
|
263
|
+
label: Text;
|
|
264
|
+
hint?: Text;
|
|
265
|
+
fields: Field[];
|
|
266
|
+
/**
|
|
267
|
+
* The arrangements this block may be shown in, where it has more than one.
|
|
268
|
+
*
|
|
269
|
+
* ── the designer writes the menu, the owner orders from it ────────────────
|
|
270
|
+
*
|
|
271
|
+
* How work is arranged is still the design's answer, for the reason that has
|
|
272
|
+
* not changed: a handle an owner can drag is a handle two pages of one site
|
|
273
|
+
* come out different through. But a page that draws pictures three ways — a
|
|
274
|
+
* row of three, one large beside two small, a strip that scrolls — was
|
|
275
|
+
* offering three *sanctioned* arrangements the owner could not reach: each
|
|
276
|
+
* was a separate block type, and changing your mind meant deleting the
|
|
277
|
+
* pictures and putting them back.
|
|
278
|
+
*
|
|
279
|
+
* So the menu is written here, by whoever designed the page. The owner picks
|
|
280
|
+
* from it and can invent nothing.
|
|
281
|
+
*
|
|
282
|
+
* Only a block with one gallery in it may declare these, and then the
|
|
283
|
+
* gallery must not carry its own `min`, `max` or `ratio`: they belong to the
|
|
284
|
+
* arrangement, because they are not the same in each. Three side by side
|
|
285
|
+
* stops being photographs of work at four; a strip that scrolls is pointless
|
|
286
|
+
* under five.
|
|
287
|
+
*
|
|
288
|
+
* Render the chosen one: it arrives as `view` on the block, and it is always
|
|
289
|
+
* one of these — a block written before the menu existed reads as the first.
|
|
290
|
+
*/
|
|
291
|
+
views?: View[];
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether this block puts a form in front of visitors and keeps what they
|
|
295
|
+
* type. `'enquiry'` is the only value, and it is what the platform's
|
|
296
|
+
* launch gate reads: a site that collects must have somewhere to send
|
|
297
|
+
* what it collects. Say it on the block that carries the form.
|
|
298
|
+
*/
|
|
299
|
+
collects?: 'enquiry';
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* What the form asks a visitor for, beyond what every enquiry has.
|
|
303
|
+
*
|
|
304
|
+
* ── the structure is the platform's, the questions are the site's ─────────
|
|
305
|
+
*
|
|
306
|
+
* Every enquiry has a name, a way back to the sender and a message; those
|
|
307
|
+
* controls are `name`, `contact` (or `phone` / `email`), `message`, and
|
|
308
|
+
* the honeypot `website`. Everything else the form asks — a wished date, a
|
|
309
|
+
* service, a number of guests — is the site's own, and is declared here,
|
|
310
|
+
* as fields, so the panel, the notification and the export can say what
|
|
311
|
+
* each value is instead of showing a key. `LeadForm.astro` renders these;
|
|
312
|
+
* a control the build finds on the form and nobody declared here fails a
|
|
313
|
+
* live build with `JTK_E_FORM_FIELD_UNDECLARED`.
|
|
314
|
+
*
|
|
315
|
+
* Kinds a visitor can type into: text, textarea, number, bool, select,
|
|
316
|
+
* tel, email, url, date, time_of_day. The structure's own keys are refused.
|
|
317
|
+
*/
|
|
318
|
+
asks?: Field[];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One arrangement, with the limits that belong to it. */
|
|
322
|
+
export interface View {
|
|
323
|
+
/** `[a-z][a-z0-9_]*`, and permanent: it is written into the content. */
|
|
324
|
+
key: string;
|
|
325
|
+
/** What the owner sees in the menu. Their language. */
|
|
326
|
+
label: Text;
|
|
327
|
+
/** How many pictures this arrangement holds. */
|
|
328
|
+
min?: number;
|
|
329
|
+
max?: number;
|
|
330
|
+
/** The frame they are cropped to — see `Field.ratio`. */
|
|
331
|
+
ratio?: string;
|
|
332
|
+
/**
|
|
333
|
+
* A page of this site where this arrangement can already be seen.
|
|
334
|
+
*
|
|
335
|
+
* The admin frames that page at the block and shows it in the menu, so that
|
|
336
|
+
* "One large beside two small" is a picture of the thing rather than a
|
|
337
|
+
* sentence somebody has to try. Costs the site nothing: no artwork to draw,
|
|
338
|
+
* nothing to keep in step with the CSS, and what the owner sees is their own
|
|
339
|
+
* site.
|
|
340
|
+
*/
|
|
341
|
+
sample?: string;
|
|
342
|
+
/**
|
|
343
|
+
* This one cannot be shown until it is built. Rare, and worth understanding.
|
|
344
|
+
*
|
|
345
|
+
* The admin's preview stands in for a block the build has not made yet by
|
|
346
|
+
* taking *this site's* markup from a page the build drew one on — so a
|
|
347
|
+
* gallery put into a post appears immediately, in this site's own
|
|
348
|
+
* arrangement, without a rebuild.
|
|
349
|
+
*
|
|
350
|
+
* It works for anything a browser draws from markup and CSS. It does not work
|
|
351
|
+
* where the arrangement is made by a script that reads the whole page: a copy
|
|
352
|
+
* put in after the page loaded is a copy that script never saw. Two of the
|
|
353
|
+
* three things that could go wrong are handled — a `fl:placed` event is fired
|
|
354
|
+
* on the new node so `onAlive` can start it, and a resize follows so anything
|
|
355
|
+
* that measured the page measures it again — but a script that cannot be
|
|
356
|
+
* asked for one subtree cannot be helped.
|
|
357
|
+
*
|
|
358
|
+
* You know which of yours those are. Say so here and the preview will not
|
|
359
|
+
* pretend; it will say the block appears with the next build.
|
|
360
|
+
*/
|
|
361
|
+
needs_build?: boolean;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* This site's blocks.
|
|
366
|
+
*
|
|
367
|
+
* ── three have a fixed job. Every other block on the page is yours ──────────
|
|
368
|
+
*
|
|
369
|
+
* The scaffold ships the three a landing page cannot do without, and they are
|
|
370
|
+
* required because each one is the page failing without it, not because a
|
|
371
|
+
* template says so:
|
|
372
|
+
*
|
|
373
|
+
* hero what this is, for whom, and one action. Without it a visitor
|
|
374
|
+
* arriving from a search does not know what they are looking at.
|
|
375
|
+
* cta_banner the form. A page that sells and cannot be answered is a leaflet.
|
|
376
|
+
* questions the questions block. It is what a hesitating visitor reads last
|
|
377
|
+
* and what an assistant lifts a paragraph out of — see
|
|
378
|
+
* `docs/pages.md` of @jtakeit/astro. It is the page's whole generative weight.
|
|
379
|
+
*
|
|
380
|
+
* **Everything between them is chosen, and its name is this business's own.**
|
|
381
|
+
* Not `features`, `about`, `benefits`: a tattooist's page had `plates`,
|
|
382
|
+
* `letter`, `conviction`, `process` and `artist`, and a page whose block names
|
|
383
|
+
* could be pasted into any other site is a page that will look like it.
|
|
384
|
+
*
|
|
385
|
+
* The type name is permanent once content exists under it, so it is worth the
|
|
386
|
+
* minute it takes to name it after what it *is* on this page.
|
|
387
|
+
*
|
|
388
|
+
* And a block is a job, not a band. An angle can be carried by a photograph
|
|
389
|
+
* with three words on it, one sentence set across the full width, or a
|
|
390
|
+
* quotation — the shape is `docs/shapes.md` of @jtakeit/astro, and
|
|
391
|
+
* nothing here asks for a heading, a lead and a grid of three.
|
|
392
|
+
*/
|
|
393
|
+
export const BLOCKS: Block[] = [
|
|
394
|
+
{
|
|
395
|
+
/*
|
|
396
|
+
* A run of prose inside a post, and the reason a post is a sequence.
|
|
397
|
+
*
|
|
398
|
+
* The writing used to be one markdown field, which meant everything else a
|
|
399
|
+
* post could hold came *after* it: a gallery between the fourth paragraph
|
|
400
|
+
* and the fifth could not be expressed at all. Putting one there is a
|
|
401
|
+
* sentence about the writing, so a post is a sequence now — prose, a
|
|
402
|
+
* gallery, more prose — and this is what the runs after the first are.
|
|
403
|
+
*
|
|
404
|
+
* The first run stays on the post itself (`post.body`), because that is
|
|
405
|
+
* what the feed and the search result quote.
|
|
406
|
+
*
|
|
407
|
+
* Prose only. A picture in a post is a block, chosen from the menu the
|
|
408
|
+
* blocks below declare, and not an `![]()` written into the text: two ways
|
|
409
|
+
* to put a photograph in a page is two sets of rules for how it may look,
|
|
410
|
+
* and only one of them is something this repository decided.
|
|
411
|
+
*
|
|
412
|
+
* Every collection that declares a `body` must name this type in it, or
|
|
413
|
+
* the writing cannot be broken by anything at all.
|
|
414
|
+
*/
|
|
415
|
+
type: 'text',
|
|
416
|
+
v: 1,
|
|
417
|
+
label: 'Text',
|
|
418
|
+
fields: [{ key: 'body', label: 'Text', kind: 'markdown', max: 40000, client_editable: true }],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
type: 'hero',
|
|
422
|
+
v: 1,
|
|
423
|
+
label: 'Opening screen',
|
|
424
|
+
fields: [
|
|
425
|
+
{ key: 'eyebrow', label: 'Line above the heading', kind: 'text', max: 60, client_editable: true },
|
|
426
|
+
{
|
|
427
|
+
key: 'title',
|
|
428
|
+
label: 'Heading',
|
|
429
|
+
kind: 'text',
|
|
430
|
+
required: true,
|
|
431
|
+
max: 80,
|
|
432
|
+
client_editable: true,
|
|
433
|
+
seo_sensitive: true,
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
key: 'lead',
|
|
437
|
+
label: 'Subheading',
|
|
438
|
+
kind: 'textarea',
|
|
439
|
+
max: 240,
|
|
440
|
+
client_editable: true,
|
|
441
|
+
seo_sensitive: true,
|
|
442
|
+
},
|
|
443
|
+
{ key: 'cta_label', label: 'Button text', kind: 'text', max: 40, client_editable: true },
|
|
444
|
+
{ key: 'cta_href', label: 'Button link', kind: 'url', no_tap_target: true },
|
|
445
|
+
{ key: 'image', label: 'Photograph', kind: 'media', client_editable: true },
|
|
446
|
+
{
|
|
447
|
+
key: 'image_alt',
|
|
448
|
+
label: 'Photograph description',
|
|
449
|
+
kind: 'text',
|
|
450
|
+
max: 120,
|
|
451
|
+
client_editable: true,
|
|
452
|
+
seo_sensitive: true,
|
|
453
|
+
no_tap_target: true,
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
type: 'cta_banner',
|
|
459
|
+
v: 1,
|
|
460
|
+
label: 'Call to action and form',
|
|
461
|
+
collects: 'enquiry',
|
|
462
|
+
/*
|
|
463
|
+
* One question of the site's own, as a starting point. The scaffold's
|
|
464
|
+
* form asks what the message is about; a salon asks for a service, a
|
|
465
|
+
* restaurant for a number of guests. Rewrite it for the business, or
|
|
466
|
+
* take it out — a field the owner never reads costs a visitor for nothing.
|
|
467
|
+
*/
|
|
468
|
+
asks: [{ key: 'topic', label: 'What it is about', kind: 'text', max: 120 }],
|
|
469
|
+
fields: [
|
|
470
|
+
{ key: 'title', label: 'Heading', kind: 'text', required: true, max: 80, client_editable: true },
|
|
471
|
+
{ key: 'lead', label: 'Subheading', kind: 'textarea', max: 240, client_editable: true },
|
|
472
|
+
{ key: 'cta_label', label: 'Button text', kind: 'text', max: 40, client_editable: true },
|
|
473
|
+
{ key: 'cta_href', label: 'Button link', kind: 'url', no_tap_target: true },
|
|
474
|
+
{ key: 'form', label: 'Show the form', kind: 'bool', no_tap_target: true },
|
|
475
|
+
|
|
476
|
+
/*
|
|
477
|
+
* What the form asks for, in the business's own words.
|
|
478
|
+
*
|
|
479
|
+
* These are not interface. "Розкажіть про ідею, де на тілі і приблизно
|
|
480
|
+
* який розмір" is a tattooist deciding what she needs to know before she
|
|
481
|
+
* can answer, and the next business needs something else entirely — a
|
|
482
|
+
* date, a number of guests, a car's registration. A visitor reads them
|
|
483
|
+
* and the owner is the only person who knows what they should say.
|
|
484
|
+
*
|
|
485
|
+
* What stays out of the catalogue is the machinery around them: "Sending…",
|
|
486
|
+
* "Sent.", the message a browser shows for an empty field. There is
|
|
487
|
+
* nothing an owner wants to say in those, and the server has its own copy
|
|
488
|
+
* of the refusals anyway.
|
|
489
|
+
*/
|
|
490
|
+
{ key: 'name_label', label: 'Label for the “name” field', kind: 'text', max: 60, client_editable: true },
|
|
491
|
+
{ key: 'contact_label', label: 'Label for the “how to reach you” field', kind: 'text', max: 90, client_editable: true },
|
|
492
|
+
{ key: 'message_label', label: 'Label for the “message” field', kind: 'text', max: 120, client_editable: true },
|
|
493
|
+
{ key: 'required_note', label: 'Line under the form', kind: 'text', max: 140, client_editable: true },
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
/*
|
|
498
|
+
* The questions, and the reason they are one of the three.
|
|
499
|
+
*
|
|
500
|
+
* Each answer has to stand up lifted out of the page, because that is
|
|
501
|
+
* exactly what happens to it: an assistant quotes one paragraph with no
|
|
502
|
+
* page around it. So an answer that begins "Так, звісно" answers nothing
|
|
503
|
+
* once it is somewhere else. `docs/pages.md` of @jtakeit/astro has what the block and its
|
|
504
|
+
* FAQPage markup must meet — and on a speculative build the markup waits
|
|
505
|
+
* until a human has confirmed the answers.
|
|
506
|
+
*/
|
|
507
|
+
type: 'questions',
|
|
508
|
+
v: 1,
|
|
509
|
+
label: 'Questions and answers',
|
|
510
|
+
hint: 'Each answer has to read on its own, away from the page.',
|
|
511
|
+
fields: [
|
|
512
|
+
{ key: 'title', label: 'Heading', kind: 'text', max: 80, client_editable: true },
|
|
513
|
+
{
|
|
514
|
+
key: 'items',
|
|
515
|
+
label: 'Questions',
|
|
516
|
+
kind: 'list',
|
|
517
|
+
max: 12,
|
|
518
|
+
of: [
|
|
519
|
+
{
|
|
520
|
+
key: 'question',
|
|
521
|
+
label: 'Question',
|
|
522
|
+
kind: 'text',
|
|
523
|
+
required: true,
|
|
524
|
+
max: 140,
|
|
525
|
+
client_editable: true,
|
|
526
|
+
seo_sensitive: true,
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
key: 'answer',
|
|
530
|
+
label: 'Answer',
|
|
531
|
+
kind: 'textarea',
|
|
532
|
+
required: true,
|
|
533
|
+
max: 600,
|
|
534
|
+
client_editable: true,
|
|
535
|
+
seo_sensitive: true,
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
},
|
|
541
|
+
];
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The sets of entries the owner may create.
|
|
545
|
+
*
|
|
546
|
+
* ── the one place the admin creates something the repository did not ────────
|
|
547
|
+
*
|
|
548
|
+
* Everywhere else, which pages a site has is this repository's to say: they
|
|
549
|
+
* arrive by import, and there is no "add a page" in the admin, because a page
|
|
550
|
+
* is a file somebody wrote. A collection moves exactly one thing across that
|
|
551
|
+
* line, and names it:
|
|
552
|
+
*
|
|
553
|
+
* this repository declares the shape a prefix, a block type, an order
|
|
554
|
+
* the admin creates the entries and nothing else
|
|
555
|
+
*
|
|
556
|
+
* So there is no "blog feature" on the platform. A blog is one collection whose
|
|
557
|
+
* prefix is `/blog` and whose entries are ordered by date; a portfolio, a price
|
|
558
|
+
* list, a menu or a vacancy board are the same mechanism with different words,
|
|
559
|
+
* and adding one is this array plus the two routes that render it.
|
|
560
|
+
*
|
|
561
|
+
* ── the two routes you owe a collection ─────────────────────────────────────
|
|
562
|
+
*
|
|
563
|
+
* src/pages/blog/index.astro the listing — an ordinary page of yours
|
|
564
|
+
* src/pages/blog/[...slug].astro one entry, from the content collection
|
|
565
|
+
*
|
|
566
|
+
* `fl-catalogue` checks the declaration; the build checks the rendering, the
|
|
567
|
+
* same way it checks every other annotated field.
|
|
568
|
+
*
|
|
569
|
+
* ── the reserved keys ───────────────────────────────────────────────────────
|
|
570
|
+
*
|
|
571
|
+
* An entry's block type may declare whatever this business needs, but five keys
|
|
572
|
+
* are spoken for, because the listing, the feed, the sitemap, the card a
|
|
573
|
+
* messenger draws and the admin's own list all read them by name:
|
|
574
|
+
*
|
|
575
|
+
* title text, and required. Everything above needs one.
|
|
576
|
+
* date date. Required if the collection is ordered by it.
|
|
577
|
+
* excerpt textarea. The sentence under the title in the listing.
|
|
578
|
+
* cover media, one picture. The card, the listing, og:image.
|
|
579
|
+
* body markdown. The entry itself.
|
|
580
|
+
*
|
|
581
|
+
* Leave any of them out where they make no sense — a set of works has no
|
|
582
|
+
* excerpt. What you may not do is give one of those names to something else.
|
|
583
|
+
*
|
|
584
|
+
* An entry is stored as a page whose document opens with one block of the
|
|
585
|
+
* declared type, so `blocks[0].body` is an ordinary annotation path and
|
|
586
|
+
* everything else — versions, drafts, publishing, media — already applies.
|
|
587
|
+
*
|
|
588
|
+
* ── only when it was asked for ──────────────────────────────────────────────
|
|
589
|
+
*
|
|
590
|
+
* **The scaffold declares none, and that is the right state for almost every
|
|
591
|
+
* site.** Do not add one because a site could have one.
|
|
592
|
+
*
|
|
593
|
+
* A collection is a standing weekly obligation on the person who owns the site.
|
|
594
|
+
* An empty blog on a live page says the business stopped caring in March, and
|
|
595
|
+
* three posts from a year ago say it louder; a page that never mentions one
|
|
596
|
+
* says nothing at all. The bar is a sentence in the brief that names the
|
|
597
|
+
* thing — not a vertical it would suit, not room in the design, not «for
|
|
598
|
+
* later». If the brief is silent, this array stays empty.
|
|
599
|
+
*
|
|
600
|
+
* A site that declares none shows no trace of them in the admin.
|
|
601
|
+
*/
|
|
602
|
+
export interface Collection {
|
|
603
|
+
/** `[a-z][a-z0-9_]*`, and permanent once entries exist under it. */
|
|
604
|
+
name: string;
|
|
605
|
+
/** What the owner sees in the admin's sidebar. Their language. */
|
|
606
|
+
label: Text;
|
|
607
|
+
/** Where entries live: `/blog` gives `/blog/aftercare`. No trailing slash. */
|
|
608
|
+
prefix: string;
|
|
609
|
+
/** The block type an entry's document opens with. */
|
|
610
|
+
type: string;
|
|
611
|
+
/**
|
|
612
|
+
* The mark beside this collection in the admin's sidebar. Name an icon of
|
|
613
|
+
* Lucide — `lucide:table-2`, `lucide:scissors`, any of lucide.dev/icons —
|
|
614
|
+
* or, for a mark nobody has drawn, path data on the admin's own 20×20
|
|
615
|
+
* grid: `path:M4 4.2h12 M4 8h12`. Path data only, never markup; the
|
|
616
|
+
* platform refuses anything that is not one of the two. Left out, the
|
|
617
|
+
* admin's generic list mark.
|
|
618
|
+
*/
|
|
619
|
+
icon?: string;
|
|
620
|
+
/**
|
|
621
|
+
* Which block types a post may hold besides its own.
|
|
622
|
+
*
|
|
623
|
+
* ── the one place an owner adds a block ─────────────────────────────────
|
|
624
|
+
*
|
|
625
|
+
* Which blocks a *page* has is this repository's to say, always. A post is
|
|
626
|
+
* the exception: it is **written**, not designed, and "a gallery here,
|
|
627
|
+
* between these two paragraphs" is a sentence about the writing. So the
|
|
628
|
+
* vocabulary is declared here and the arranging happens in the admin.
|
|
629
|
+
*
|
|
630
|
+
* A post is a *sequence*: its opening prose, then whatever is named here in
|
|
631
|
+
* the order the owner put it in — a gallery, more prose, another gallery.
|
|
632
|
+
* `text` must be in this list, or the writing cannot be broken by anything;
|
|
633
|
+
* everything else in it is this site's own vocabulary.
|
|
634
|
+
*
|
|
635
|
+
* Left out means a post is prose and nothing else, which is the right answer
|
|
636
|
+
* for most collections. Name a type and this repository has to render it in
|
|
637
|
+
* `[...entry].astro` — a type named here and not rendered there is a block
|
|
638
|
+
* the owner can add and nobody can see.
|
|
639
|
+
*/
|
|
640
|
+
body?: string[];
|
|
641
|
+
/**
|
|
642
|
+
* How the site lists them. Left out means arranged by hand, which is the
|
|
643
|
+
* honest default: a set of works has no natural order, and inventing one from
|
|
644
|
+
* a creation date would put the newest photograph first for ever with nobody
|
|
645
|
+
* having asked for that.
|
|
646
|
+
*
|
|
647
|
+
* `by` names a field of `type`, and it must be a date or a number, and it
|
|
648
|
+
* must be required — sorting by something an entry may not have puts those
|
|
649
|
+
* entries wherever the database felt like.
|
|
650
|
+
*/
|
|
651
|
+
order?: { by: string; desc?: boolean };
|
|
652
|
+
/** Paginate the listing. Absent means one page, however many there are. */
|
|
653
|
+
per_page?: number;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export const COLLECTIONS: Collection[] = [];
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The language this site is written in.
|
|
660
|
+
*
|
|
661
|
+
* `META.lang` in `src/copy/` is this value, and `<html lang>` is that — one
|
|
662
|
+
* declaration, read by the page and by the platform. Set by `fl-init`; change
|
|
663
|
+
* it only if the site itself changes language.
|
|
664
|
+
*
|
|
665
|
+
* It is here, beside the others, because **a site declares every language it
|
|
666
|
+
* has in one place.** It used to live only in the admin's own record of the
|
|
667
|
+
* site, set when the site was created and changeable by nobody, so a site
|
|
668
|
+
* written in English could carry a record saying Ukrainian — and every screen
|
|
669
|
+
* that named a language named the wrong one.
|
|
670
|
+
*/
|
|
671
|
+
/**
|
|
672
|
+
* Where the platform says what it does with what this site's form collects.
|
|
673
|
+
*
|
|
674
|
+
* ── why a link and not a paragraph ──────────────────────────────────────────
|
|
675
|
+
*
|
|
676
|
+
* This used to be a version number beside six paragraphs of our text, written
|
|
677
|
+
* into every site in every language it is built in. Two things were wrong with
|
|
678
|
+
* that. It had to be translated, by us, for every language anybody ever builds
|
|
679
|
+
* in — and it went stale: the day the platform changes what it keeps or for how
|
|
680
|
+
* long, every site in the world is carrying a sentence that is no longer true
|
|
681
|
+
* and has to be rebuilt to stop.
|
|
682
|
+
*
|
|
683
|
+
* A link cannot go stale. The text lives where the thing it describes lives,
|
|
684
|
+
* and there is nothing on this side to keep in step.
|
|
685
|
+
*
|
|
686
|
+
* It also makes the check real. Prose cannot be verified; an address can — the
|
|
687
|
+
* platform reads the built artifact and looks for this string, rather than
|
|
688
|
+
* believing a number the build reported about itself.
|
|
689
|
+
*
|
|
690
|
+
* ── what is asked, and what is not ──────────────────────────────────────────
|
|
691
|
+
*
|
|
692
|
+
* **One link on a page that opens.** Nothing about what else that page says,
|
|
693
|
+
* how it is worded, or which language it is in — that is the business's, and
|
|
694
|
+
* grading it is neither our job nor within our competence.
|
|
695
|
+
*
|
|
696
|
+
* And it is a warning rather than a refusal. A site without it goes live and
|
|
697
|
+
* somebody is told. The only thing publishing refuses over is a form with
|
|
698
|
+
* nowhere to send what people write in it, which is a broken product rather
|
|
699
|
+
* than an opinion about anybody's legal position. See wiki/34.
|
|
700
|
+
*/
|
|
701
|
+
export const PROCESSING_URL = 'https://jtakeit.com/processing';
|
|
702
|
+
|
|
703
|
+
export const LOCALE = '{{LANG}}';
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* The languages this site has **besides** the one above.
|
|
707
|
+
*
|
|
708
|
+
* Declared once, here, and everything derives from it: where an entry's address
|
|
709
|
+
* goes, which languages the admin offers, which translations it reports as
|
|
710
|
+
* missing. A fact repeated in three places is a fact that is eventually three
|
|
711
|
+
* different facts.
|
|
712
|
+
*
|
|
713
|
+
* Empty is almost every site. A second language is a second site to keep
|
|
714
|
+
* written — every page, every post, every footer, for ever — so it is a
|
|
715
|
+
* commitment somebody asked for, never a feature that seemed nice.
|
|
716
|
+
*
|
|
717
|
+
* The address pattern that follows is fixed, and so is everything after it:
|
|
718
|
+
*
|
|
719
|
+
* /prices /ru/prices
|
|
720
|
+
* /blog/healing /ru/blog/healing
|
|
721
|
+
*
|
|
722
|
+
* The whole address is the same in every language, with the language in front.
|
|
723
|
+
* That is what pairs the two — nothing is declared, so nothing can be misspelt,
|
|
724
|
+
* and `fl-check` can answer "are these the same page" by looking. The price is
|
|
725
|
+
* one segment of one address reading in the site's own language.
|
|
726
|
+
*/
|
|
727
|
+
export const LOCALES: string[] = [];
|
|
728
|
+
|
|
729
|
+
/** The page's own SEO. Every site has these two, whatever else it has. */
|
|
730
|
+
export const PAGE_SEO: Field[] = [
|
|
731
|
+
{
|
|
732
|
+
key: 'title',
|
|
733
|
+
label: 'Page title',
|
|
734
|
+
kind: 'text',
|
|
735
|
+
required: true,
|
|
736
|
+
max: 60,
|
|
737
|
+
client_editable: true,
|
|
738
|
+
seo_sensitive: true,
|
|
739
|
+
no_tap_target: true,
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
key: 'description',
|
|
743
|
+
label: 'Description for search results',
|
|
744
|
+
kind: 'textarea',
|
|
745
|
+
max: 160,
|
|
746
|
+
client_editable: true,
|
|
747
|
+
seo_sensitive: true,
|
|
748
|
+
no_tap_target: true,
|
|
749
|
+
},
|
|
750
|
+
];
|
|
751
|
+
|
|
752
|
+
/** The business's own facts, which the layout reads and the owner may correct. */
|
|
753
|
+
export const BUSINESS_FACTS: Field[] = [
|
|
754
|
+
{ key: 'name', label: 'Name', kind: 'text', max: 80, client_editable: true, no_tap_target: true },
|
|
755
|
+
{ key: 'city', label: 'City', kind: 'text', max: 60, client_editable: true, no_tap_target: true },
|
|
756
|
+
{ key: 'phone', label: 'Phone', kind: 'tel', client_editable: true, no_tap_target: true },
|
|
757
|
+
{ key: 'email', label: 'Email', kind: 'email', client_editable: true, no_tap_target: true },
|
|
758
|
+
];
|