@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
package/lib/codes.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names the platform gives to the things a catalogue can get wrong.
|
|
3
|
+
*
|
|
4
|
+
* ── this file is a copy, and that is not an accident ────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The set lives in the admin, in `backend/api/registry/errcode.go`, and that
|
|
7
|
+
* table is the source of truth: it is what `POST /v1/registry/validate` answers
|
|
8
|
+
* with, what the generated reference (`docs/reference/errors.md`) prints, and
|
|
9
|
+
* what the troubleshooting page is tested against. Nothing here is authoritative
|
|
10
|
+
* about anything.
|
|
11
|
+
*
|
|
12
|
+
* It is copied because `fl-catalogue` has to work before there is anything to
|
|
13
|
+
* ask — on a laptop, before a deploy, before a token, on a plane. A local
|
|
14
|
+
* checker that needs the network to name a fault is a local checker nobody runs.
|
|
15
|
+
*
|
|
16
|
+
* The alternative was generating this file from the Go table at build time, and
|
|
17
|
+
* it was rejected for what it would cost rather than what it would buy: a
|
|
18
|
+
* generator, a make target, a CI diff guard and a second repository in the
|
|
19
|
+
* checkout, all so that a list of twenty-one strings can be regenerated on the
|
|
20
|
+
* few days a year it changes.
|
|
21
|
+
*
|
|
22
|
+
* **What keeps it honest is `fl-catalogue --judge`, not anybody remembering.**
|
|
23
|
+
* That flag posts the emitted catalogue to the real validator and reports every
|
|
24
|
+
* place the two answers differ — a code the server raised and we did not, or one
|
|
25
|
+
* we raised and it did not. Every client the studio runs through it is one
|
|
26
|
+
* sitting of that exam (wiki/35 §5, §7), so a code that has been renamed or a
|
|
27
|
+
* check that has drifted is found by the next real project rather than by a
|
|
28
|
+
* reviewer noticing. A copy nobody checks rots in a month; a copy that is
|
|
29
|
+
* examined weekly is a cache.
|
|
30
|
+
*
|
|
31
|
+
* Only the catalogue's codes are here. The build's five — `JTK_E_UNANNOTATED_
|
|
32
|
+
* FIELD` and its siblings — belong to the annotation lint, which runs in the
|
|
33
|
+
* platform's build container and is judged there.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The codes, under the names the Go table gives them, so a reader with both
|
|
38
|
+
* files open can put them side by side.
|
|
39
|
+
*/
|
|
40
|
+
export const E = Object.freeze({
|
|
41
|
+
// The file itself, before anything in it is read.
|
|
42
|
+
UNREADABLE: 'JTK_E_CATALOGUE_UNREADABLE',
|
|
43
|
+
CONTRACT: 'JTK_E_CONTRACT_UNKNOWN',
|
|
44
|
+
EMPTY: 'JTK_E_CATALOGUE_EMPTY',
|
|
45
|
+
GENERATOR: 'JTK_E_GENERATOR_INVALID',
|
|
46
|
+
|
|
47
|
+
// Names: what a thing is called, and whether it is called that twice.
|
|
48
|
+
NAME: 'JTK_E_NAME_INVALID',
|
|
49
|
+
DUPLICATE: 'JTK_E_NAME_DUPLICATE',
|
|
50
|
+
|
|
51
|
+
// Words: the label and the hint an owner reads in the admin.
|
|
52
|
+
LABEL_MISSING: 'JTK_E_LABEL_MISSING',
|
|
53
|
+
TEXT: 'JTK_E_TEXT_INVALID',
|
|
54
|
+
|
|
55
|
+
// Blocks.
|
|
56
|
+
BLOCK_VERSION: 'JTK_E_BLOCK_VERSION',
|
|
57
|
+
BLOCK_EMPTY: 'JTK_E_BLOCK_EMPTY',
|
|
58
|
+
COLLECTS: 'JTK_E_COLLECTS_UNKNOWN',
|
|
59
|
+
|
|
60
|
+
// Fields.
|
|
61
|
+
KIND: 'JTK_E_KIND_UNKNOWN',
|
|
62
|
+
BOUNDS: 'JTK_E_BOUNDS_INVALID',
|
|
63
|
+
OPTIONS: 'JTK_E_SELECT_NO_OPTIONS',
|
|
64
|
+
LIST: 'JTK_E_LIST_INVALID',
|
|
65
|
+
MEDIA: 'JTK_E_MEDIA_INVALID',
|
|
66
|
+
|
|
67
|
+
// Arrangements.
|
|
68
|
+
VIEW: 'JTK_E_VIEW_INVALID',
|
|
69
|
+
|
|
70
|
+
// Languages.
|
|
71
|
+
LOCALE: 'JTK_E_LOCALE_INVALID',
|
|
72
|
+
|
|
73
|
+
// Collections.
|
|
74
|
+
COLLECTION: 'JTK_E_COLLECTION_INVALID',
|
|
75
|
+
ENTRY_TYPE: 'JTK_E_ENTRY_TYPE_INVALID',
|
|
76
|
+
ORDER: 'JTK_E_ORDER_INVALID',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The judge's second voice: advice, with `JTK_W_` codes. A finding here
|
|
81
|
+
* refuses nothing — the catalogue is valid — and comes back from
|
|
82
|
+
* `POST /v1/registry/validate` under `advice`, apart from the refusals. This
|
|
83
|
+
* checker raises none of them (the rules are the server's, about the bookings
|
|
84
|
+
* module it runs), so `--judge` prints them and leaves them out of the exam.
|
|
85
|
+
*/
|
|
86
|
+
export const W = Object.freeze({
|
|
87
|
+
MODULE_TWIN: 'JTK_W_MODULE_TWIN',
|
|
88
|
+
MODULE_POST: 'JTK_W_MODULE_POST',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const ADVICE = Object.freeze({
|
|
92
|
+
[W.MODULE_TWIN]:
|
|
93
|
+
'A page block types again a fact the bookings module holds — the owner\'s hours, the services\' prices — so the site has two copies and one goes stale.',
|
|
94
|
+
[W.MODULE_POST]:
|
|
95
|
+
'An entry type the bookings module reads is shaped as a post — a body, or excerpt, cover and body — or its services get a page each; a service is filled in, not written.',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** Advice from the judge, never a refusal. */
|
|
99
|
+
export function isAdvice(code) {
|
|
100
|
+
return typeof code === 'string' && code.startsWith('JTK_W_');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What each one means, in the platform's own words — the `Means` line of its row
|
|
105
|
+
* in `AllCodes`.
|
|
106
|
+
*
|
|
107
|
+
* Copied verbatim rather than paraphrased. A second wording of one rule is how
|
|
108
|
+
* two documents come to describe two rules, and the developer reading this one
|
|
109
|
+
* is going to be handed the other by the server a moment later.
|
|
110
|
+
*/
|
|
111
|
+
export const MEANS = Object.freeze({
|
|
112
|
+
[E.UNREADABLE]: 'The catalogue is not JSON, or carries a key this contract has no place for.',
|
|
113
|
+
[E.CONTRACT]: 'The catalogue names a contract number this service does not speak.',
|
|
114
|
+
[E.EMPTY]: 'The catalogue declares no blocks, so it describes no site.',
|
|
115
|
+
[E.GENERATOR]: 'The `generator` string does not name a tool.',
|
|
116
|
+
|
|
117
|
+
[E.NAME]: 'A block type, field key, view key or collection name is not spelled the way an identifier must be.',
|
|
118
|
+
[E.DUPLICATE]: 'Two things in the same place are called the same thing.',
|
|
119
|
+
|
|
120
|
+
[E.LABEL_MISSING]: 'A field, view or collection has no label.',
|
|
121
|
+
[E.TEXT]:
|
|
122
|
+
'A label or hint is written both once and per language, names a language that is not one, is empty, or runs too long.',
|
|
123
|
+
|
|
124
|
+
[E.BLOCK_VERSION]: 'A block type has no version, or a version below one.',
|
|
125
|
+
[E.BLOCK_EMPTY]: 'A block type declares no fields.',
|
|
126
|
+
[E.COLLECTS]: 'A block says it collects something nothing knows about.',
|
|
127
|
+
|
|
128
|
+
[E.KIND]: "A field's kind is not one of the thirteen.",
|
|
129
|
+
[E.BOUNDS]: 'A `min` or `max` is negative, or the minimum is above the maximum.',
|
|
130
|
+
[E.OPTIONS]: 'A `select` field offers nothing to select.',
|
|
131
|
+
[E.LIST]: 'A list nests another list, declares no item fields, or `of` appears on a field that is not a list.',
|
|
132
|
+
[E.MEDIA]:
|
|
133
|
+
"A media field's `accept`, `ratio` or `multiple` do not go together — or one of the three appears on a field that is not media.",
|
|
134
|
+
|
|
135
|
+
[E.VIEW]:
|
|
136
|
+
"A block declares arrangements but has no gallery to arrange, sets its bounds in two places, or a view's own bounds do not make sense.",
|
|
137
|
+
|
|
138
|
+
[E.LOCALE]: "A language is not a language, is declared twice, or the site's own language appears again among the others.",
|
|
139
|
+
|
|
140
|
+
[E.COLLECTION]: "A collection's prefix, pagination, entry type or body list is wrong.",
|
|
141
|
+
[E.ENTRY_TYPE]: 'The block type a collection names cannot be used for entries.',
|
|
142
|
+
[E.ORDER]: 'A collection is ordered by a field that will not order it.',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The mark on a finding that is this skill's own and will never come back from
|
|
147
|
+
* the server.
|
|
148
|
+
*
|
|
149
|
+
* ── why these may not have a JTK_E_ code ────────────────────────────────────
|
|
150
|
+
*
|
|
151
|
+
* A code is a promise the platform's documentation keeps: every one of them has
|
|
152
|
+
* a row in the generated reference and an anchor in the troubleshooting page,
|
|
153
|
+
* enforced by a test over there. Inventing a `JTK_E_…` for a rule the platform
|
|
154
|
+
* has never heard of would put a name in a developer's terminal that leads
|
|
155
|
+
* nowhere when they go and look it up.
|
|
156
|
+
*
|
|
157
|
+
* And these are genuinely not the catalogue's business. Whether every address in
|
|
158
|
+
* `src/` survives being served under `/p/<slug>/` is about the studio's preview
|
|
159
|
+
* — our deployment, our arrangement, nothing to do with the format a stranger's
|
|
160
|
+
* repository declares. The judge is right not to have an opinion on it.
|
|
161
|
+
*
|
|
162
|
+
* So they are marked, printed as plainly as anything else, and left out of the
|
|
163
|
+
* comparison in `--judge`: their absence from the server's answer is the correct
|
|
164
|
+
* answer rather than a gap.
|
|
165
|
+
*/
|
|
166
|
+
export const LOCAL = 'FL_LOCAL';
|
|
167
|
+
|
|
168
|
+
/** Whether a finding is one the server can be asked about. */
|
|
169
|
+
export function isPlatformCode(code) {
|
|
170
|
+
return Object.hasOwn(MEANS, code);
|
|
171
|
+
}
|
package/lib/create.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jtk create` — scaffold a site for jtakeit.
|
|
3
|
+
*
|
|
4
|
+
* Copies the template, fills in the tokens, and stops. It does not decide what
|
|
5
|
+
* the site looks like: the template is a working Astro project with the
|
|
6
|
+
* platform's contract in it — `jtk/catalogue.json` derived from
|
|
7
|
+
* `src/content/blocks.ts`, every editable field annotated with `data-jtk-path`,
|
|
8
|
+
* the lead form posting to `/api/lead`, the flags read as `import.meta.env` —
|
|
9
|
+
* and every component in it is yours to replace.
|
|
10
|
+
*
|
|
11
|
+
* npx @jtakeit/astro create <slug> --name "The Business" --locale de-CH
|
|
12
|
+
* npx @jtakeit/astro create <slug> --out ./sites/<slug> --schema-type HairSalon
|
|
13
|
+
*
|
|
14
|
+
* The slug becomes the directory and the package name; the site's address on
|
|
15
|
+
* the platform comes from `create_site`, not from here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const TEMPLATE = resolve(HERE, '..', 'template');
|
|
25
|
+
|
|
26
|
+
/** What the form and the privacy page say, in the site's own language. */
|
|
27
|
+
const UI = {
|
|
28
|
+
en: {
|
|
29
|
+
skipToContent: 'Skip to content',
|
|
30
|
+
required: 'Required',
|
|
31
|
+
privacy: 'We use what you send only to answer you.',
|
|
32
|
+
sending: 'Sending…',
|
|
33
|
+
success: 'Thank you — we will be in touch.',
|
|
34
|
+
error: 'That did not send. Please try again, or call us.',
|
|
35
|
+
invalidName: 'Please give a name we can call you by.',
|
|
36
|
+
invalidContact: 'Please leave a phone number or an email address.',
|
|
37
|
+
invalidMessage: 'Please write a few words about what you need.',
|
|
38
|
+
demoNote: 'This is a preview — the form does not send anything yet.',
|
|
39
|
+
privacyTitle: 'Privacy',
|
|
40
|
+
privacyIntro: 'This page says what happens to what you send through the form on this website. It is written to be read, not to be agreed to.',
|
|
41
|
+
privacyFormTitle: 'When you write to us',
|
|
42
|
+
privacyFormBody: 'We are given your name, the way you asked us to reach you — a telephone number, an email address, or both — and whatever you wrote. Nothing else: no location, nothing about the pages you looked at, and nothing that identifies you between visits.',
|
|
43
|
+
privacyWhoTitle: 'Who else sees it',
|
|
44
|
+
privacyWhoBody: 'This website runs on jtakeit, which stores what you send and passes it to us. What they do with it, and for how long, is set out here:',
|
|
45
|
+
privacyAskTitle: 'Asking us',
|
|
46
|
+
privacyAskBody: 'Write or ring, and we will tell you what we hold about you or delete it.',
|
|
47
|
+
privacyOwnTitle: 'About us',
|
|
48
|
+
},
|
|
49
|
+
uk: {
|
|
50
|
+
skipToContent: 'Перейти до вмісту',
|
|
51
|
+
required: 'Обовʼязково',
|
|
52
|
+
privacy: 'Ми використовуємо надіслане лише для того, щоб вам відповісти.',
|
|
53
|
+
sending: 'Надсилаємо…',
|
|
54
|
+
success: 'Дякуємо — ми звʼяжемося з вами.',
|
|
55
|
+
error: 'Не вдалося надіслати. Спробуйте ще раз або зателефонуйте нам.',
|
|
56
|
+
invalidName: 'Напишіть, будь ласка, як до вас звертатися.',
|
|
57
|
+
invalidContact: 'Залиште, будь ласка, телефон або пошту.',
|
|
58
|
+
invalidMessage: 'Напишіть, будь ласка, кілька слів про те, що потрібно.',
|
|
59
|
+
demoNote: 'Це попередній перегляд — форма поки нічого не надсилає.',
|
|
60
|
+
privacyTitle: 'Конфіденційність',
|
|
61
|
+
privacyIntro: 'Ця сторінка пояснює, що відбувається з тим, що ви надсилаєте через форму на цьому сайті. Вона написана, щоб її прочитали, а не щоб з нею погодилися.',
|
|
62
|
+
privacyFormTitle: 'Коли ви пишете нам',
|
|
63
|
+
privacyFormBody: 'Ми отримуємо ваше імʼя, спосіб звʼязку, який ви залишили — телефон, пошту або те й інше — і те, що ви написали. Більше нічого: ні місця, ні того, які сторінки ви дивилися, ні того, що впізнало б вас між візитами.',
|
|
64
|
+
privacyWhoTitle: 'Хто ще це бачить',
|
|
65
|
+
privacyWhoBody: 'Сайт працює на jtakeit: вони зберігають надіслане і передають його нам. Що саме вони з цим роблять і як довго зберігають — написано тут:',
|
|
66
|
+
privacyAskTitle: 'Запитати нас',
|
|
67
|
+
privacyAskBody: 'Напишіть або зателефонуйте — скажемо, що саме про вас зберігається, або видалимо.',
|
|
68
|
+
privacyOwnTitle: 'Про нас',
|
|
69
|
+
},
|
|
70
|
+
ru: {
|
|
71
|
+
skipToContent: 'Перейти к содержимому',
|
|
72
|
+
required: 'Обязательно',
|
|
73
|
+
privacy: 'Мы используем отправленное только для того, чтобы вам ответить.',
|
|
74
|
+
sending: 'Отправляем…',
|
|
75
|
+
success: 'Спасибо — мы свяжемся с вами.',
|
|
76
|
+
error: 'Не удалось отправить. Попробуйте ещё раз или позвоните нам.',
|
|
77
|
+
invalidName: 'Напишите, пожалуйста, как к вам обращаться.',
|
|
78
|
+
invalidContact: 'Оставьте, пожалуйста, телефон или почту.',
|
|
79
|
+
invalidMessage: 'Напишите, пожалуйста, несколько слов о том, что нужно.',
|
|
80
|
+
demoNote: 'Это предпросмотр — форма пока ничего не отправляет.',
|
|
81
|
+
privacyTitle: 'Конфиденциальность',
|
|
82
|
+
privacyIntro: 'Эта страница объясняет, что происходит с тем, что вы отправляете через форму на этом сайте. Она написана, чтобы её прочитали, а не чтобы с ней согласились.',
|
|
83
|
+
privacyFormTitle: 'Когда вы пишете нам',
|
|
84
|
+
privacyFormBody: 'Мы получаем ваше имя, оставленный вами способ связи — телефон, почту или и то и другое — и то, что вы написали. Больше ничего: ни места, ни того, какие страницы вы смотрели, ни того, что опознало бы вас между визитами.',
|
|
85
|
+
privacyWhoTitle: 'Кто ещё это видит',
|
|
86
|
+
privacyWhoBody: 'Сайт работает на jtakeit: они хранят отправленное и передают его нам. Что именно они с этим делают и сколько хранят — написано здесь:',
|
|
87
|
+
privacyAskTitle: 'Спросить нас',
|
|
88
|
+
privacyAskBody: 'Напишите или позвоните — скажем, что именно о вас хранится, или удалим.',
|
|
89
|
+
privacyOwnTitle: 'О нас',
|
|
90
|
+
},
|
|
91
|
+
de: {
|
|
92
|
+
skipToContent: 'Zum Inhalt springen',
|
|
93
|
+
required: 'Pflichtfeld',
|
|
94
|
+
privacy: 'Wir verwenden Ihre Angaben nur, um Ihnen zu antworten.',
|
|
95
|
+
sending: 'Wird gesendet…',
|
|
96
|
+
success: 'Danke — wir melden uns bei Ihnen.',
|
|
97
|
+
error: 'Das Senden hat nicht geklappt. Bitte versuchen Sie es erneut oder rufen Sie uns an.',
|
|
98
|
+
invalidName: 'Bitte geben Sie an, wie wir Sie ansprechen dürfen.',
|
|
99
|
+
invalidContact: 'Bitte hinterlassen Sie eine Telefonnummer oder eine E-Mail-Adresse.',
|
|
100
|
+
invalidMessage: 'Bitte schreiben Sie ein paar Worte dazu, worum es geht.',
|
|
101
|
+
demoNote: 'Dies ist eine Vorschau — das Formular sendet noch nichts.',
|
|
102
|
+
privacyTitle: 'Datenschutz',
|
|
103
|
+
privacyIntro: 'Diese Seite erklärt, was mit dem geschieht, was Sie über das Formular auf dieser Website senden. Sie ist zum Lesen geschrieben, nicht zum Zustimmen.',
|
|
104
|
+
privacyFormTitle: 'Wenn Sie uns schreiben',
|
|
105
|
+
privacyFormBody: 'Wir erhalten Ihren Namen, den von Ihnen angegebenen Weg zu Ihnen — Telefonnummer, E-Mail-Adresse oder beides — und was Sie geschrieben haben. Sonst nichts: kein Ort, nichts über die Seiten, die Sie angesehen haben, und nichts, was Sie zwischen Besuchen wiedererkennt.',
|
|
106
|
+
privacyWhoTitle: 'Wer es sonst sieht',
|
|
107
|
+
privacyWhoBody: 'Diese Website läuft auf jtakeit: dort wird gespeichert, was Sie senden, und an uns weitergegeben. Was dort damit geschieht und wie lange, steht hier:',
|
|
108
|
+
privacyAskTitle: 'Fragen Sie uns',
|
|
109
|
+
privacyAskBody: 'Schreiben oder rufen Sie an — wir sagen Ihnen, was wir über Sie haben, oder löschen es.',
|
|
110
|
+
privacyOwnTitle: 'Über uns',
|
|
111
|
+
},
|
|
112
|
+
pl: {
|
|
113
|
+
skipToContent: 'Przejdź do treści',
|
|
114
|
+
required: 'Wymagane',
|
|
115
|
+
privacy: 'Podane dane wykorzystujemy wyłącznie po to, aby Ci odpowiedzieć.',
|
|
116
|
+
sending: 'Wysyłanie…',
|
|
117
|
+
success: 'Dziękujemy — odezwiemy się.',
|
|
118
|
+
error: 'Nie udało się wysłać. Spróbuj ponownie lub zadzwoń do nas.',
|
|
119
|
+
invalidName: 'Napisz, jak możemy się do Ciebie zwracać.',
|
|
120
|
+
invalidContact: 'Zostaw numer telefonu lub adres e-mail.',
|
|
121
|
+
invalidMessage: 'Napisz kilka słów o tym, czego potrzebujesz.',
|
|
122
|
+
demoNote: 'To podgląd — formularz jeszcze nic nie wysyła.',
|
|
123
|
+
privacyTitle: 'Prywatność',
|
|
124
|
+
privacyIntro: 'Ta strona wyjaśnia, co dzieje się z tym, co wysyłasz przez formularz na tej stronie. Została napisana po to, by ją przeczytać, a nie po to, by się na nią zgodzić.',
|
|
125
|
+
privacyFormTitle: 'Kiedy do nas piszesz',
|
|
126
|
+
privacyFormBody: 'Otrzymujemy Twoje imię, pozostawiony sposób kontaktu — numer telefonu, adres e-mail albo oba — i to, co napisałeś. Nic więcej: ani miejsca, ani tego, jakie strony oglądałeś, ani niczego, co rozpoznałoby Cię między wizytami.',
|
|
127
|
+
privacyWhoTitle: 'Kto jeszcze to widzi',
|
|
128
|
+
privacyWhoBody: 'Ta strona działa na jtakeit: tam przechowywane jest to, co wysyłasz, i przekazywane nam. Co się z tym dzieje i jak długo, opisano tutaj:',
|
|
129
|
+
privacyAskTitle: 'Zapytaj nas',
|
|
130
|
+
privacyAskBody: 'Napisz lub zadzwoń — powiemy, co o Tobie mamy, albo to usuniemy.',
|
|
131
|
+
privacyOwnTitle: 'O nas',
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const LOCALE_MAP = {
|
|
136
|
+
uk: { lang: 'uk', og: 'uk_UA', region: 'UA', ui: 'uk' },
|
|
137
|
+
ru: { lang: 'ru', og: 'ru_RU', region: 'UA', ui: 'ru' },
|
|
138
|
+
en: { lang: 'en', og: 'en_GB', region: 'GB', ui: 'en' },
|
|
139
|
+
de: { lang: 'de', og: 'de_DE', region: 'DE', ui: 'de' },
|
|
140
|
+
'de-CH': { lang: 'de-CH', og: 'de_CH', region: 'CH', ui: 'de' },
|
|
141
|
+
'de-AT': { lang: 'de-AT', og: 'de_AT', region: 'AT', ui: 'de' },
|
|
142
|
+
pl: { lang: 'pl', og: 'pl_PL', region: 'PL', ui: 'pl' },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const BINARY = /\.(png|jpe?g|webp|avif|gif|ico|woff2?|pdf|zip)$/i;
|
|
146
|
+
|
|
147
|
+
export function usage() {
|
|
148
|
+
return `jtk create <slug> [--name "The Business"] [--locale de-CH] [--vertical salon]
|
|
149
|
+
[--schema-type LocalBusiness] [--out <dir>] [--no-git] [--force] [--dry-run]
|
|
150
|
+
|
|
151
|
+
slug lowercase letters, digits and dashes — the directory, and the name in package.json
|
|
152
|
+
--locale uk, ru, en, de, de-CH, de-AT, pl; anything else falls back to English words
|
|
153
|
+
--out where to write; the slug under the current directory by default
|
|
154
|
+
--no-git do not run git init and the first commit`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function create(argv) {
|
|
158
|
+
const has = (name) => argv.includes(`--${name}`);
|
|
159
|
+
const flag = (name, fallback) => {
|
|
160
|
+
const i = argv.indexOf(`--${name}`);
|
|
161
|
+
return i === -1 ? fallback : argv[i + 1];
|
|
162
|
+
};
|
|
163
|
+
const die = (message) => {
|
|
164
|
+
console.error(`jtk create: ${message}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const slug = argv.find((a) => !a.startsWith('--') && argv[argv.indexOf(a) - 1]?.startsWith('--') !== true);
|
|
169
|
+
if (!slug) die(`needs a slug\n\n${usage()}`);
|
|
170
|
+
if (!/^[a-z0-9][a-z0-9-]{2,39}$/.test(slug)) {
|
|
171
|
+
die(`slug "${slug}" must be 3–40 characters of a–z, 0–9 and dashes — the platform's create_site takes the same shape`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const name = flag('name', slug);
|
|
175
|
+
const locale = flag('locale', 'en');
|
|
176
|
+
const vertical = flag('vertical', '');
|
|
177
|
+
const target = resolve(flag('out', join(process.cwd(), slug)));
|
|
178
|
+
const dryRun = has('dry-run');
|
|
179
|
+
|
|
180
|
+
if (existsSync(target) && readdirSync(target).filter((f) => f !== '.git').length && !has('force')) {
|
|
181
|
+
die(`${target} is not empty. Pass --force to scaffold into it anyway.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const loc = LOCALE_MAP[locale] ?? { lang: locale, og: locale.replace('-', '_'), region: 'GB', ui: 'en' };
|
|
185
|
+
const ui = UI[loc.ui] ?? UI.en;
|
|
186
|
+
const tokens = {
|
|
187
|
+
SLUG: slug,
|
|
188
|
+
NAME: name,
|
|
189
|
+
LOCALE: locale,
|
|
190
|
+
LANG: loc.lang,
|
|
191
|
+
OG_LOCALE: loc.og,
|
|
192
|
+
PHONE_REGION: loc.region,
|
|
193
|
+
VERTICAL: vertical,
|
|
194
|
+
// Where `astro dev` says it is until the platform gives the site an
|
|
195
|
+
// address: every build the platform makes passes SITE_URL.
|
|
196
|
+
PREVIEW_URL: 'http://localhost:4321/',
|
|
197
|
+
SCHEMA_TYPE: flag('schema-type', 'LocalBusiness'),
|
|
198
|
+
UI_SKIP_TO_CONTENT: ui.skipToContent,
|
|
199
|
+
UI_REQUIRED: ui.required,
|
|
200
|
+
UI_PRIVACY: ui.privacy,
|
|
201
|
+
UI_SENDING: ui.sending,
|
|
202
|
+
UI_SUCCESS: ui.success,
|
|
203
|
+
UI_ERROR: ui.error,
|
|
204
|
+
UI_INVALID_NAME: ui.invalidName,
|
|
205
|
+
UI_INVALID_CONTACT: ui.invalidContact,
|
|
206
|
+
UI_INVALID_MESSAGE: ui.invalidMessage,
|
|
207
|
+
UI_DEMO_NOTE: ui.demoNote,
|
|
208
|
+
UI_PRIVACY_TITLE: ui.privacyTitle,
|
|
209
|
+
UI_PRIVACY_INTRO: ui.privacyIntro,
|
|
210
|
+
UI_PRIVACY_FORM_TITLE: ui.privacyFormTitle,
|
|
211
|
+
UI_PRIVACY_FORM_BODY: ui.privacyFormBody,
|
|
212
|
+
UI_PRIVACY_WHO_TITLE: ui.privacyWhoTitle,
|
|
213
|
+
UI_PRIVACY_WHO_BODY: ui.privacyWhoBody,
|
|
214
|
+
UI_PRIVACY_ASK_TITLE: ui.privacyAskTitle,
|
|
215
|
+
UI_PRIVACY_ASK_BODY: ui.privacyAskBody,
|
|
216
|
+
UI_PRIVACY_OWN_TITLE: ui.privacyOwnTitle,
|
|
217
|
+
YEAR: String(new Date().getFullYear()),
|
|
218
|
+
};
|
|
219
|
+
const substitute = (text) => text.replace(/\{\{([A-Z_]+)\}\}/g, (m, t) => (t in tokens ? tokens[t] : m));
|
|
220
|
+
|
|
221
|
+
const written = [];
|
|
222
|
+
function copyTree(from, to) {
|
|
223
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
224
|
+
const src = join(from, entry.name);
|
|
225
|
+
const outName = entry.name === 'gitignore' ? '.gitignore' : entry.name;
|
|
226
|
+
const dest = join(to, outName);
|
|
227
|
+
if (entry.isDirectory()) {
|
|
228
|
+
if (!dryRun) mkdirSync(dest, { recursive: true });
|
|
229
|
+
copyTree(src, dest);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (BINARY.test(entry.name)) {
|
|
233
|
+
if (!dryRun) cpSync(src, dest);
|
|
234
|
+
} else if (!dryRun) {
|
|
235
|
+
writeFileSync(dest, substitute(readFileSync(src, 'utf8')), 'utf8');
|
|
236
|
+
}
|
|
237
|
+
written.push(dest);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!dryRun) mkdirSync(target, { recursive: true });
|
|
242
|
+
copyTree(TEMPLATE, target);
|
|
243
|
+
|
|
244
|
+
// The copy module is named for the site's language.
|
|
245
|
+
const copyFrom = join(target, 'src', 'copy', 'LOCALE.ts');
|
|
246
|
+
const copyTo = join(target, 'src', 'copy', `${locale}.ts`);
|
|
247
|
+
if (!dryRun && existsSync(copyFrom)) renameSync(copyFrom, copyTo);
|
|
248
|
+
|
|
249
|
+
if (!dryRun) {
|
|
250
|
+
writeFileSync(
|
|
251
|
+
join(target, 'README.md'),
|
|
252
|
+
`# ${name}\n\nA site on jtakeit, scaffolded by @jtakeit/astro.\n\n` +
|
|
253
|
+
'```bash\nnpm ci # the lockfile is pinned; npm install would drift it\nnpm run dev\nnpm run check\nnpx @jtakeit/astro catalogue # write jtk/catalogue.json and check it against the build\n```\n\n' +
|
|
254
|
+
'What the platform expects of this repository is in `node_modules/@jtakeit/astro/docs/` once\n' +
|
|
255
|
+
'the package is installed, and at https://github.com/jtakeit/astro.\n',
|
|
256
|
+
'utf8',
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (dryRun) {
|
|
261
|
+
console.log(`Would write ${written.length} files into ${target}:`);
|
|
262
|
+
for (const f of written) console.log(` ${relative(target, f)}`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!has('no-git')) {
|
|
267
|
+
const git = (...args) => execFileSync('git', args, { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
268
|
+
if (!existsSync(join(target, '.git'))) git('init', '-q', '-b', 'main');
|
|
269
|
+
git('add', '-A');
|
|
270
|
+
try {
|
|
271
|
+
git('commit', '-q', '-m', `scaffold: ${name} (@jtakeit/astro)`);
|
|
272
|
+
} catch {
|
|
273
|
+
// Nothing to commit, or no identity configured: the files are there either way.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(`${written.length} files written into ${relative(process.cwd(), target) || '.'}`);
|
|
278
|
+
console.log('\nNext:');
|
|
279
|
+
console.log(` cd ${relative(process.cwd(), target) || '.'} && npm ci && npm run dev`);
|
|
280
|
+
console.log(' edit src/content/blocks.ts, then: npx @jtakeit/astro catalogue');
|
|
281
|
+
console.log(' push the repository, then create_site and attach_repo on the platform');
|
|
282
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jtakeit/astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Astro scaffold and the catalogue tool for a site on jtakeit — the platform's contract, ready to build on",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "jtk": "bin/jtk.mjs" },
|
|
8
|
+
"files": ["bin", "lib", "template", "docs", "README.md", "LICENSE"],
|
|
9
|
+
"engines": { "node": ">=22.12.0" },
|
|
10
|
+
"repository": { "type": "git", "url": "https://github.com/jtakeit/astro.git" },
|
|
11
|
+
"homepage": "https://github.com/jtakeit/astro#readme",
|
|
12
|
+
"keywords": ["jtakeit", "astro", "scaffold", "catalogue", "agent"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node test/smoke.mjs"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { env } from 'node:process';
|
|
3
|
+
import { defineConfig } from 'astro/config';
|
|
4
|
+
import { jtakeitMeta } from './jtakeit-meta.mjs';
|
|
5
|
+
import { satteri } from '@astrojs/markdown-satteri';
|
|
6
|
+
import { IMAGE, HAST_PLUGINS } from './markdown.mjs';
|
|
7
|
+
|
|
8
|
+
// The host this build claims as its own: it is what canonical links, the
|
|
9
|
+
// sitemap and the OG tags are written against. `src/data/site.ts` reads it back
|
|
10
|
+
// out of `import.meta.env.SITE`, so it is written down exactly once.
|
|
11
|
+
//
|
|
12
|
+
// SITE_URL is what the platform gives every build: the site's address. There
|
|
13
|
+
// is one kind of build, and the preview shows it under the slug.
|
|
14
|
+
const SITE = env.SITE_URL ?? '{{PREVIEW_URL}}';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The path this build is served under, taken from SITE_URL rather than
|
|
18
|
+
* configured beside it.
|
|
19
|
+
*
|
|
20
|
+
* **Without this the preview is a bare document, and it took three separate
|
|
21
|
+
* afternoons to see why.** A site's own address is a host — assets come out as
|
|
22
|
+
* `/_astro/style.css` and resolve — but the studio's preview serves the same
|
|
23
|
+
* build under `https://preview…/p/<slug>/`, where that path is not the site's
|
|
24
|
+
* root and every stylesheet, every script and every image asks for something
|
|
25
|
+
* that is not there. The page renders with no CSS at all, which reads as a
|
|
26
|
+
* broken build rather than as a wrong prefix.
|
|
27
|
+
*
|
|
28
|
+
* Deriving it from SITE_URL is what makes it impossible to set one and forget
|
|
29
|
+
* the other: they are one decision, and there is one place it is written.
|
|
30
|
+
* A build for a real host gets `/` and nothing changes.
|
|
31
|
+
*/
|
|
32
|
+
const BASE = new URL(SITE).pathname;
|
|
33
|
+
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
site: SITE,
|
|
36
|
+
base: BASE,
|
|
37
|
+
|
|
38
|
+
// Static output. There is no server-side part: the lead form posts to
|
|
39
|
+
// /api/lead, which the platform serves beside the site.
|
|
40
|
+
output: 'static',
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Responsive images by default — which is a decision about *bodies*, not
|
|
44
|
+
* about components.
|
|
45
|
+
*
|
|
46
|
+
* A picture inside an entry of a collection is markdown: ``,
|
|
47
|
+
* with no props to pass and nowhere to pass them. Without a layout Astro
|
|
48
|
+
* emits one optimised file and an empty `srcset`, so a phone downloads the
|
|
49
|
+
* sixteen-hundred-pixel version of every photograph in a blog post. With
|
|
50
|
+
* `constrained` the same markdown produces the full set — 640 through 1600 —
|
|
51
|
+
* and `sizes` to go with it.
|
|
52
|
+
*
|
|
53
|
+
* Components that already choose their own widths opt out with
|
|
54
|
+
* `layout="none"` and are unchanged; `Shot.astro` does.
|
|
55
|
+
*/
|
|
56
|
+
image: IMAGE,
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A picture in a body is a figure, and pictures side by side are a row.
|
|
60
|
+
*
|
|
61
|
+
* The whole of what a post's pictures can look like, and it adds no syntax:
|
|
62
|
+
* one image alone in a paragraph is a figure, several in *one* paragraph are
|
|
63
|
+
* a row, and the caption is markdown's own title slot. See figures.mjs — the
|
|
64
|
+
* argument for why this is not a markdown dialect is worth reading before
|
|
65
|
+
* anybody adds one.
|
|
66
|
+
*
|
|
67
|
+
* `hastPlugins` belongs to the processor, not to Astro. Written as
|
|
68
|
+
* `markdown: { hastPlugins }` it was accepted and ignored — Astro 7 has no
|
|
69
|
+
* such option — so `figures()` applied to bodies the loader rendered and to
|
|
70
|
+
* nothing Astro rendered itself. Two pipelines, one of them silently plain,
|
|
71
|
+
* and no error anywhere to say so.
|
|
72
|
+
*/
|
|
73
|
+
markdown: { processor: satteri({ hastPlugins: HAST_PLUGINS }) },
|
|
74
|
+
|
|
75
|
+
// Writes dist/_meta.json: the redirects and the unfinished entries, which
|
|
76
|
+
// are the two things the studio's edge cannot read off a page.
|
|
77
|
+
integrations: [jtakeitMeta()],
|
|
78
|
+
|
|
79
|
+
build: {
|
|
80
|
+
// One canonical URL per page, with the trailing slash a redirect map and a
|
|
81
|
+
// sitemap both expect.
|
|
82
|
+
format: 'directory',
|
|
83
|
+
},
|
|
84
|
+
});
|