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
|
@@ -11,9 +11,18 @@
|
|
|
11
11
|
// • PAGE field — pass `collection` + `key` + `field`; emits
|
|
12
12
|
// `data-louise-field="<collection>:<key>:<field>"` (saved as a
|
|
13
13
|
// versioned draft when the page is mounted with `versionedPageId`).
|
|
14
|
-
// • SECTION field — pass `
|
|
15
|
-
//
|
|
16
|
-
// for the
|
|
14
|
+
// • SECTION field — pass `base` (this item's path, e.g. `"2"` or `"2.blocks.0"`)
|
|
15
|
+
// plus `field`; emits `data-louise-sfield="<base>.<field>"`
|
|
16
|
+
// (+ `data-louise-multiline`) for the on-canvas section editor.
|
|
17
|
+
// `sfield` stays as the escape hatch for a path you build
|
|
18
|
+
// yourself — an array entry: sfield={`${base}.items.${i}.title`}.
|
|
19
|
+
//
|
|
20
|
+
// The `base` form is what ADR 0005 §2 asks for: "a site author writes `<Editable
|
|
21
|
+
// field="heading">` and never hand-stamps the deeper path". `<Section>` supplies
|
|
22
|
+
// `base`, so the SAME component renders correctly whether it sits at the top level
|
|
23
|
+
// (`"2.heading"`) or inside a block (`"2.blocks.0.heading"`) — the depth never
|
|
24
|
+
// appears in the component's source, which is what makes a section component
|
|
25
|
+
// reusable as a block component.
|
|
17
26
|
//
|
|
18
27
|
// Polymorphic via `as` (default `div`) with a typed `...rest` over the div's
|
|
19
28
|
// attributes; edit mode is read from context (`Astro.locals.editMode`) unless the
|
|
@@ -27,9 +36,14 @@ interface Props extends HTMLAttributes<"div"> {
|
|
|
27
36
|
collection?: string;
|
|
28
37
|
/** PAGE-field mode: the row key/id the field is on. */
|
|
29
38
|
key?: string | number;
|
|
30
|
-
/** PAGE-field mode: the field name (e.g. `"title"`, `"body"`).
|
|
39
|
+
/** PAGE-field mode: the field name (e.g. `"title"`, `"body"`). Also the field
|
|
40
|
+
* name in SECTION mode, where it's joined onto `base`. */
|
|
31
41
|
field?: string;
|
|
32
|
-
/** SECTION-field mode:
|
|
42
|
+
/** SECTION-field mode: this item's path prefix (`"2"`, `"2.blocks.0"`),
|
|
43
|
+
* supplied by `<Section>`. Combined with `field` to build the marker. */
|
|
44
|
+
base?: string;
|
|
45
|
+
/** SECTION-field mode, explicit: the full `<index>.<path>` marker. Overrides
|
|
46
|
+
* `base`+`field`, for paths a component builds itself (array entries). */
|
|
33
47
|
sfield?: string;
|
|
34
48
|
/** Rich text (ProseKit) vs a single-line contenteditable. Default `"text"`. */
|
|
35
49
|
type?: "text" | "richtext";
|
|
@@ -44,6 +58,7 @@ const {
|
|
|
44
58
|
collection,
|
|
45
59
|
key,
|
|
46
60
|
field,
|
|
61
|
+
base,
|
|
47
62
|
sfield,
|
|
48
63
|
type = "text",
|
|
49
64
|
multiline = false,
|
|
@@ -51,16 +66,25 @@ const {
|
|
|
51
66
|
...rest
|
|
52
67
|
} = Astro.props;
|
|
53
68
|
|
|
69
|
+
// An explicit `sfield` wins; otherwise `base` + `field` compose one. Both absent
|
|
70
|
+
// means this isn't a section field at all, and the page-field branch below runs.
|
|
71
|
+
const sectionPath = sfield ?? (base !== undefined && field ? `${base}.${field}` : undefined);
|
|
72
|
+
|
|
54
73
|
const editing = edit ?? (Astro.locals as { editMode?: boolean }).editMode ?? false;
|
|
55
|
-
|
|
74
|
+
// Annotated rather than inferred: without it the ternary widens to a union of
|
|
75
|
+
// two object shapes, one of which carries `"data-louise-type"?: undefined`, and
|
|
76
|
+
// spreading that into `Record<string, string>` is a type error.
|
|
77
|
+
const richText: Record<string, string> =
|
|
78
|
+
type === "richtext" ? { "data-louise-type": "richtext" } : {};
|
|
79
|
+
const multilineMarker: Record<string, string> = multiline ? { "data-louise-multiline": "" } : {};
|
|
56
80
|
|
|
57
81
|
let markers: Record<string, string> = {};
|
|
58
82
|
if (editing) {
|
|
59
|
-
if (
|
|
83
|
+
if (sectionPath) {
|
|
60
84
|
markers = {
|
|
61
|
-
"data-louise-sfield":
|
|
85
|
+
"data-louise-sfield": sectionPath,
|
|
62
86
|
...richText,
|
|
63
|
-
...
|
|
87
|
+
...multilineMarker,
|
|
64
88
|
};
|
|
65
89
|
} else if (collection && key !== undefined && field) {
|
|
66
90
|
markers = { "data-louise-field": `${collection}:${key}:${field}`, ...richText };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
---
|
|
2
|
+
// `<JustifiedGallery>` — Flickr-style row-balanced photo grid (#257).
|
|
3
|
+
//
|
|
4
|
+
// Images keep their aspect ratios, rows fill the container exactly, and row
|
|
5
|
+
// heights land near a target. CSS alone can't do it: `grid` wants uniform
|
|
6
|
+
// tracks, and `columns` gives a masonry *column* flow where reading order runs
|
|
7
|
+
// down the page instead of across it — wrong for a portfolio, and wrong for
|
|
8
|
+
// keyboard/screen-reader order.
|
|
9
|
+
//
|
|
10
|
+
// Two layers, so it never depends on JavaScript to be usable:
|
|
11
|
+
//
|
|
12
|
+
// 1. SSR emits a flex-wrap floor. Each tile carries its declared aspect ratio
|
|
13
|
+
// (from the media library's recorded dimensions) as `flex-grow`/`flex-basis`,
|
|
14
|
+
// which already produces a justified, gap-correct grid with no layout shift
|
|
15
|
+
// — that's what someone with JS blocked, or on the first paint before the
|
|
16
|
+
// bundle lands, sees.
|
|
17
|
+
// 2. On the client, once the images decode and their TRUE dimensions are known,
|
|
18
|
+
// `justifyRows` recomputes exact rows and pins each tile's box. This is the
|
|
19
|
+
// pass that fixes the common case where the media row's dimensions are
|
|
20
|
+
// missing, stale, or transposed.
|
|
21
|
+
//
|
|
22
|
+
// The refinement is deliberately a no-op when the declared ratios were already
|
|
23
|
+
// right, so the correction isn't visible as a jump on a well-populated library.
|
|
24
|
+
//
|
|
25
|
+
// CSP: the layout script is a normal (bundled) `<script>`, so Astro hashes it
|
|
26
|
+
// into `script-src` — no `is:inline`, no `unsafe-inline`. Per-tile sizing is
|
|
27
|
+
// written as inline `style`, which is what the `style-src` rewrite in #253
|
|
28
|
+
// exists for.
|
|
29
|
+
import { DEFAULT_ASPECT, type GalleryItem } from "./justify.js";
|
|
30
|
+
import MediaSlot from "./MediaSlot.astro";
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
items: GalleryItem[];
|
|
34
|
+
/** Row height to aim for, in px. Default 260. */
|
|
35
|
+
targetHeight?: number;
|
|
36
|
+
/** Gap between tiles, in px. Default 8. */
|
|
37
|
+
gap?: number;
|
|
38
|
+
/** Fade/rise tiles in as they scroll into view. Default true; automatically
|
|
39
|
+
* inert under `prefers-reduced-motion`. */
|
|
40
|
+
reveal?: boolean;
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { items, targetHeight = 260, gap = 8, reveal = true, class: className } = Astro.props;
|
|
45
|
+
|
|
46
|
+
const aspectOf = (item: GalleryItem) =>
|
|
47
|
+
item.width && item.height && item.height > 0 ? item.width / item.height : DEFAULT_ASPECT;
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
<div
|
|
51
|
+
class:list={["astroid-gallery", reveal && "astroid-gallery--reveal", className]}
|
|
52
|
+
data-astroid-gallery
|
|
53
|
+
data-target-height={targetHeight}
|
|
54
|
+
data-gap={gap}
|
|
55
|
+
style={`--astroid-gallery-gap:${gap}px;--astroid-gallery-row:${targetHeight}px`}
|
|
56
|
+
>
|
|
57
|
+
{items.map((item) => {
|
|
58
|
+
const aspect = aspectOf(item);
|
|
59
|
+
const media = (
|
|
60
|
+
<MediaSlot
|
|
61
|
+
src={item.src}
|
|
62
|
+
alt={item.alt}
|
|
63
|
+
width={Math.round(targetHeight * aspect * 2)}
|
|
64
|
+
sizes={`${Math.round(targetHeight * aspect)}px`}
|
|
65
|
+
loading="lazy"
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
return (
|
|
69
|
+
<figure
|
|
70
|
+
class="astroid-gallery__tile"
|
|
71
|
+
data-aspect={aspect}
|
|
72
|
+
style={`flex-grow:${aspect};flex-basis:${Math.round(aspect * targetHeight)}px`}
|
|
73
|
+
>
|
|
74
|
+
{/* Always a <figure>, with the link INSIDE it: <figcaption> is only
|
|
75
|
+
valid as a child of <figure>, so wrapping the whole tile in an <a>
|
|
76
|
+
would emit invalid markup whenever a linked tile has a caption. */}
|
|
77
|
+
{item.href ? (
|
|
78
|
+
<a
|
|
79
|
+
href={item.href}
|
|
80
|
+
class="astroid-gallery__link"
|
|
81
|
+
{...(item.alt ? {} : { "aria-label": item.caption ?? "View image" })}
|
|
82
|
+
>
|
|
83
|
+
{media}
|
|
84
|
+
</a>
|
|
85
|
+
) : (
|
|
86
|
+
media
|
|
87
|
+
)}
|
|
88
|
+
{item.caption && <figcaption class="astroid-gallery__caption">{item.caption}</figcaption>}
|
|
89
|
+
</figure>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.astroid-gallery {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-wrap: wrap;
|
|
98
|
+
gap: var(--astroid-gallery-gap, 8px);
|
|
99
|
+
}
|
|
100
|
+
.astroid-gallery__tile {
|
|
101
|
+
display: block;
|
|
102
|
+
position: relative;
|
|
103
|
+
/* The floor: height fixed to the target row, width proportional to aspect.
|
|
104
|
+
flex-grow then stretches each row to fill. Once the script takes over it
|
|
105
|
+
writes explicit width/height and these stop mattering. */
|
|
106
|
+
height: var(--astroid-gallery-row, 260px);
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
margin: 0;
|
|
110
|
+
}
|
|
111
|
+
.astroid-gallery__link {
|
|
112
|
+
display: block;
|
|
113
|
+
width: 100%;
|
|
114
|
+
height: 100%;
|
|
115
|
+
}
|
|
116
|
+
.astroid-gallery__tile :global(.astroid-media),
|
|
117
|
+
.astroid-gallery__tile :global(.astroid-media > img) {
|
|
118
|
+
width: 100%;
|
|
119
|
+
height: 100%;
|
|
120
|
+
}
|
|
121
|
+
.astroid-gallery__caption {
|
|
122
|
+
position: absolute;
|
|
123
|
+
inset-inline: 0;
|
|
124
|
+
bottom: 0;
|
|
125
|
+
padding: 0.5rem 0.75rem;
|
|
126
|
+
font-size: 0.8125rem;
|
|
127
|
+
color: #fff;
|
|
128
|
+
background: linear-gradient(transparent, rgb(0 0 0 / 0.6));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Reveal-on-scroll. The visible state is the DEFAULT so that a browser with
|
|
132
|
+
no IntersectionObserver — or no JS at all — shows every tile; the script
|
|
133
|
+
opts tiles into the hidden state only once it can guarantee it will undo it. */
|
|
134
|
+
.astroid-gallery--reveal .astroid-gallery__tile[data-reveal="pending"] {
|
|
135
|
+
opacity: 0;
|
|
136
|
+
transform: translateY(12px);
|
|
137
|
+
}
|
|
138
|
+
.astroid-gallery--reveal .astroid-gallery__tile {
|
|
139
|
+
transition:
|
|
140
|
+
opacity 400ms ease,
|
|
141
|
+
transform 400ms ease;
|
|
142
|
+
}
|
|
143
|
+
@media (prefers-reduced-motion: reduce) {
|
|
144
|
+
.astroid-gallery--reveal .astroid-gallery__tile {
|
|
145
|
+
transition: none;
|
|
146
|
+
}
|
|
147
|
+
.astroid-gallery--reveal .astroid-gallery__tile[data-reveal="pending"] {
|
|
148
|
+
opacity: 1;
|
|
149
|
+
transform: none;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
153
|
+
|
|
154
|
+
<script>
|
|
155
|
+
import { justifyRows } from "./justify.js";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Lay out one gallery precisely, using whatever dimensions are known right
|
|
159
|
+
* now. Called on mount, after each image decodes, and on resize.
|
|
160
|
+
*/
|
|
161
|
+
function layout(root: HTMLElement) {
|
|
162
|
+
const tiles = [...root.querySelectorAll<HTMLElement>(".astroid-gallery__tile")];
|
|
163
|
+
if (tiles.length === 0) return;
|
|
164
|
+
|
|
165
|
+
const targetHeight = Number(root.dataset.targetHeight) || 260;
|
|
166
|
+
const gap = Number(root.dataset.gap) || 8;
|
|
167
|
+
// clientWidth excludes the scrollbar, which offsetWidth does not — using the
|
|
168
|
+
// wrong one overflows the row by the scrollbar width on Windows.
|
|
169
|
+
const containerWidth = root.clientWidth;
|
|
170
|
+
|
|
171
|
+
const rows = justifyRows(
|
|
172
|
+
tiles.map((tile) => ({ aspect: Number(tile.dataset.aspect) || 0 })),
|
|
173
|
+
{ containerWidth, targetHeight, gap },
|
|
174
|
+
);
|
|
175
|
+
// No measurable width yet (hidden tab, display:none ancestor). Leave the CSS
|
|
176
|
+
// floor in place rather than writing zero-size boxes over it.
|
|
177
|
+
if (rows.length === 0) return;
|
|
178
|
+
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
for (const box of row.boxes) {
|
|
181
|
+
const tile = tiles[box.index];
|
|
182
|
+
tile.style.flex = "0 0 auto";
|
|
183
|
+
tile.style.width = `${box.width}px`;
|
|
184
|
+
tile.style.height = `${box.height}px`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Correct a tile's aspect ratio from the decoded image, then re-lay out. */
|
|
190
|
+
function refineFromDecode(root: HTMLElement) {
|
|
191
|
+
const images = [...root.querySelectorAll<HTMLImageElement>(".astroid-gallery__tile img")];
|
|
192
|
+
let pending = images.length;
|
|
193
|
+
if (pending === 0) return;
|
|
194
|
+
|
|
195
|
+
const settle = (img: HTMLImageElement) => {
|
|
196
|
+
const tile = img.closest<HTMLElement>(".astroid-gallery__tile");
|
|
197
|
+
if (tile && img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
198
|
+
tile.dataset.aspect = String(img.naturalWidth / img.naturalHeight);
|
|
199
|
+
}
|
|
200
|
+
// Re-lay out once the batch settles rather than per image: a 60-image
|
|
201
|
+
// gallery would otherwise run 60 full layouts during load.
|
|
202
|
+
if (--pending === 0) layout(root);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
for (const img of images) {
|
|
206
|
+
if (img.complete) settle(img);
|
|
207
|
+
else {
|
|
208
|
+
img.addEventListener("load", () => settle(img), { once: true });
|
|
209
|
+
// A broken image still has to release the counter, or the refinement
|
|
210
|
+
// pass never runs and the gallery keeps its declared ratios forever.
|
|
211
|
+
img.addEventListener("error", () => settle(img), { once: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function revealOnScroll(root: HTMLElement) {
|
|
217
|
+
if (!root.classList.contains("astroid-gallery--reveal")) return;
|
|
218
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
219
|
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
|
220
|
+
|
|
221
|
+
const tiles = [...root.querySelectorAll<HTMLElement>(".astroid-gallery__tile")];
|
|
222
|
+
// Only hide tiles now that an observer is guaranteed to reveal them.
|
|
223
|
+
for (const tile of tiles) tile.dataset.reveal = "pending";
|
|
224
|
+
|
|
225
|
+
const observer = new IntersectionObserver(
|
|
226
|
+
(entries) => {
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (!entry.isIntersecting) continue;
|
|
229
|
+
(entry.target as HTMLElement).dataset.reveal = "shown";
|
|
230
|
+
observer.unobserve(entry.target);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
{ rootMargin: "64px" },
|
|
234
|
+
);
|
|
235
|
+
for (const tile of tiles) observer.observe(tile);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const root of document.querySelectorAll<HTMLElement>("[data-astroid-gallery]")) {
|
|
239
|
+
layout(root);
|
|
240
|
+
refineFromDecode(root);
|
|
241
|
+
revealOnScroll(root);
|
|
242
|
+
|
|
243
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
244
|
+
let frame = 0;
|
|
245
|
+
// Coalesce to one layout per animation frame — a drag-resize fires this
|
|
246
|
+
// continuously, and each run touches every tile's style.
|
|
247
|
+
const observer = new ResizeObserver(() => {
|
|
248
|
+
cancelAnimationFrame(frame);
|
|
249
|
+
frame = requestAnimationFrame(() => layout(root));
|
|
250
|
+
});
|
|
251
|
+
observer.observe(root);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
</script>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
// `<MediaSlot>` — the responsive-image primitive (#257).
|
|
3
|
+
//
|
|
4
|
+
// One place that knows how to put a media-library image on a page correctly:
|
|
5
|
+
// a width-descriptor `srcset` over Cloudflare Image Resizing, a `sizes` hint so
|
|
6
|
+
// the browser picks the right derivative *before* layout, a reserved aspect-ratio
|
|
7
|
+
// box so the image can't shift the page as it loads, and lazy/async decoding by
|
|
8
|
+
// default.
|
|
9
|
+
//
|
|
10
|
+
// The math is not new — `louise-toolkit/media` already ships `cfImageSrcset`
|
|
11
|
+
// (width descriptors, ratio-derived heights, cover-crop gravity, `format=auto`)
|
|
12
|
+
// and `circleImage`. What every consuming site rebuilt by hand was the *component*
|
|
13
|
+
// around it: the ratio box, the `sizes` string, the focal-point crop, and
|
|
14
|
+
// remembering that `loading="lazy"` on an above-the-fold hero is a Largest
|
|
15
|
+
// Contentful Paint regression. That's what this owns.
|
|
16
|
+
//
|
|
17
|
+
// CSP: the focal/zoom knobs emit a data-driven inline `style` attribute. That is
|
|
18
|
+
// exactly the case astroid's middleware rewrites `style-src` for (#253) — scripts
|
|
19
|
+
// stay hash-pinned with no `unsafe-inline`.
|
|
20
|
+
import { cfImageSrcset, circleImage } from "louise-toolkit/media";
|
|
21
|
+
import type { HTMLAttributes } from "astro/types";
|
|
22
|
+
|
|
23
|
+
interface Props extends Omit<HTMLAttributes<"img">, "src" | "srcset" | "sizes" | "width" | "height"> {
|
|
24
|
+
/** Image URL — a media-library asset, or any URL on a resizing-enabled zone. */
|
|
25
|
+
src: string;
|
|
26
|
+
/**
|
|
27
|
+
* Alternative text. REQUIRED, and `""` is a legitimate value: an image that
|
|
28
|
+
* carries no information the surrounding copy doesn't already give (a
|
|
29
|
+
* decorative texture, a repeated brand mark) should be `alt=""` so screen
|
|
30
|
+
* readers skip it. What must never happen is the attribute going missing,
|
|
31
|
+
* which makes assistive tech read the filename aloud.
|
|
32
|
+
*/
|
|
33
|
+
alt: string;
|
|
34
|
+
/**
|
|
35
|
+
* The largest width, in CSS px, this image is displayed at on a 1× screen.
|
|
36
|
+
* Drives the srcset ladder — not a hard render width.
|
|
37
|
+
*/
|
|
38
|
+
width?: number;
|
|
39
|
+
/**
|
|
40
|
+
* The `sizes` attribute: how wide the image renders at each breakpoint, e.g.
|
|
41
|
+
* `"(max-width: 640px) 100vw, 33vw"`. Without it the browser assumes `100vw`
|
|
42
|
+
* and over-fetches on every multi-column layout, which is the single most
|
|
43
|
+
* common way a "responsive" image ends up slower than a fixed one.
|
|
44
|
+
*/
|
|
45
|
+
sizes?: string;
|
|
46
|
+
/** Aspect ratio as `"w/h"` (e.g. `"16/9"`). Reserves the box and derives each
|
|
47
|
+
* derivative's height so the crop matches what's shown — no wasted pixels. */
|
|
48
|
+
ratio?: string;
|
|
49
|
+
/** `rect` (default) or `circle`. A circle uses a square focal crop + 2× retina. */
|
|
50
|
+
shape?: "rect" | "circle";
|
|
51
|
+
/** Rendered diameter in px when `shape="circle"`. Default 96. */
|
|
52
|
+
size?: number;
|
|
53
|
+
/** Resize behaviour. Default `cover`. */
|
|
54
|
+
fit?: "cover" | "contain" | "scale-down" | "crop" | "pad";
|
|
55
|
+
/** Focal point for cover-crops. Default `auto` (Cloudflare picks the subject). */
|
|
56
|
+
gravity?: "auto" | "center" | "left" | "right" | "top" | "bottom";
|
|
57
|
+
/** Encode quality, 1–100. Default 82. */
|
|
58
|
+
quality?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Focal point as percentages, for when `gravity: auto` picks wrong — a portrait
|
|
61
|
+
* where the subject sits high, say. Applied as `object-position`, so it's a
|
|
62
|
+
* render-time framing choice and the same derivative is reused.
|
|
63
|
+
*/
|
|
64
|
+
focal?: { x: number; y: number };
|
|
65
|
+
/** Scale factor inside the box, for a punch-in crop. Default 1. */
|
|
66
|
+
zoom?: number;
|
|
67
|
+
/**
|
|
68
|
+
* `lazy` (default) or `eager`. Set `eager` — and `priority` — for anything
|
|
69
|
+
* above the fold: lazy-loading the LCP image delays it by a full network
|
|
70
|
+
* round-trip after layout.
|
|
71
|
+
*/
|
|
72
|
+
loading?: "lazy" | "eager";
|
|
73
|
+
/** Hint this as the LCP candidate (sets `fetchpriority="high"`). */
|
|
74
|
+
priority?: boolean;
|
|
75
|
+
/** Optional caption; renders a `<figure>`/`<figcaption>` instead of a bare img. */
|
|
76
|
+
caption?: string;
|
|
77
|
+
/** Extra classes on the wrapper. */
|
|
78
|
+
class?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const {
|
|
82
|
+
src,
|
|
83
|
+
alt,
|
|
84
|
+
width = 1200,
|
|
85
|
+
sizes,
|
|
86
|
+
ratio,
|
|
87
|
+
shape = "rect",
|
|
88
|
+
size = 96,
|
|
89
|
+
fit = "cover",
|
|
90
|
+
gravity = "auto",
|
|
91
|
+
quality = 82,
|
|
92
|
+
focal,
|
|
93
|
+
zoom = 1,
|
|
94
|
+
loading = "lazy",
|
|
95
|
+
priority = false,
|
|
96
|
+
caption,
|
|
97
|
+
class: className,
|
|
98
|
+
...rest
|
|
99
|
+
} = Astro.props;
|
|
100
|
+
|
|
101
|
+
const circle = shape === "circle";
|
|
102
|
+
const derived = circle
|
|
103
|
+
? circleImage(src, size)
|
|
104
|
+
: cfImageSrcset(src, { width, ratio, fit, gravity, quality });
|
|
105
|
+
|
|
106
|
+
// Without a `sizes` hint the browser assumes the image fills the viewport and
|
|
107
|
+
// picks the largest candidate. A circle's rendered width is known exactly, so
|
|
108
|
+
// state it; a rect defaults to its 1× width, which is right for a fixed-width
|
|
109
|
+
// placement and should be overridden for a fluid one.
|
|
110
|
+
const sizesAttr = sizes ?? (circle ? `${size}px` : `${width}px`);
|
|
111
|
+
|
|
112
|
+
// Framing lives on the <img>: `object-position` moves the visible window of a
|
|
113
|
+
// cover-crop, `scale` punches in. Both are per-placement, so they must NOT go
|
|
114
|
+
// through the CDN — that would mint a second derivative of the same source.
|
|
115
|
+
const frame = [
|
|
116
|
+
focal ? `object-position:${focal.x}% ${focal.y}%` : "",
|
|
117
|
+
zoom !== 1 ? `transform:scale(${zoom})` : "",
|
|
118
|
+
]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(";");
|
|
121
|
+
|
|
122
|
+
// Reserve the box before the bytes arrive. Skipping this is what makes a gallery
|
|
123
|
+
// jump as it loads (Cumulative Layout Shift); `aspect-ratio` costs nothing and
|
|
124
|
+
// fixes it outright.
|
|
125
|
+
const boxStyle = circle
|
|
126
|
+
? `width:${size}px;height:${size}px`
|
|
127
|
+
: ratio
|
|
128
|
+
? `aspect-ratio:${ratio.replace("/", " / ")}`
|
|
129
|
+
: "";
|
|
130
|
+
|
|
131
|
+
const img = {
|
|
132
|
+
src: derived.src,
|
|
133
|
+
srcset: derived.srcset,
|
|
134
|
+
// A circle's srcset uses DPR descriptors (`1x`/`2x`), which `sizes` does not
|
|
135
|
+
// apply to — supplying it there would be ignored at best and confusing at worst.
|
|
136
|
+
...(circle ? {} : { sizes: sizesAttr }),
|
|
137
|
+
alt,
|
|
138
|
+
loading,
|
|
139
|
+
decoding: "async" as const,
|
|
140
|
+
...(priority ? { fetchpriority: "high" as const } : {}),
|
|
141
|
+
...(frame ? { style: frame } : {}),
|
|
142
|
+
};
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
{caption ? (
|
|
146
|
+
<figure class:list={["astroid-media", circle && "astroid-media--circle", className]} style={boxStyle} {...rest}>
|
|
147
|
+
<img {...img} />
|
|
148
|
+
<figcaption>{caption}</figcaption>
|
|
149
|
+
</figure>
|
|
150
|
+
) : (
|
|
151
|
+
<div class:list={["astroid-media", circle && "astroid-media--circle", className]} style={boxStyle} {...rest}>
|
|
152
|
+
<img {...img} />
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<style>
|
|
157
|
+
.astroid-media {
|
|
158
|
+
display: block;
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
margin: 0;
|
|
161
|
+
}
|
|
162
|
+
.astroid-media > img {
|
|
163
|
+
display: block;
|
|
164
|
+
width: 100%;
|
|
165
|
+
height: 100%;
|
|
166
|
+
object-fit: cover;
|
|
167
|
+
}
|
|
168
|
+
/* The mask lives on the wrapper, not the image, so `transform: scale()` from
|
|
169
|
+
`zoom` is clipped by the circle instead of escaping it. */
|
|
170
|
+
.astroid-media--circle {
|
|
171
|
+
border-radius: 9999px;
|
|
172
|
+
}
|
|
173
|
+
.astroid-media > figcaption {
|
|
174
|
+
margin-top: 0.5rem;
|
|
175
|
+
font-size: 0.875rem;
|
|
176
|
+
opacity: 0.7;
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
// `<PortalShell>` — the account-area chrome: a sidebar of nav items and a main
|
|
3
|
+
// column, collapsing to a burger on small screens.
|
|
4
|
+
//
|
|
5
|
+
// Theme-tokened, never brand-styled. Every colour here is a daisyUI token
|
|
6
|
+
// (`base-100`, `base-content`, `primary`), so it inherits whatever theme the
|
|
7
|
+
// project's `theme.colors` generated and a site can restyle it by changing the
|
|
8
|
+
// theme rather than forking the component.
|
|
9
|
+
//
|
|
10
|
+
// The burger is a `<details>` element, not a Solid island: the portal's chrome
|
|
11
|
+
// shouldn't ship or await JavaScript to open a menu, and `<details>` gets
|
|
12
|
+
// keyboard behaviour, focus handling, and Escape from the browser for free.
|
|
13
|
+
import type { PortalNav } from "astroidjs";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
nav: PortalNav;
|
|
17
|
+
/** The signed-in user's role — decides which nav items are visible. */
|
|
18
|
+
role?: string | null;
|
|
19
|
+
/** Current path, for the active highlight. Defaults to the rendered URL. */
|
|
20
|
+
path?: string;
|
|
21
|
+
/** Heading above the nav, e.g. the account holder's name. */
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { nav, role, path, title } = Astro.props;
|
|
26
|
+
const current = path ?? Astro.url.pathname;
|
|
27
|
+
|
|
28
|
+
// Filtered by role BEFORE render: an item the viewer can't reach is never
|
|
29
|
+
// drawn, rather than drawn and then 403'd — which reads as a broken link.
|
|
30
|
+
const items = nav.forRole(role);
|
|
31
|
+
const active = nav.activeFor(current);
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div class="flex min-h-screen flex-col md:flex-row">
|
|
35
|
+
{/* Mobile: a disclosure. No island, no hydration wait. */}
|
|
36
|
+
<details class="md:hidden border-b border-base-300 bg-base-200">
|
|
37
|
+
<summary class="cursor-pointer list-none px-4 py-3 font-medium">
|
|
38
|
+
<span aria-hidden="true">☰</span>
|
|
39
|
+
<span class="ml-2">{active?.label ?? title ?? "Menu"}</span>
|
|
40
|
+
</summary>
|
|
41
|
+
<nav aria-label="Portal" class="px-2 pb-3">
|
|
42
|
+
<ul class="menu w-full">
|
|
43
|
+
{
|
|
44
|
+
items.map((item) => (
|
|
45
|
+
<li>
|
|
46
|
+
<a href={item.href} aria-current={item === active ? "page" : undefined}>
|
|
47
|
+
{item.label}
|
|
48
|
+
</a>
|
|
49
|
+
</li>
|
|
50
|
+
))
|
|
51
|
+
}
|
|
52
|
+
</ul>
|
|
53
|
+
</nav>
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
<aside class="hidden md:flex md:w-64 md:shrink-0 md:flex-col border-r border-base-300 bg-base-200">
|
|
57
|
+
{title && <p class="px-4 pt-5 pb-2 text-sm font-semibold opacity-70">{title}</p>}
|
|
58
|
+
<nav aria-label="Portal" class="p-2">
|
|
59
|
+
<ul class="menu w-full gap-1">
|
|
60
|
+
{
|
|
61
|
+
items.map((item) => (
|
|
62
|
+
<li>
|
|
63
|
+
<a
|
|
64
|
+
href={item.href}
|
|
65
|
+
class={item === active ? "menu-active font-medium" : undefined}
|
|
66
|
+
aria-current={item === active ? "page" : undefined}
|
|
67
|
+
>
|
|
68
|
+
{item.label}
|
|
69
|
+
</a>
|
|
70
|
+
</li>
|
|
71
|
+
))
|
|
72
|
+
}
|
|
73
|
+
</ul>
|
|
74
|
+
</nav>
|
|
75
|
+
</aside>
|
|
76
|
+
|
|
77
|
+
<main class="min-w-0 flex-1 bg-base-100 px-4 py-6 md:px-8 md:py-10">
|
|
78
|
+
<slot />
|
|
79
|
+
</main>
|
|
80
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
// `<RegisterSW>` — registers the PWA service worker.
|
|
3
|
+
//
|
|
4
|
+
// A bundled `<script>`, never an inline one: Astro hashes it into `script-src`,
|
|
5
|
+
// so this works under the strict CSP without `unsafe-inline`. That's also why
|
|
6
|
+
// the scope rides on a `data-` attribute rather than being interpolated into
|
|
7
|
+
// the script — `define:vars` forces `is:inline`, whose content varies per
|
|
8
|
+
// render and therefore can't be hashed.
|
|
9
|
+
//
|
|
10
|
+
// Registration is best-effort by design. A service worker is an enhancement:
|
|
11
|
+
// if it fails, or the browser has none, the app must still work. The one thing
|
|
12
|
+
// worth being loud about is a worker that fails to *install* while claiming to
|
|
13
|
+
// be there, which is why the catch logs rather than swallowing silently.
|
|
14
|
+
interface Props {
|
|
15
|
+
/**
|
|
16
|
+
* URL prefix this worker controls. Must match the `scope` the manifest and
|
|
17
|
+
* the generated worker were built with.
|
|
18
|
+
*
|
|
19
|
+
* A scope narrower than the script's own location needs no
|
|
20
|
+
* `Service-Worker-Allowed` header; a broader one does. With `sw.js` served
|
|
21
|
+
* from the root, every scope is narrower, so no header is required.
|
|
22
|
+
*/
|
|
23
|
+
scope?: string;
|
|
24
|
+
/** Path to the worker. Default `/sw.js`. */
|
|
25
|
+
src?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { scope = "/", src = "/sw.js" } = Astro.props;
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<div data-astroid-sw hidden data-scope={scope} data-src={src}></div>
|
|
32
|
+
|
|
33
|
+
<script>
|
|
34
|
+
const el = document.querySelector<HTMLElement>("[data-astroid-sw]");
|
|
35
|
+
if (el && "serviceWorker" in navigator) {
|
|
36
|
+
navigator.serviceWorker
|
|
37
|
+
.register(el.dataset.src ?? "/sw.js", { scope: el.dataset.scope ?? "/" })
|
|
38
|
+
.catch((error) => {
|
|
39
|
+
// Not fatal — the app works without it. Logged rather than swallowed so
|
|
40
|
+
// a scope/MIME misconfiguration is findable instead of just "the PWA
|
|
41
|
+
// doesn't install and nobody knows why".
|
|
42
|
+
console.warn("[astroid] service worker registration failed", error);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
</script>
|