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.
Files changed (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +42 -8
  96. package/dist/schema/collections.js +102 -8
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -5
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. 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 `sfield` (the `<index>.<path>` into the section's data,
15
- // ADR 0003 §5); emits `data-louise-sfield` (+ `data-louise-multiline`),
16
- // for the structured `<Section>` editor.
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: the `<index>.<path>` marker for the section editor. */
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
- const richText = type === "richtext" ? { "data-louise-type": "richtext" } : {};
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 (sfield) {
83
+ if (sectionPath) {
60
84
  markers = {
61
- "data-louise-sfield": sfield,
85
+ "data-louise-sfield": sectionPath,
62
86
  ...richText,
63
- ...(multiline ? { "data-louise-multiline": "" } : {}),
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>