astroidjs 0.1.1 → 0.2.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/README.md +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +67 -0
- package/dist/commerce/mirror.js +203 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +174 -12
- package/dist/config.js +43 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +50 -0
- package/dist/portal/guard.d.ts +48 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +106 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +140 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +42 -8
- package/dist/schema/collections.js +102 -8
- package/dist/schema/generate.js +10 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +107 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +325 -37
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -5
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// One media lookup per page, not one per image.
|
|
4
|
+
//
|
|
5
|
+
// A section stores an image as a URL. The `alt` and `caption` an editor typed
|
|
6
|
+
// live on the media ASSET, so rendering a page's images correctly means joining
|
|
7
|
+
// every image field back to the media registry. Done naively — a query per
|
|
8
|
+
// `<MediaSlot>` — a gallery section is thirty round-trips to D1 on every render.
|
|
9
|
+
//
|
|
10
|
+
// So the whole page is resolved in ONE bounded `IN (...)` lookup before anything
|
|
11
|
+
// renders, and the result is threaded down as `mediaMeta`. This is the pattern
|
|
12
|
+
// ghostfire's `Sections.astro` arrived at independently, generalized.
|
|
13
|
+
//
|
|
14
|
+
// The collection step is SCHEMA-DRIVEN: it walks the catalog looking for fields
|
|
15
|
+
// of `type: "image"` rather than hardcoding field names. That's what keeps it
|
|
16
|
+
// correct as sections are added — a new section with an image field is picked up
|
|
17
|
+
// because it declared one, not because someone remembered to update a list here.
|
|
18
|
+
//
|
|
19
|
+
// Self-contained (ships as source): type-only imports, no astroid `src/*` reach.
|
|
20
|
+
|
|
21
|
+
import type { SectionCatalog, SectionField, SectionItem } from "louise-toolkit/content";
|
|
22
|
+
import type { MediaMeta } from "./sections.js";
|
|
23
|
+
|
|
24
|
+
/** SQLite's default parameter ceiling is 999; stay well under it and let the
|
|
25
|
+
* caller's URL count drive the number of statements rather than the limit. */
|
|
26
|
+
const CHUNK = 100;
|
|
27
|
+
|
|
28
|
+
/** Walk one item's declared fields, collecting the values of `image` ones.
|
|
29
|
+
* Recurses into `array` item fields — a gallery's images live there. */
|
|
30
|
+
function collectFrom(
|
|
31
|
+
item: Record<string, unknown>,
|
|
32
|
+
fields: Record<string, SectionField> | undefined,
|
|
33
|
+
out: Set<string>,
|
|
34
|
+
): void {
|
|
35
|
+
if (!fields) return;
|
|
36
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
37
|
+
const value = item[key];
|
|
38
|
+
if (field.type === "image") {
|
|
39
|
+
if (typeof value === "string" && value !== "") out.add(value);
|
|
40
|
+
} else if (field.type === "array" && Array.isArray(value)) {
|
|
41
|
+
for (const row of value) {
|
|
42
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) continue;
|
|
43
|
+
// With a discriminator, an item's variant contributes extra fields on
|
|
44
|
+
// top of the shared set — an image declared only on the "image" variant
|
|
45
|
+
// of a blocks-style array would be missed otherwise.
|
|
46
|
+
const sub = row as Record<string, unknown>;
|
|
47
|
+
let itemFields = field.itemFields ?? {};
|
|
48
|
+
const disc = field.discriminator;
|
|
49
|
+
if (disc) {
|
|
50
|
+
const variant = sub[disc.key];
|
|
51
|
+
const extra = typeof variant === "string" ? disc.variants[variant] : undefined;
|
|
52
|
+
if (extra) itemFields = { ...itemFields, ...extra };
|
|
53
|
+
}
|
|
54
|
+
collectFrom(sub, itemFields, out);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every media URL a page's sections reference, deduplicated.
|
|
62
|
+
*
|
|
63
|
+
* Blocks are walked too, against the block catalog — a section's images and its
|
|
64
|
+
* blocks' images are the same lookup, and splitting them would reintroduce the
|
|
65
|
+
* per-section query this exists to avoid.
|
|
66
|
+
*/
|
|
67
|
+
export function collectSectionMediaUrls(
|
|
68
|
+
items: SectionItem[],
|
|
69
|
+
catalog: SectionCatalog,
|
|
70
|
+
blockCatalog: SectionCatalog = {},
|
|
71
|
+
): string[] {
|
|
72
|
+
const urls = new Set<string>();
|
|
73
|
+
for (const item of items) {
|
|
74
|
+
const def = catalog[String(item._type)];
|
|
75
|
+
if (def) collectFrom(item, def.fields, urls);
|
|
76
|
+
if (!Array.isArray(item.blocks)) continue;
|
|
77
|
+
for (const block of item.blocks) {
|
|
78
|
+
if (typeof block !== "object" || block === null) continue;
|
|
79
|
+
const blockDef = blockCatalog[String((block as { _type?: unknown })._type)];
|
|
80
|
+
if (blockDef) collectFrom(block as Record<string, unknown>, blockDef.fields, urls);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...urls];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The D1 surface this needs. Structural, so a real `D1Database` fits and a test
|
|
87
|
+
* can pass a stub. */
|
|
88
|
+
export interface MediaMetaDatabase {
|
|
89
|
+
prepare(query: string): {
|
|
90
|
+
bind(...values: unknown[]): {
|
|
91
|
+
all<T = Record<string, unknown>>(): Promise<{ results?: T[] }>;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Turn a public media URL into its registry `key`.
|
|
98
|
+
*
|
|
99
|
+
* The public URL is `MEDIA_URL + "/" + key`, so this is prefix removal — but it
|
|
100
|
+
* has to tolerate an absolute URL too (a site whose media base is a full origin),
|
|
101
|
+
* which is why it falls back to the pathname's last segment rather than assuming
|
|
102
|
+
* the string starts with `mediaBase`.
|
|
103
|
+
*/
|
|
104
|
+
export function mediaKeyFromUrl(url: string, mediaBase: string): string | null {
|
|
105
|
+
if (!url) return null;
|
|
106
|
+
const base = mediaBase.replace(/\/+$/, "");
|
|
107
|
+
if (base && url.startsWith(`${base}/`)) return url.slice(base.length + 1);
|
|
108
|
+
// Absolute or unexpected shape — take the path's tail, which is the key for
|
|
109
|
+
// every URL the media route produces.
|
|
110
|
+
try {
|
|
111
|
+
const path = url.startsWith("http") ? new URL(url).pathname : url;
|
|
112
|
+
const tail = path.split("/").filter(Boolean).pop();
|
|
113
|
+
return tail ?? null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve alt/caption for a set of media URLs in one bounded lookup per chunk.
|
|
121
|
+
*
|
|
122
|
+
* Returns `{}` on any failure rather than throwing: missing alt text degrades a
|
|
123
|
+
* page, but a media table that isn't provisioned yet must not take the whole
|
|
124
|
+
* render down with it. The caller's per-image `alt` (a usage-level override)
|
|
125
|
+
* always wins over what comes back here — this is the ASSET-level fallback.
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveSectionMedia(
|
|
128
|
+
db: MediaMetaDatabase | undefined,
|
|
129
|
+
urls: string[],
|
|
130
|
+
mediaBase = "/media",
|
|
131
|
+
): Promise<MediaMeta> {
|
|
132
|
+
if (!db || urls.length === 0) return {};
|
|
133
|
+
|
|
134
|
+
// key → the URL(s) that produced it, so results map back to what the sections
|
|
135
|
+
// actually reference.
|
|
136
|
+
const byKey = new Map<string, string[]>();
|
|
137
|
+
for (const url of urls) {
|
|
138
|
+
const key = mediaKeyFromUrl(url, mediaBase);
|
|
139
|
+
if (!key) continue;
|
|
140
|
+
const existing = byKey.get(key);
|
|
141
|
+
if (existing) existing.push(url);
|
|
142
|
+
else byKey.set(key, [url]);
|
|
143
|
+
}
|
|
144
|
+
if (byKey.size === 0) return {};
|
|
145
|
+
|
|
146
|
+
const keys = [...byKey.keys()];
|
|
147
|
+
const meta: MediaMeta = {};
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
for (let i = 0; i < keys.length; i += CHUNK) {
|
|
151
|
+
const chunk = keys.slice(i, i + CHUNK);
|
|
152
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
153
|
+
const { results } = await db
|
|
154
|
+
.prepare(`SELECT key, alt, caption FROM media WHERE key IN (${placeholders})`)
|
|
155
|
+
.bind(...chunk)
|
|
156
|
+
.all<{ key: string; alt: string | null; caption: string | null }>();
|
|
157
|
+
|
|
158
|
+
for (const row of results ?? []) {
|
|
159
|
+
for (const url of byKey.get(row.key) ?? []) {
|
|
160
|
+
meta[url] = {
|
|
161
|
+
...(row.alt ? { alt: row.alt } : {}),
|
|
162
|
+
...(row.caption ? { caption: row.caption } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// No media table yet (pre-provision), or a transient D1 error. Alt text
|
|
169
|
+
// falls back to whatever the section itself carries.
|
|
170
|
+
return meta;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return meta;
|
|
174
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A portrait beside an introduction — the "about the maker" opener.
|
|
3
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
4
|
+
import { colorwayClass, field, mediaAlt, setting } from "../sections.js";
|
|
5
|
+
import Editable from "../Editable.astro";
|
|
6
|
+
import MediaSlot from "../MediaSlot.astro";
|
|
7
|
+
|
|
8
|
+
type Props = SectionRenderProps;
|
|
9
|
+
|
|
10
|
+
const { item, base, edit, mediaMeta } = Astro.props;
|
|
11
|
+
|
|
12
|
+
const heading = field(item, "heading");
|
|
13
|
+
const body = field(item, "body");
|
|
14
|
+
const image = field(item, "image");
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
18
|
+
<div class="mx-auto grid max-w-5xl items-start gap-10 px-6 md:grid-cols-[minmax(0,18rem)_1fr]">
|
|
19
|
+
{image && (
|
|
20
|
+
<MediaSlot
|
|
21
|
+
src={image}
|
|
22
|
+
alt={mediaAlt(mediaMeta, image, field(item, "alt"))}
|
|
23
|
+
width={576}
|
|
24
|
+
sizes="(max-width: 48rem) 100vw, 18rem"
|
|
25
|
+
ratio="3/4"
|
|
26
|
+
/>
|
|
27
|
+
)}
|
|
28
|
+
<div>
|
|
29
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="text-3xl font-bold">
|
|
30
|
+
{heading}
|
|
31
|
+
</Editable>
|
|
32
|
+
{body && (
|
|
33
|
+
<Editable
|
|
34
|
+
as="div"
|
|
35
|
+
base={base}
|
|
36
|
+
field="body"
|
|
37
|
+
type="richtext"
|
|
38
|
+
edit={edit}
|
|
39
|
+
class="prose mt-4 max-w-none"
|
|
40
|
+
>
|
|
41
|
+
<Fragment set:html={body} />
|
|
42
|
+
</Editable>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A single-line attention strip. Defaults to the brand colorway, since that's
|
|
3
|
+
// what a banner is for.
|
|
4
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
5
|
+
import { alignClass, colorwayClass, field, setting } from "../sections.js";
|
|
6
|
+
import Editable from "../Editable.astro";
|
|
7
|
+
|
|
8
|
+
type Props = SectionRenderProps;
|
|
9
|
+
|
|
10
|
+
const { item, base, edit } = Astro.props;
|
|
11
|
+
|
|
12
|
+
const text = field(item, "text");
|
|
13
|
+
const ctaLabel = field(item, "ctaLabel");
|
|
14
|
+
const ctaHref = field(item, "ctaHref");
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<section class:list={["py-4", colorwayClass(setting(item, "colorway", "brand"))]}>
|
|
18
|
+
<div
|
|
19
|
+
class:list={[
|
|
20
|
+
"mx-auto flex max-w-6xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-6",
|
|
21
|
+
alignClass(setting(item, "align", "center")),
|
|
22
|
+
]}
|
|
23
|
+
>
|
|
24
|
+
<Editable as="p" base={base} field="text" edit={edit} class="font-medium">{text}</Editable>
|
|
25
|
+
{ctaLabel && (
|
|
26
|
+
<a class="btn btn-sm" href={ctaHref || "#"}>
|
|
27
|
+
<Editable as="span" base={base} field="ctaLabel" edit={edit}>{ctaLabel}</Editable>
|
|
28
|
+
</a>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
</section>
|
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
---
|
|
2
|
-
// Contact section
|
|
3
|
-
//
|
|
4
|
-
import type {
|
|
5
|
-
import { colorwayClass } from "../sections.js";
|
|
2
|
+
// Contact section — a heading + blurb + link to the contact route (the generated
|
|
3
|
+
// worker's contact form lives at /contact).
|
|
4
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
5
|
+
import { colorwayClass, field, setting } from "../sections.js";
|
|
6
|
+
import Editable from "../Editable.astro";
|
|
6
7
|
|
|
7
|
-
type Props =
|
|
8
|
+
type Props = SectionRenderProps;
|
|
8
9
|
|
|
9
|
-
const {
|
|
10
|
+
const { item, base, edit } = Astro.props;
|
|
11
|
+
|
|
12
|
+
// The default is a render-time fallback, not stored data: an editor who hasn't
|
|
13
|
+
// typed a heading still gets a sensible section, and the moment they do the
|
|
14
|
+
// stored value takes over.
|
|
15
|
+
const heading = field(item, "heading") || "Get in touch";
|
|
16
|
+
const blurb = field(item, "blurb");
|
|
10
17
|
---
|
|
11
18
|
|
|
12
|
-
<section class:list={["py-16", colorwayClass(colorway)]}>
|
|
19
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
13
20
|
<div class="mx-auto max-w-xl px-6 text-center">
|
|
14
|
-
<h2 class="text-3xl font-bold">
|
|
15
|
-
|
|
21
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="text-3xl font-bold">
|
|
22
|
+
{heading}
|
|
23
|
+
</Editable>
|
|
24
|
+
{blurb && (
|
|
25
|
+
<Editable as="p" base={base} field="blurb" edit={edit} multiline class="mt-2 opacity-80">
|
|
26
|
+
{blurb}
|
|
27
|
+
</Editable>
|
|
28
|
+
)}
|
|
16
29
|
<a class="btn btn-primary mt-6" href="/contact">Contact us</a>
|
|
17
30
|
</div>
|
|
18
31
|
</section>
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
---
|
|
2
|
-
// Call-to-action band
|
|
3
|
-
|
|
4
|
-
import {
|
|
2
|
+
// Call-to-action band. Defaults to the brand colorway — a setting token, so an
|
|
3
|
+
// editor can change it in the inspector without touching this file.
|
|
4
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
5
|
+
import { alignClass, colorwayClass, field, setting } from "../sections.js";
|
|
6
|
+
import Editable from "../Editable.astro";
|
|
5
7
|
|
|
6
|
-
type Props =
|
|
8
|
+
type Props = SectionRenderProps;
|
|
7
9
|
|
|
8
|
-
const {
|
|
10
|
+
const { item, base, edit } = Astro.props;
|
|
11
|
+
|
|
12
|
+
const heading = field(item, "heading");
|
|
13
|
+
const body = field(item, "body");
|
|
14
|
+
const ctaLabel = field(item, "ctaLabel");
|
|
15
|
+
const ctaHref = field(item, "ctaHref");
|
|
9
16
|
---
|
|
10
17
|
|
|
11
|
-
<section class:list={["py-16", colorwayClass(colorway)]}>
|
|
12
|
-
<div
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "brand"))]}>
|
|
19
|
+
<div
|
|
20
|
+
class:list={[
|
|
21
|
+
"mx-auto flex max-w-3xl flex-col gap-4 px-6",
|
|
22
|
+
alignClass(setting(item, "align", "center")),
|
|
23
|
+
]}
|
|
24
|
+
>
|
|
25
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="text-3xl font-bold">
|
|
26
|
+
{heading}
|
|
27
|
+
</Editable>
|
|
28
|
+
{body && (
|
|
29
|
+
<Editable as="p" base={base} field="body" edit={edit} multiline class="text-lg opacity-90">
|
|
30
|
+
{body}
|
|
31
|
+
</Editable>
|
|
32
|
+
)}
|
|
33
|
+
{/* Rendered even without an href so the label stays editable: a CTA whose
|
|
34
|
+
link hasn't been filled in yet would otherwise vanish from the canvas,
|
|
35
|
+
and an editor can't click what isn't there. */}
|
|
36
|
+
<a class="btn" href={ctaHref || "#"}>
|
|
37
|
+
<Editable as="span" base={base} field="ctaLabel" edit={edit}>{ctaLabel}</Editable>
|
|
38
|
+
</a>
|
|
16
39
|
</div>
|
|
17
40
|
</section>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Frequently-asked questions on native <details>/<summary>.
|
|
3
|
+
//
|
|
4
|
+
// Native rather than a JS accordion on purpose: it is keyboard-accessible and
|
|
5
|
+
// screen-reader-correct with no script, it works before hydration, and browser
|
|
6
|
+
// find-in-page can open a collapsed answer — which a div-based accordion
|
|
7
|
+
// silently breaks.
|
|
8
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
9
|
+
import { colorwayClass, field, itemField, list, setting } from "../sections.js";
|
|
10
|
+
import Editable from "../Editable.astro";
|
|
11
|
+
|
|
12
|
+
type Props = SectionRenderProps;
|
|
13
|
+
|
|
14
|
+
const { item, base, edit } = Astro.props;
|
|
15
|
+
|
|
16
|
+
const heading = field(item, "heading");
|
|
17
|
+
const items = list(item, "items");
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
21
|
+
<div class="mx-auto max-w-3xl px-6">
|
|
22
|
+
{heading && (
|
|
23
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="mb-8 text-3xl font-bold">
|
|
24
|
+
{heading}
|
|
25
|
+
</Editable>
|
|
26
|
+
)}
|
|
27
|
+
<div class="divide-y divide-base-300">
|
|
28
|
+
{items.map((row, i) => (
|
|
29
|
+
/* `open` while editing: a collapsed answer is unreachable for in-place
|
|
30
|
+
editing, and the editor can't be expected to expand each one first. */
|
|
31
|
+
<details class="group py-4" open={edit}>
|
|
32
|
+
<summary class="cursor-pointer list-none font-semibold marker:content-none">
|
|
33
|
+
<Editable as="span" sfield={`${base}.items.${i}.question`} edit={edit}>
|
|
34
|
+
{itemField(row, "question")}
|
|
35
|
+
</Editable>
|
|
36
|
+
</summary>
|
|
37
|
+
<Editable
|
|
38
|
+
as="div"
|
|
39
|
+
sfield={`${base}.items.${i}.answer`}
|
|
40
|
+
type="richtext"
|
|
41
|
+
edit={edit}
|
|
42
|
+
class="prose mt-3 max-w-none"
|
|
43
|
+
>
|
|
44
|
+
<Fragment set:html={itemField(row, "answer") ?? ""} />
|
|
45
|
+
</Editable>
|
|
46
|
+
</details>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
@@ -1,23 +1,52 @@
|
|
|
1
1
|
---
|
|
2
|
-
// Feature-grid section
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// Feature-grid section — value-prop cards over an `array` field.
|
|
3
|
+
//
|
|
4
|
+
// The array is where `base` earns its keep. Each card's fields address
|
|
5
|
+
// `<base>.items.<i>.<key>`, so the marker is built from the item's position
|
|
6
|
+
// rather than a fixed slot name — which is why the schema can use a real
|
|
7
|
+
// repeatable array instead of the `card1…card6` flattening sites fall back to.
|
|
8
|
+
// The client's path parser is depth-agnostic (it splits on `.` and coerces
|
|
9
|
+
// numeric segments), so this needs nothing new on the editor side.
|
|
10
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
11
|
+
import { colorwayClass, field, itemField, list, setting } from "../sections.js";
|
|
12
|
+
import Editable from "../Editable.astro";
|
|
6
13
|
|
|
7
|
-
type Props =
|
|
14
|
+
type Props = SectionRenderProps;
|
|
8
15
|
|
|
9
|
-
const {
|
|
16
|
+
const { item, base, edit } = Astro.props;
|
|
17
|
+
|
|
18
|
+
const heading = field(item, "heading");
|
|
19
|
+
const items = list(item, "items");
|
|
10
20
|
---
|
|
11
21
|
|
|
12
|
-
<section class:list={["py-16", colorwayClass(colorway)]}>
|
|
22
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
13
23
|
<div class="mx-auto max-w-5xl px-6">
|
|
14
|
-
{heading &&
|
|
24
|
+
{heading && (
|
|
25
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="mb-8 text-3xl font-bold">
|
|
26
|
+
{heading}
|
|
27
|
+
</Editable>
|
|
28
|
+
)}
|
|
15
29
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
16
|
-
{items.map((
|
|
30
|
+
{items.map((row, i) => (
|
|
17
31
|
<div class="card bg-base-100 text-base-content shadow-sm">
|
|
18
32
|
<div class="card-body">
|
|
19
|
-
<
|
|
20
|
-
|
|
33
|
+
<Editable
|
|
34
|
+
as="h3"
|
|
35
|
+
sfield={`${base}.items.${i}.title`}
|
|
36
|
+
edit={edit}
|
|
37
|
+
class="card-title"
|
|
38
|
+
>
|
|
39
|
+
{itemField(row, "title")}
|
|
40
|
+
</Editable>
|
|
41
|
+
<Editable
|
|
42
|
+
as="p"
|
|
43
|
+
sfield={`${base}.items.${i}.body`}
|
|
44
|
+
edit={edit}
|
|
45
|
+
multiline
|
|
46
|
+
class="opacity-80"
|
|
47
|
+
>
|
|
48
|
+
{itemField(row, "body")}
|
|
49
|
+
</Editable>
|
|
21
50
|
</div>
|
|
22
51
|
</div>
|
|
23
52
|
))}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A gallery section over the justified-grid primitive (#257).
|
|
3
|
+
//
|
|
4
|
+
// The layout maths lives in `<JustifiedGallery>`; what this adds is the mapping
|
|
5
|
+
// from stored section rows to gallery items, including the asset-level alt and
|
|
6
|
+
// caption fallback that the page's single media lookup makes cheap.
|
|
7
|
+
import type { GalleryItem } from "../justify.js";
|
|
8
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
9
|
+
import { colorwayClass, field, itemField, list, mediaAlt, mediaCaption, setting } from "../sections.js";
|
|
10
|
+
import Editable from "../Editable.astro";
|
|
11
|
+
import JustifiedGallery from "../JustifiedGallery.astro";
|
|
12
|
+
|
|
13
|
+
type Props = SectionRenderProps;
|
|
14
|
+
|
|
15
|
+
const { item, base, edit, mediaMeta } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const heading = field(item, "heading");
|
|
18
|
+
|
|
19
|
+
// Rows with no image are dropped rather than rendered as a gap: an editor who
|
|
20
|
+
// adds a row and hasn't picked the picture yet shouldn't see a broken tile.
|
|
21
|
+
const items: GalleryItem[] = list(item, "items").flatMap((row) => {
|
|
22
|
+
const src = itemField(row, "image");
|
|
23
|
+
if (!src) return [];
|
|
24
|
+
const caption = mediaCaption(mediaMeta, src, itemField(row, "caption"));
|
|
25
|
+
const href = itemField(row, "href");
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
src,
|
|
29
|
+
alt: mediaAlt(mediaMeta, src, itemField(row, "alt")),
|
|
30
|
+
...(caption ? { caption } : {}),
|
|
31
|
+
...(href ? { href } : {}),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
});
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
38
|
+
<div class="mx-auto max-w-6xl px-6">
|
|
39
|
+
{heading && (
|
|
40
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="mb-8 text-3xl font-bold">
|
|
41
|
+
{heading}
|
|
42
|
+
</Editable>
|
|
43
|
+
)}
|
|
44
|
+
{items.length > 0 && <JustifiedGallery items={items} />}
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
@@ -1,22 +1,50 @@
|
|
|
1
1
|
---
|
|
2
|
-
// Hero section
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// Hero section. Renders a stored `SectionItem`; every visible text node is
|
|
3
|
+
// wrapped in `<Editable base={base} field="…">`, which is what makes it editable
|
|
4
|
+
// in place on the real design rather than through a proxy form.
|
|
5
|
+
//
|
|
6
|
+
// Presentation comes from `_settings` TOKENS, not props: Louise stores
|
|
7
|
+
// `colorway: "brand"` and never learns what that renders as, so the mapping
|
|
8
|
+
// below is the site-owned half of the contract (ADR 0005 §5) and a re-theme
|
|
9
|
+
// needs no content rewrite.
|
|
10
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
11
|
+
import { alignClass, colorwayClass, field, setting } from "../sections.js";
|
|
12
|
+
import Editable from "../Editable.astro";
|
|
7
13
|
|
|
8
|
-
type Props =
|
|
14
|
+
type Props = SectionRenderProps;
|
|
9
15
|
|
|
10
|
-
const {
|
|
16
|
+
const { item, base, edit } = Astro.props;
|
|
17
|
+
|
|
18
|
+
const heading = field(item, "heading");
|
|
19
|
+
const subheading = field(item, "subheading");
|
|
20
|
+
const ctaLabel = field(item, "ctaLabel");
|
|
21
|
+
const ctaHref = field(item, "ctaHref");
|
|
11
22
|
---
|
|
12
23
|
|
|
13
|
-
<section class:list={["hero py-20", colorwayClass(colorway)]}>
|
|
14
|
-
<div class:list={["hero-content flex-col", alignClass(align)]}>
|
|
24
|
+
<section class:list={["hero py-20", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
25
|
+
<div class:list={["hero-content flex-col", alignClass(setting(item, "align", "center"))]}>
|
|
15
26
|
<div class="max-w-2xl">
|
|
16
|
-
<h1 class="text-5xl font-bold">
|
|
17
|
-
|
|
27
|
+
<Editable as="h1" base={base} field="heading" edit={edit} class="text-5xl font-bold">
|
|
28
|
+
{heading}
|
|
29
|
+
</Editable>
|
|
30
|
+
{subheading && (
|
|
31
|
+
<Editable
|
|
32
|
+
as="p"
|
|
33
|
+
base={base}
|
|
34
|
+
field="subheading"
|
|
35
|
+
edit={edit}
|
|
36
|
+
multiline
|
|
37
|
+
class="py-6 text-lg opacity-80"
|
|
38
|
+
>
|
|
39
|
+
{subheading}
|
|
40
|
+
</Editable>
|
|
41
|
+
)}
|
|
42
|
+
{/* The label is editable in place; the href is not — you can't point at a
|
|
43
|
+
URL, so it lives in the inspector (`inline: false` in the catalog). */}
|
|
18
44
|
{ctaLabel && ctaHref && (
|
|
19
|
-
<a class="btn btn-primary" href={ctaHref}>
|
|
45
|
+
<a class="btn btn-primary" href={ctaHref}>
|
|
46
|
+
<Editable as="span" base={base} field="ctaLabel" edit={edit}>{ctaLabel}</Editable>
|
|
47
|
+
</a>
|
|
20
48
|
)}
|
|
21
49
|
</div>
|
|
22
50
|
</div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Address, phone, and opening hours.
|
|
3
|
+
//
|
|
4
|
+
// Hours are a <dl>: each day is a term and its hours the description, which is
|
|
5
|
+
// what the pairing actually is. A table would imply columns that don't exist,
|
|
6
|
+
// and a list of "Monday 9–5" strings loses the association entirely.
|
|
7
|
+
import type { SectionRenderProps } from "../sections.js";
|
|
8
|
+
import { colorwayClass, field, itemField, list, setting } from "../sections.js";
|
|
9
|
+
import Editable from "../Editable.astro";
|
|
10
|
+
|
|
11
|
+
type Props = SectionRenderProps;
|
|
12
|
+
|
|
13
|
+
const { item, base, edit } = Astro.props;
|
|
14
|
+
|
|
15
|
+
const heading = field(item, "heading");
|
|
16
|
+
const address = field(item, "address");
|
|
17
|
+
const phone = field(item, "phone");
|
|
18
|
+
const items = list(item, "items");
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<section class:list={["py-16", colorwayClass(setting(item, "colorway", "base"))]}>
|
|
22
|
+
<div class="mx-auto grid max-w-4xl gap-10 px-6 sm:grid-cols-2">
|
|
23
|
+
<div>
|
|
24
|
+
{heading && (
|
|
25
|
+
<Editable as="h2" base={base} field="heading" edit={edit} class="text-3xl font-bold">
|
|
26
|
+
{heading}
|
|
27
|
+
</Editable>
|
|
28
|
+
)}
|
|
29
|
+
{address && (
|
|
30
|
+
<Editable as="address" base={base} field="address" edit={edit} multiline class="mt-4 not-italic opacity-80">
|
|
31
|
+
{address}
|
|
32
|
+
</Editable>
|
|
33
|
+
)}
|
|
34
|
+
{phone && (
|
|
35
|
+
/* The visible number stays editable; the tel: href is derived from it,
|
|
36
|
+
stripping the formatting a human reads but a dialler can't. */
|
|
37
|
+
<p class="mt-2">
|
|
38
|
+
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`}>
|
|
39
|
+
<Editable as="span" base={base} field="phone" edit={edit}>{phone}</Editable>
|
|
40
|
+
</a>
|
|
41
|
+
</p>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
{items.length > 0 && (
|
|
45
|
+
<dl class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1">
|
|
46
|
+
{items.map((row, i) => (
|
|
47
|
+
<>
|
|
48
|
+
<Editable as="dt" sfield={`${base}.items.${i}.day`} edit={edit} class="font-medium">
|
|
49
|
+
{itemField(row, "day")}
|
|
50
|
+
</Editable>
|
|
51
|
+
<Editable as="dd" sfield={`${base}.items.${i}.hours`} edit={edit} class="opacity-80">
|
|
52
|
+
{itemField(row, "hours")}
|
|
53
|
+
</Editable>
|
|
54
|
+
</>
|
|
55
|
+
))}
|
|
56
|
+
</dl>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</section>
|