@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,168 @@
|
|
|
1
|
+
import type { ImageMetadata } from 'astro';
|
|
2
|
+
import { isUpload, uploaded } from './uploads';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The site's photographs.
|
|
6
|
+
*
|
|
7
|
+
* The file name in src/assets is the slot: replacing a photo is dropping a file
|
|
8
|
+
* with the same name and rebuilding — no code change, no page edit.
|
|
9
|
+
*
|
|
10
|
+
* Every slot is registered here with alt text in the site's language, because a
|
|
11
|
+
* photo with no alt text is a photo a screen reader announces as its file name,
|
|
12
|
+
* and these sites are mostly photographs.
|
|
13
|
+
*
|
|
14
|
+
* **A missing file is never a build error.** `photo()` returns undefined and
|
|
15
|
+
* `<Shot>` renders a labelled placeholder that keeps the frame's proportions.
|
|
16
|
+
* That is what lets stage 1 proceed while the client is still finding their
|
|
17
|
+
* photographs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface Photo {
|
|
21
|
+
/** File base name in src/assets, without the extension. */
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly alt: string;
|
|
24
|
+
/** Portrait-shaped files can be given a taller cell by a gallery. */
|
|
25
|
+
readonly tall?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* This is the person the business *is* — their face, not their hands, their
|
|
28
|
+
* room or their work.
|
|
29
|
+
*
|
|
30
|
+
* It is registered because it changes what may be done to the picture. A
|
|
31
|
+
* treatment that replaces skin colour — `duotone`, `press` — makes a face
|
|
32
|
+
* look like stock art, and on a one-person business that face is the thing
|
|
33
|
+
* being chosen. `<Shot>` refuses to apply one to a photo marked here, and
|
|
34
|
+
* fails the build rather than shipping it. The gentle treatments still work.
|
|
35
|
+
*/
|
|
36
|
+
readonly person?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What may be done to a photograph, beyond framing it.
|
|
41
|
+
*
|
|
42
|
+
* Client photographs arrive as a feed: twenty pictures in twenty lights, shot
|
|
43
|
+
* on three phones over two years, with white balance disagreeing between every
|
|
44
|
+
* pair. Cropping them well leaves them still disagreeing, and a page of
|
|
45
|
+
* photographs that disagree reads as a page nobody art-directed — which is
|
|
46
|
+
* exactly what it is.
|
|
47
|
+
*
|
|
48
|
+
* A treatment is what makes them one set. It is CSS over the picture, in the
|
|
49
|
+
* variant's own tokens: no build step, no second copy of any file, nothing to
|
|
50
|
+
* re-run when a photo is replaced, and it degrades to the untouched photograph
|
|
51
|
+
* anywhere the blend modes do not land.
|
|
52
|
+
*
|
|
53
|
+
* The table of what each one is for is in `docs/photos.md` of @jtakeit/astro. Two rules travel with them: **one treatment per site**,
|
|
54
|
+
* because two is the look of a demo rather than a design, and **never a strong
|
|
55
|
+
* one on their face**.
|
|
56
|
+
*/
|
|
57
|
+
export type Treatment = 'none' | 'grade' | 'duotone' | 'film' | 'press' | 'recede';
|
|
58
|
+
|
|
59
|
+
export const TREATMENTS: readonly Treatment[] = [
|
|
60
|
+
'none',
|
|
61
|
+
'grade',
|
|
62
|
+
'duotone',
|
|
63
|
+
'film',
|
|
64
|
+
'press',
|
|
65
|
+
'recede',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Treatments that replace skin colour, and so may not touch a person. */
|
|
69
|
+
export const STRONG: readonly Treatment[] = ['duotone', 'press'];
|
|
70
|
+
|
|
71
|
+
export const PHOTOS: readonly Photo[] = [
|
|
72
|
+
// { name: 'hero', alt: '' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Resolved by Vite at build time: files that do not exist simply never appear.
|
|
76
|
+
const modules = import.meta.glob<{ default: ImageMetadata }>(
|
|
77
|
+
'/src/assets/*.{jpg,jpeg,JPG,JPEG,png,PNG,webp,WEBP,avif,AVIF}',
|
|
78
|
+
{ eager: true },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const byName = new Map<string, ImageMetadata>();
|
|
82
|
+
const metaByName = new Map<string, Photo>();
|
|
83
|
+
|
|
84
|
+
for (const [path, mod] of Object.entries(modules)) {
|
|
85
|
+
const base = path
|
|
86
|
+
.split('/')
|
|
87
|
+
.pop()!
|
|
88
|
+
.replace(/\.[^.]+$/, '')
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
// First file wins, so hero.jpg and hero.webp cannot fight over one slot.
|
|
91
|
+
if (!byName.has(base)) byName.set(base, mod.default);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const p of PHOTOS) metaByName.set(p.name, p);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The picture behind a name.
|
|
98
|
+
*
|
|
99
|
+
* A name is one of two things and this resolves both: a slot in `src/assets`,
|
|
100
|
+
* which is the developer's, or a key like `media/<site>/<hash>.jpg`, which is
|
|
101
|
+
* the owner's and was downloaded into `src/assets/media/` before the build. A
|
|
102
|
+
* component never has to know which it was given — that is the whole point,
|
|
103
|
+
* because a photograph the owner replaced in the admin has to land in exactly
|
|
104
|
+
* the frame the developer's one was in.
|
|
105
|
+
*/
|
|
106
|
+
export function photo(name: string): ImageMetadata | undefined {
|
|
107
|
+
if (isUpload(name)) return uploaded(name);
|
|
108
|
+
return byName.get(name.toLowerCase());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What the photograph actually is, before a frame is chosen for it. */
|
|
112
|
+
export interface Shape {
|
|
113
|
+
readonly width: number;
|
|
114
|
+
readonly height: number;
|
|
115
|
+
/** width / height. 0.8 for a 4:5 phone portrait, 1.78 for 16:9. */
|
|
116
|
+
readonly ratio: number;
|
|
117
|
+
readonly orientation: 'portrait' | 'landscape' | 'square';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Measure before placing.
|
|
122
|
+
*
|
|
123
|
+
* A frame is a decision about which part of a photograph nobody will ever see,
|
|
124
|
+
* and made by feel it is made wrong: client photos arrive in every orientation,
|
|
125
|
+
* and the 4:5 portrait that carries the whole business loses more than half of
|
|
126
|
+
* itself to a 16:9 band. A page that asks this first can pick the frame from
|
|
127
|
+
* the picture instead of cropping the picture to the frame.
|
|
128
|
+
*
|
|
129
|
+
* const owner = shape('owner');
|
|
130
|
+
* <Shot name="owner" ratio={owner?.orientation === 'portrait' ? '4 / 5' : '3 / 2'} />
|
|
131
|
+
*/
|
|
132
|
+
export function shape(name: string): Shape | undefined {
|
|
133
|
+
const src = photo(name);
|
|
134
|
+
if (!src) return undefined;
|
|
135
|
+
const ratio = src.width / src.height;
|
|
136
|
+
return {
|
|
137
|
+
width: src.width,
|
|
138
|
+
height: src.height,
|
|
139
|
+
ratio,
|
|
140
|
+
// A little tolerance: a phone crop is rarely exactly square.
|
|
141
|
+
orientation: ratio > 1.05 ? 'landscape' : ratio < 0.95 ? 'portrait' : 'square',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether this slot holds the face of the person the business is.
|
|
147
|
+
*
|
|
148
|
+
* Unregistered means no — a photo nobody described is not asserted to be
|
|
149
|
+
* anybody. The check is deliberately one-way: it can stop a treatment reaching
|
|
150
|
+
* a face somebody registered, and it cannot know about one nobody did.
|
|
151
|
+
*/
|
|
152
|
+
export function isPerson(name: string): boolean {
|
|
153
|
+
return metaByName.get(name)?.person === true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether this picture came from the admin rather than from the repository.
|
|
158
|
+
*
|
|
159
|
+
* Worth asking in one place: a treatment is a decision about the site's own
|
|
160
|
+
* photographs, and an owner who swapped one in has not agreed to have it
|
|
161
|
+
* duotoned. Nothing enforces that — it is here for a component that wants to.
|
|
162
|
+
*/
|
|
163
|
+
export { isUpload } from './uploads';
|
|
164
|
+
|
|
165
|
+
/** Registered alt text, or a neutral fallback so alt is never empty. */
|
|
166
|
+
export function altFor(name: string): string {
|
|
167
|
+
return metaByName.get(name)?.alt ?? '';
|
|
168
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this build is served from, and how to write an address inside it.
|
|
3
|
+
*
|
|
4
|
+
* ── the bug this exists to make impossible ──────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A site is served two ways and the difference is a path. On its own host it
|
|
7
|
+
* is at the root, so `/preise/` and `/favicon.png` mean what they say. In the
|
|
8
|
+
* studio's preview the same build is served under `https://preview…/p/<slug>/`,
|
|
9
|
+
* where the root is not the site: every one of those addresses leaves it. What
|
|
10
|
+
* a person sees is a page with no stylesheet, no pictures and navigation that
|
|
11
|
+
* 404s — which reads as a broken build rather than as a wrong prefix, and has
|
|
12
|
+
* cost three separate afternoons to diagnose from that symptom.
|
|
13
|
+
*
|
|
14
|
+
* Astro's `base` fixes what *it* emits — the bundled CSS, the optimised
|
|
15
|
+
* images — and it cannot fix a string somebody typed. So every internal
|
|
16
|
+
* address written by hand goes through `under()`, and `fl-catalogue` fails a
|
|
17
|
+
* repository that writes one straight.
|
|
18
|
+
*
|
|
19
|
+
* A build for a real host has a base of `/`, where `under()` changes nothing.
|
|
20
|
+
* That is the point: one way to write an address, correct in both places.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The path this build is served under, always with its trailing slash. */
|
|
24
|
+
export function base(): string {
|
|
25
|
+
const at = import.meta.env.BASE_URL ?? '/';
|
|
26
|
+
return at.endsWith('/') ? at : `${at}/`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A site-absolute path — `/preise/` — served under wherever this build is. */
|
|
30
|
+
export function under(path: string): string {
|
|
31
|
+
return `${base()}${path.replace(/^\//, '')}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ImageMetadata } from 'astro';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pictures the owner uploaded, as local files.
|
|
5
|
+
*
|
|
6
|
+
* ── the two places a picture can be ─────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* `src/assets/hero.jpg` is the developer's: it is in the repository, it is
|
|
9
|
+
* chosen by whoever built the site, and `photos.ts` resolves it by slot name.
|
|
10
|
+
* This is the other half — a photograph somebody put in through the admin,
|
|
11
|
+
* which arrives as a key like `media/<site>/<hash>.jpg` inside the content
|
|
12
|
+
* document.
|
|
13
|
+
*
|
|
14
|
+
* **They are both local files by the time this runs.** The build's second step
|
|
15
|
+
* downloads every key the content names into `src/assets/`, before `npm run
|
|
16
|
+
* build`, precisely so that a photograph from the admin goes through
|
|
17
|
+
* `astro:assets` exactly as one from the repository does: variants, `srcset`,
|
|
18
|
+
* width hints, the lot. Nothing here fetches anything.
|
|
19
|
+
*
|
|
20
|
+
* That matters more than it sounds. A gallery of thirty-seven pictures served
|
|
21
|
+
* as uploaded is fifteen megabytes; the same gallery through the image pipeline
|
|
22
|
+
* is under two. The difference is not visible to anybody building the site — it
|
|
23
|
+
* is visible to somebody on a phone, on a train, deciding whether to wait.
|
|
24
|
+
*
|
|
25
|
+
* ── on a laptop, before any of that ─────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* There are no downloaded files while a developer is building the site, so
|
|
28
|
+
* every lookup here answers undefined and `<Shot>` draws its labelled
|
|
29
|
+
* placeholder. That is the same behaviour as a missing slot, and it is what lets
|
|
30
|
+
* a site be built, checked and pushed before it has ever been attached.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Anything under `src/assets/media/` — which is only ever what the build put
|
|
35
|
+
* there, keyed by the key itself.
|
|
36
|
+
*
|
|
37
|
+
* `import.meta.glob` needs a literal pattern, so this cannot be narrowed by the
|
|
38
|
+
* site id; the map is small (a site's own pictures) and built once.
|
|
39
|
+
*/
|
|
40
|
+
const pictures = import.meta.glob<{ default: ImageMetadata }>(
|
|
41
|
+
'/src/assets/media/**/*.{jpg,jpeg,JPG,JPEG,png,PNG,webp,WEBP,avif,AVIF}',
|
|
42
|
+
{ eager: true },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clips, as URLs rather than as metadata.
|
|
47
|
+
*
|
|
48
|
+
* `astro:assets` optimises pictures and has nothing to do to a video, so a clip
|
|
49
|
+
* takes the ordinary asset path: Vite emits the file and hands back its final
|
|
50
|
+
* address, hashed and cacheable. The clips are already small — `fl-clips` cuts
|
|
51
|
+
* them to a tenth of a megabyte — so there is nothing to gain by doing more.
|
|
52
|
+
*/
|
|
53
|
+
const clips = import.meta.glob<string>('/src/assets/media/**/*.{mp4,webm}', {
|
|
54
|
+
eager: true,
|
|
55
|
+
query: '?url',
|
|
56
|
+
import: 'default',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const PREFIX = '/src/assets/';
|
|
60
|
+
|
|
61
|
+
function keyed<T>(modules: Record<string, T>): Map<string, T> {
|
|
62
|
+
const out = new Map<string, T>();
|
|
63
|
+
for (const [path, value] of Object.entries(modules)) {
|
|
64
|
+
out.set(path.slice(PREFIX.length), value);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const byKey = keyed(pictures);
|
|
70
|
+
const clipsByKey = keyed(clips);
|
|
71
|
+
|
|
72
|
+
/** Whether a value from the content document is a key rather than a slot. */
|
|
73
|
+
export function isUpload(value: string): boolean {
|
|
74
|
+
return value.startsWith('media/');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An uploaded photograph, ready for `<Image>`. */
|
|
78
|
+
export function uploaded(key: string): ImageMetadata | undefined {
|
|
79
|
+
return byKey.get(key)?.default;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An uploaded clip's address in the built site. */
|
|
83
|
+
export function uploadedClip(key: string): string | undefined {
|
|
84
|
+
return clipsByKey.get(key);
|
|
85
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One entry of one collection — a blog post, a work, a job.
|
|
4
|
+
*
|
|
5
|
+
* ── one file for every collection, and why it is a starting point ───────────
|
|
6
|
+
*
|
|
7
|
+
* This route generates every entry of every collection the site declares, so a
|
|
8
|
+
* new collection is an entry in `COLLECTIONS` and its listing page, and never a
|
|
9
|
+
* second copy of this file. What it renders is deliberately plain: a heading, a
|
|
10
|
+
* picture where there is one, and the body. Exactly like `Blocks.astro`, it is
|
|
11
|
+
* the thing that makes a site buildable on the first commit, not the design.
|
|
12
|
+
*
|
|
13
|
+
* **The design pass replaces what is inside `<article>`. What must survive it
|
|
14
|
+
* is the annotation.** Every field the owner may edit carries its
|
|
15
|
+
* `data-jtk-path`, `annotation-lint.mjs` fails the build without one, and an
|
|
16
|
+
* element that loses its path stops opening the editor *silently*.
|
|
17
|
+
*
|
|
18
|
+
* An entry's document opens with the post — its title, its date, its cover and
|
|
19
|
+
* its prose, always at `blocks[0].<field>`. Everything after it is whatever the
|
|
20
|
+
* collection lets a post hold (the platform's wiki/30), and its paths are
|
|
21
|
+
* `blocks[N].<field>` exactly as on any other page.
|
|
22
|
+
*
|
|
23
|
+
* ── a hidden entry is built ─────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* `getStaticPaths` does **not** filter by `visible`. That is on purpose: an
|
|
26
|
+
* entry nobody has finished still has to be looked at before it is finished,
|
|
27
|
+
* and the studio's edge serves such a page only to a session that is editing
|
|
28
|
+
* the site. Every place that *lists* entries filters — see `src/lib/entries.ts`
|
|
29
|
+
* — and this one renders.
|
|
30
|
+
*/
|
|
31
|
+
import { everyEntry, rendered } from '../lib/entries';
|
|
32
|
+
import { Image } from 'astro:assets';
|
|
33
|
+
import Layout from '../layouts/Layout.astro';
|
|
34
|
+
import { COLLECTIONS } from '../content/blocks';
|
|
35
|
+
import { isUpload, uploaded } from '../lib/uploads';
|
|
36
|
+
import { photo } from '../lib/photos';
|
|
37
|
+
|
|
38
|
+
export async function getStaticPaths() {
|
|
39
|
+
const paths = [];
|
|
40
|
+
|
|
41
|
+
for (const collection of COLLECTIONS) {
|
|
42
|
+
const entries = await everyEntry(collection.name);
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
paths.push({
|
|
45
|
+
params: { entry: `${collection.prefix.replace(/^\//, '')}/${entry.id}` },
|
|
46
|
+
props: { entry, collection },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return paths;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { entry } = Astro.props;
|
|
54
|
+
const data = entry.data as Record<string, unknown>;
|
|
55
|
+
const { Content } = await rendered(entry);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A value from a document as a list of records.
|
|
59
|
+
*
|
|
60
|
+
* A function rather than a cast written where it is needed, because a `as
|
|
61
|
+
* Record<string, unknown>[]` **inside a `{ }` expression in the template** is
|
|
62
|
+
* not valid Astro: the compiler turns the template into TSX and the cast is
|
|
63
|
+
* read as a comparison, which produced nine errors starting with "Generic type
|
|
64
|
+
* 'Record' requires 2 type argument(s)" and cascading to the end of the file.
|
|
65
|
+
* Casts belong in the frontmatter; the template calls this.
|
|
66
|
+
*
|
|
67
|
+
* It also answers for a document that holds something other than a list, which
|
|
68
|
+
* a hand-edited one can.
|
|
69
|
+
*/
|
|
70
|
+
function records(value: unknown): Record<string, unknown>[] {
|
|
71
|
+
return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Everything after the post itself, in the order the owner put it in. */
|
|
75
|
+
const rest = records(data.rest);
|
|
76
|
+
|
|
77
|
+
const title = typeof data.title === 'string' ? data.title : '';
|
|
78
|
+
const excerpt = typeof data.excerpt === 'string' ? data.excerpt : '';
|
|
79
|
+
const cover = typeof data.cover === 'string' && data.cover !== ''
|
|
80
|
+
? (isUpload(data.cover) ? uploaded(data.cover) : photo(data.cover))
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The entry as a thing search engines and assistants can quote.
|
|
85
|
+
*
|
|
86
|
+
* `datePublished` is the field the catalogue reserves, which is why it may be
|
|
87
|
+
* read by name here: the five keys of an entry are the admin's vocabulary and
|
|
88
|
+
* not a site's to rename.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* What a search result says, where somebody wrote it.
|
|
92
|
+
*
|
|
93
|
+
* The admin has a card for these two — the title in a result list and the
|
|
94
|
+
* sentence under it — and they appear nowhere on the page, which is exactly why
|
|
95
|
+
* they are edited in a form rather than by tapping. Empty, a post is found by
|
|
96
|
+
* its own heading and its announcement, so this is a fallback and never a
|
|
97
|
+
* second pair of fields that must be filled in.
|
|
98
|
+
*/
|
|
99
|
+
const seo = (data.seo ?? {}) as Record<string, unknown>;
|
|
100
|
+
const said = (key: string): string =>
|
|
101
|
+
typeof seo[key] === 'string' ? (seo[key] as string).trim() : '';
|
|
102
|
+
|
|
103
|
+
const schema = {
|
|
104
|
+
'@type': 'Article',
|
|
105
|
+
headline: title,
|
|
106
|
+
datePublished: typeof data.date === 'string' ? data.date : undefined,
|
|
107
|
+
description: excerpt || undefined,
|
|
108
|
+
};
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
<Layout title={said('title') || title} description={said('description') || excerpt} schema={schema}>
|
|
112
|
+
<main id="content">
|
|
113
|
+
<article class="entry">
|
|
114
|
+
<h1 data-jtk-path="blocks[0].title">{title}</h1>
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
cover && (
|
|
118
|
+
<Image
|
|
119
|
+
src={cover}
|
|
120
|
+
alt={title}
|
|
121
|
+
data-jtk-path="blocks[0].cover"
|
|
122
|
+
widths={[640, 1280, 1920]}
|
|
123
|
+
sizes="(min-width: 900px) 60rem, 92vw"
|
|
124
|
+
layout="none"
|
|
125
|
+
/>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
{/*
|
|
130
|
+
One element for the whole body, because the body is one field. The
|
|
131
|
+
editor opens it full screen — nine hundred words is not a popover — and
|
|
132
|
+
what is inside is markdown Astro rendered, pictures and all.
|
|
133
|
+
*/}
|
|
134
|
+
<div class="entry-body" data-jtk-path="blocks[0].body"><Content /></div>
|
|
135
|
+
|
|
136
|
+
{/*
|
|
137
|
+
And the rest of the post, in the order the owner put it in.
|
|
138
|
+
|
|
139
|
+
A post is a *sequence*: its opening prose above, then runs of prose and
|
|
140
|
+
whatever else it holds, interleaved. A post is written rather than
|
|
141
|
+
designed, so the owner arranges these in the admin — but *which* types
|
|
142
|
+
may be in one is this repository's to say, in `COLLECTIONS[].body`, and
|
|
143
|
+
`text` must be among them or the writing cannot be broken by anything.
|
|
144
|
+
|
|
145
|
+
A gallery arrives with the arrangement it is in — `block.view`, always
|
|
146
|
+
one of the ones its type declares. Draw each one differently: that menu
|
|
147
|
+
is the whole reason the owner may choose, and a `view` that changes
|
|
148
|
+
nothing on the page is a choice this repository lied about offering.
|
|
149
|
+
|
|
150
|
+
Plain on purpose, exactly like `Blocks.astro`: what the design pass
|
|
151
|
+
replaces is what is inside, and what must survive it is the
|
|
152
|
+
`data-jtk-path` on every field the owner may edit.
|
|
153
|
+
*/}
|
|
154
|
+
{
|
|
155
|
+
rest.map((block, offset) => {
|
|
156
|
+
const at = offset + 1;
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* A run of prose. A post is a sequence — prose, a gallery, more
|
|
160
|
+
* prose — and this is what the runs after the first are. Rendered by
|
|
161
|
+
* the loader, because Astro renders one body per entry and knows
|
|
162
|
+
* nothing about a second.
|
|
163
|
+
*/
|
|
164
|
+
if (block.type === 'text') {
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
class="entry-body"
|
|
168
|
+
data-jtk-path={`blocks[${at}].body`}
|
|
169
|
+
set:html={String(block.html ?? '')}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pictures = records(block.work ?? block.items);
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div class="entry-wall" data-view={String(block.view ?? '')}>
|
|
178
|
+
{pictures.map((picture, index) => {
|
|
179
|
+
/*
|
|
180
|
+
* The same two answers the cover has, for the same reason: a
|
|
181
|
+
* value is either a key in the studio's bucket, which the build
|
|
182
|
+
* downloaded, or the name of a photograph this repository
|
|
183
|
+
* ships. Reading only the first renders nothing at all for the
|
|
184
|
+
* second, with no error until the build refuses the undefined.
|
|
185
|
+
*/
|
|
186
|
+
const src = String(picture.src ?? '');
|
|
187
|
+
const file = isUpload(src) ? uploaded(src) : photo(src);
|
|
188
|
+
if (!file) return null;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<Image
|
|
192
|
+
src={file}
|
|
193
|
+
alt={String(picture.alt ?? '')}
|
|
194
|
+
data-jtk-path={`blocks[${at}].work[${index}].src`}
|
|
195
|
+
widths={[640, 1280]}
|
|
196
|
+
sizes="(min-width: 900px) 30rem, 92vw"
|
|
197
|
+
layout="none"
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
</article>
|
|
206
|
+
</main>
|
|
207
|
+
</Layout>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { COLLECTIONS, words } from '../content/blocks';
|
|
3
|
+
import { INDEXABLE, canonicalFor } from '../data/site';
|
|
4
|
+
import { allListed, href } from '../lib/entries';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One feed per collection, at `/blog/rss.xml`.
|
|
8
|
+
*
|
|
9
|
+
* Written by hand rather than through `@astrojs/rss`, for the same reason
|
|
10
|
+
* `sitemap.xml.ts` is: it is thirty lines, it has no dependency, and what goes
|
|
11
|
+
* in it is a decision rather than a default.
|
|
12
|
+
*
|
|
13
|
+
* Hidden entries are absent, because `allListed` is the only way anything here
|
|
14
|
+
* enumerates them. Empty while INDEXABLE is false — a preview announcing itself
|
|
15
|
+
* to a reader is a preview competing with the site it previews.
|
|
16
|
+
*/
|
|
17
|
+
export async function getStaticPaths() {
|
|
18
|
+
return COLLECTIONS.map((collection) => ({
|
|
19
|
+
params: { feed: `${collection.prefix.replace(/^\//, '')}/rss` },
|
|
20
|
+
props: { name: collection.name },
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const GET: APIRoute = async ({ props }) => {
|
|
25
|
+
const name = (props as { name: string }).name;
|
|
26
|
+
const all = await allListed();
|
|
27
|
+
const found = all.find((one) => one.collection.name === name);
|
|
28
|
+
const entries = INDEXABLE && found ? found.entries : [];
|
|
29
|
+
|
|
30
|
+
const items = entries
|
|
31
|
+
.map((entry) => {
|
|
32
|
+
const data = entry.data as Record<string, unknown>;
|
|
33
|
+
const link = canonicalFor(href(found!.collection, entry));
|
|
34
|
+
return ` <item>
|
|
35
|
+
<title>${escapeXML(String(data.title ?? ''))}</title>
|
|
36
|
+
<link>${link}</link>
|
|
37
|
+
<guid isPermaLink="true">${link}</guid>
|
|
38
|
+
${data.date ? `<pubDate>${new Date(String(data.date)).toUTCString()}</pubDate>` : ''}
|
|
39
|
+
${data.excerpt ? `<description>${escapeXML(String(data.excerpt))}</description>` : ''}
|
|
40
|
+
</item>`;
|
|
41
|
+
})
|
|
42
|
+
.join('\n');
|
|
43
|
+
|
|
44
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
45
|
+
<rss version="2.0">
|
|
46
|
+
<channel>
|
|
47
|
+
<title>${escapeXML(words(found?.collection.label) || name)}</title>
|
|
48
|
+
<link>${canonicalFor(found?.collection.prefix ?? '/')}</link>
|
|
49
|
+
<description>${escapeXML(words(found?.collection.label) || name)}</description>
|
|
50
|
+
${items}
|
|
51
|
+
</channel>
|
|
52
|
+
</rss>
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function escapeXML(text: string): string {
|
|
59
|
+
return text
|
|
60
|
+
.replace(/&/g, '&')
|
|
61
|
+
.replace(/</g, '<')
|
|
62
|
+
.replace(/>/g, '>')
|
|
63
|
+
.replace(/"/g, '"');
|
|
64
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The landing page.
|
|
4
|
+
*
|
|
5
|
+
* A deliberately plain starting point: the structure and every visual decision
|
|
6
|
+
* come from the brief, through the frontend-design skill, one variant at a
|
|
7
|
+
* time. Nothing here is a design suggestion — it is a page that builds, with a
|
|
8
|
+
* correct <head>, a working form, a photo slot and every section of the content
|
|
9
|
+
* document rendered, so the first commit of a new project is already deployable
|
|
10
|
+
* and a generated one is already viewable.
|
|
11
|
+
*
|
|
12
|
+
* ── data-jtk-path, and why it is here from the first commit ──────────────────
|
|
13
|
+
*
|
|
14
|
+
* The studio's admin edits this site by tapping the text on it. That works
|
|
15
|
+
* because every element whose content comes from `jtk/content/index.json`
|
|
16
|
+
* carries the path of the field it came from, and `flPath` builds those from
|
|
17
|
+
* the document rather than from anything written by hand.
|
|
18
|
+
*
|
|
19
|
+
* The build fails without them — `annotation-lint.mjs` compares the published
|
|
20
|
+
* content against the generated HTML and refuses a page that dropped one. It is
|
|
21
|
+
* a gate rather than a convention, because an element that loses its path stops
|
|
22
|
+
* opening the editor **silently**, and nobody finds out for a week.
|
|
23
|
+
*
|
|
24
|
+
* So: whatever a variant does to this page, every field it renders keeps its
|
|
25
|
+
* `data-jtk-path={flPath(...)}`.
|
|
26
|
+
*
|
|
27
|
+
* ── the hero's ground ───────────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* `jtk/design.json` holds the palette and which of the ten fields to
|
|
30
|
+
* paint. It is a document rather than props written into this file for the same
|
|
31
|
+
* reason the copy is: it can then be changed without an agent, and the studio
|
|
32
|
+
* bar's live accent drives the same tokens. With the document empty — a fresh
|
|
33
|
+
* `fl-init`, before anything has been designed — the hero is a plain section on
|
|
34
|
+
* the kit's own ground, which is what a scaffold should be.
|
|
35
|
+
*
|
|
36
|
+
* ── the surface ─────────────────────────────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* The same document's `surface` block holds the other three choices: the ground
|
|
39
|
+
* the page is printed on, the edge its blocks are drawn with, and how they
|
|
40
|
+
* arrive. Empty means none, and none is a real choice rather than a missing
|
|
41
|
+
* one — on a page carrying strong photographs it is usually the right one. See
|
|
42
|
+
* `docs/surface.md` of @jtakeit/astro.
|
|
43
|
+
*/
|
|
44
|
+
import Layout from '../layouts/Layout.astro';
|
|
45
|
+
import Hero from '../components/Hero.astro';
|
|
46
|
+
import Blocks from '../components/Blocks.astro';
|
|
47
|
+
import HeroField from '../components/motion/HeroField.astro';
|
|
48
|
+
import Pattern from '../components/surface/Pattern.astro';
|
|
49
|
+
import { HOME } from '../copy/{{LOCALE}}';
|
|
50
|
+
import design from '../../jtk/design.json';
|
|
51
|
+
|
|
52
|
+
const painted =
|
|
53
|
+
design.field !== '' && Array.isArray(design.colors) && design.colors.length === 4;
|
|
54
|
+
|
|
55
|
+
const surface = design.surface ?? {};
|
|
56
|
+
const grounded = Boolean(surface.pattern);
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
<Layout title={HOME.title} description={HOME.description} suffix={false}>
|
|
60
|
+
{
|
|
61
|
+
grounded && (
|
|
62
|
+
<Pattern
|
|
63
|
+
pattern={surface.pattern as never}
|
|
64
|
+
scope={(surface.scope || 'page') as never}
|
|
65
|
+
size={surface.size || undefined}
|
|
66
|
+
weight={surface.weight || undefined}
|
|
67
|
+
opacity={surface.opacity || undefined}
|
|
68
|
+
draw={surface.draw === true}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
<main id="content">
|
|
74
|
+
{painted ? (
|
|
75
|
+
<HeroField
|
|
76
|
+
field={design.field as never}
|
|
77
|
+
ground="var(--fl-ground)"
|
|
78
|
+
colors={['var(--fl-c1)', 'var(--fl-c2)', 'var(--fl-c3)', 'var(--fl-c4)']}
|
|
79
|
+
scrim={design.scrim !== false}
|
|
80
|
+
class="hero"
|
|
81
|
+
>
|
|
82
|
+
<Hero />
|
|
83
|
+
</HeroField>
|
|
84
|
+
) : (
|
|
85
|
+
<section class="hero"><Hero /></section>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<Blocks />
|
|
89
|
+
</main>
|
|
90
|
+
</Layout>
|