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
@@ -1,46 +1,112 @@
1
1
  ---
2
- // `<Section>` — the section-library dispatcher (ADR 0003, item 2). It's a
3
- // discriminated union over `SectionKind` (see ./sections.ts): `<Section
4
- // kind="hero" heading="…" />` requires hero fields and rejects another kind's, and
5
- // each arm renders the matching section-library component. Bespoke, site-owned
6
- // sections stay first-class (as the reference site's hand-written ones are) — this
7
- // is for the catalog sections, not a mandate to route everything through it.
8
- import type { SectionProps } from "./sections.js";
2
+ // `<Section>` — the section-library dispatcher (ADR 0003 item 2, on the ADR 0005
3
+ // model).
4
+ //
5
+ // It renders ONE stored `SectionItem`: dispatch on `_type`, hand the component
6
+ // its `base` path, and let the component own every pixel. What changed from the
7
+ // old `kind`-prop version is where the truth lives — the item is data loaded from
8
+ // the page's `sections` column, not props a template hand-wrote, so what renders
9
+ // is exactly what the on-canvas editor edits in place.
10
+ //
11
+ // Two marker responsibilities, split the way ADR 0005 §2 splits them:
12
+ // • the BOUNDARY (`data-louise-section` / `data-louise-block`) is stamped here,
13
+ // because only the dispatcher knows an item's position;
14
+ // • the FIELDS (`data-louise-sfield`) are stamped by `<Editable base={base}
15
+ // field="…">` inside each component, because only the component knows which
16
+ // of its text nodes are editable.
17
+ //
18
+ // Blocks recurse through this same component with a deeper `base`. That's the
19
+ // whole trick: a component never learns its own depth, so a type that renders as
20
+ // a section renders unchanged as a block.
21
+ import type { MediaMeta, SectionItem, SectionRenderProps } from "./sections.js";
22
+ import AboutIntro from "./sections/AboutIntro.astro";
23
+ import Banner from "./sections/Banner.astro";
9
24
  import Contact from "./sections/Contact.astro";
10
25
  import Cta from "./sections/Cta.astro";
26
+ import Faq from "./sections/Faq.astro";
11
27
  import FeatureGrid from "./sections/FeatureGrid.astro";
28
+ import Gallery from "./sections/Gallery.astro";
12
29
  import Hero from "./sections/Hero.astro";
30
+ import LocationHours from "./sections/LocationHours.astro";
31
+ import Media from "./sections/Media.astro";
32
+ import PricingTiers from "./sections/PricingTiers.astro";
33
+ import ProductGrid from "./sections/ProductGrid.astro";
34
+ import SplitImage from "./sections/SplitImage.astro";
35
+ import Steps from "./sections/Steps.astro";
36
+ import Testimonial from "./sections/Testimonial.astro";
13
37
 
14
- const p = Astro.props as SectionProps;
38
+ interface Props {
39
+ item: SectionItem;
40
+ /** This item's path within the page's `sections` array: `"2"` at the top
41
+ * level, `"2.blocks.0"` for a block. */
42
+ base: string;
43
+ /** Force edit markers on/off. Defaults to `Astro.locals.editMode`. */
44
+ edit?: boolean;
45
+ /** Page-wide media alt/caption, resolved once by `<Sections>`. */
46
+ mediaMeta?: MediaMeta;
47
+ }
48
+
49
+ /**
50
+ * `_type` → render component. A map rather than a chain of `{type === "x" && …}`
51
+ * for one reason that matters: it can be checked. A catalog entry whose type is
52
+ * missing here renders NOTHING — a silent hole in the page rather than an error
53
+ * — so the test suite asserts these keys and the catalog's are the same set.
54
+ */
55
+ const COMPONENTS: Record<string, ((props: SectionRenderProps) => unknown) | undefined> = {
56
+ hero: Hero,
57
+ featureGrid: FeatureGrid,
58
+ cta: Cta,
59
+ contact: Contact,
60
+ gallery: Gallery,
61
+ media: Media,
62
+ splitImage: SplitImage,
63
+ steps: Steps,
64
+ banner: Banner,
65
+ faq: Faq,
66
+ pricingTiers: PricingTiers,
67
+ testimonial: Testimonial,
68
+ aboutIntro: AboutIntro,
69
+ productGrid: ProductGrid,
70
+ locationHours: LocationHours,
71
+ };
72
+
73
+ const { item, base, edit, mediaMeta } = Astro.props;
74
+
75
+ const editing = edit ?? (Astro.locals as { editMode?: boolean }).editMode ?? false;
76
+ const type = String(item._type);
77
+
78
+ // `blocks` is the reserved structural key; its marker path is positional
79
+ // (`<i>.blocks.<j>`), which is what the client's depth-agnostic path parser walks.
80
+ const blocks = Array.isArray(item.blocks) ? item.blocks : [];
81
+
82
+ // The boundary marker the on-canvas chrome draws its ring on. `class="contents"`
83
+ // keeps this wrapper out of layout entirely — the ring is drawn as a box-shadow
84
+ // on the element itself (ADR 0005 §3), so an extra block box here would offset
85
+ // every section's spacing for the sake of an attribute.
86
+ const boundary = editing
87
+ ? base.includes(".blocks.")
88
+ ? { "data-louise-block": base }
89
+ : { "data-louise-section": base }
90
+ : {};
91
+
92
+ const shared = { item, base, edit: editing, mediaMeta };
93
+
94
+ // Resolved in the frontmatter so the template is one dynamic tag rather than a
95
+ // ladder of comparisons. The map itself is the dispatch check — an unknown
96
+ // `_type` (legitimate mid-migration, and already rejected on write) simply
97
+ // finds nothing and renders nothing, which is why the lookup is typed as
98
+ // possibly-undefined rather than asserted.
99
+ const Component = COMPONENTS[type];
15
100
  ---
16
101
 
17
102
  {
18
- p.kind === "hero" && (
19
- <Hero
20
- heading={p.heading}
21
- subheading={p.subheading}
22
- ctaLabel={p.ctaLabel}
23
- ctaHref={p.ctaHref}
24
- colorway={p.colorway}
25
- align={p.align}
26
- />
27
- )
28
- }
29
- {
30
- p.kind === "featureGrid" && (
31
- <FeatureGrid heading={p.heading} items={p.items} colorway={p.colorway} align={p.align} />
32
- )
33
- }
34
- {
35
- p.kind === "cta" && (
36
- <Cta
37
- heading={p.heading}
38
- body={p.body}
39
- ctaLabel={p.ctaLabel}
40
- ctaHref={p.ctaHref}
41
- colorway={p.colorway}
42
- align={p.align}
43
- />
103
+ Component && (
104
+ <div {...boundary} class="contents">
105
+ <Component {...shared} />
106
+
107
+ {blocks.map((block, j) => (
108
+ <Astro.self item={block} base={`${base}.blocks.${j}`} edit={editing} mediaMeta={mediaMeta} />
109
+ ))}
110
+ </div>
44
111
  )
45
112
  }
46
- {p.kind === "contact" && <Contact heading={p.heading} blurb={p.blurb} colorway={p.colorway} align={p.align} />}
@@ -0,0 +1,64 @@
1
+ ---
2
+ // `<Sections>` — render a page's whole `sections` array.
3
+ //
4
+ // This exists for one reason `<Section>` can't serve: the media lookup has to
5
+ // span the page. A section stores an image as a URL, but the `alt`/`caption` an
6
+ // editor typed live on the media ASSET, so rendering images correctly means
7
+ // joining back to the registry — and doing that per image is thirty D1
8
+ // round-trips for one gallery. Here it's ONE bounded `IN (...)` per page,
9
+ // threaded down as `mediaMeta`.
10
+ //
11
+ // It's also the only place that knows an item's index, which is what every
12
+ // `data-louise-*` marker path is built from.
13
+ import { collectSectionMediaUrls, type MediaMetaDatabase, resolveSectionMedia } from "./media-meta.js";
14
+ import type { MediaMeta, SectionCatalog, SectionItem } from "./sections.js";
15
+ import { astroidSectionCatalog } from "./sections.js";
16
+ import Section from "./Section.astro";
17
+
18
+ interface Props {
19
+ /** The page's stored `sections` array. Untrusted JSON — a non-array, or an
20
+ * item with an unknown `_type`, renders nothing rather than throwing. */
21
+ items: unknown;
22
+ /** D1, for the media alt/caption lookup. Omit to skip it entirely. */
23
+ db?: MediaMetaDatabase;
24
+ /** Public media base, for turning a stored URL back into a registry key.
25
+ * Matches the site's `MEDIA_URL`. */
26
+ mediaBase?: string;
27
+ /** Override the catalog — a site that extends Astroid's set passes its own. */
28
+ catalog?: SectionCatalog;
29
+ /** Force edit markers on/off. Defaults to `Astro.locals.editMode`. */
30
+ edit?: boolean;
31
+ /** Pre-resolved media metadata, when the page already looked it up. */
32
+ mediaMeta?: MediaMeta;
33
+ }
34
+
35
+ const {
36
+ items,
37
+ db,
38
+ mediaBase = "/media",
39
+ catalog = astroidSectionCatalog,
40
+ edit,
41
+ mediaMeta: providedMeta,
42
+ } = Astro.props;
43
+
44
+ // A page with no sections column yet, or a malformed one, is a normal state
45
+ // (nothing seeded) — not an error worth failing the render over.
46
+ const list: SectionItem[] = Array.isArray(items) ? (items as SectionItem[]) : [];
47
+
48
+ // One lookup for the page. Skipped when the caller already did it (a layout that
49
+ // renders sections in two places) or when there's no DB to ask.
50
+ const mediaMeta =
51
+ providedMeta ?? (await resolveSectionMedia(db, collectSectionMediaUrls(list, catalog), mediaBase));
52
+ ---
53
+
54
+ {/*
55
+ The host element. `mountSections(el, opts)` takes the element to scan for
56
+ `[data-louise-sfield]` markers and to hang on-canvas chrome off, so rendering
57
+ it here — rather than asking every page to remember a wrapper — is what makes
58
+ `<Sections>` self-sufficient: drop it on a page and the editor can find it.
59
+ A plain block wrapper, not `display: contents`, because the chrome positions
60
+ toolbars against a real box.
61
+ */}
62
+ <div data-louise-sections-host>
63
+ {list.map((item, i) => <Section item={item} base={String(i)} edit={edit} mediaMeta={mediaMeta} />)}
64
+ </div>
@@ -0,0 +1,57 @@
1
+ ---
2
+ // `<Seo>` — the settings-driven `<head>` block: title (templated), description,
3
+ // canonical, robots, Open Graph, and the Twitter/X card.
4
+ //
5
+ // Tags are emitted directly rather than through a wrapper library. There are
6
+ // about fifteen of them, the shapes are frozen by the OG and Twitter specs, and
7
+ // owning them means the resolution rules (see `resolvePageSeo`) are the ONLY
8
+ // place a value can come from — which is what makes the layer predictable.
9
+ //
10
+ // JSON-LD is not here: it's a separate concern with different escaping rules and
11
+ // its own component (`<StructuredData>`).
12
+ import { resolvePageSeo } from "astroidjs";
13
+ import type { AstroidSeoSettings, PageSeoInput } from "astroidjs";
14
+
15
+ interface Props extends PageSeoInput {
16
+ /** The `site_settings` row (or the SEO-relevant subset of it). */
17
+ settings: AstroidSeoSettings;
18
+ /** Absolute canonical URL. Defaults to the URL being rendered. */
19
+ canonical?: string;
20
+ /** Overrides the config's `seo.titleTemplate` for this page. */
21
+ titleTemplate?: string;
22
+ twitterHandle?: string;
23
+ locale?: string;
24
+ }
25
+
26
+ const { settings, canonical, titleTemplate, twitterHandle, locale, ...page } = Astro.props;
27
+
28
+ const seo = resolvePageSeo(settings, page, {
29
+ canonical: canonical ?? Astro.url.href,
30
+ titleTemplate,
31
+ twitterHandle,
32
+ locale,
33
+ });
34
+ ---
35
+
36
+ <title>{seo.title}</title>
37
+ <link rel="canonical" href={seo.canonical} />
38
+ {seo.description && <meta name="description" content={seo.description} />}
39
+ {/* nofollow alongside noindex: a page worth hiding is rarely worth crawling. */}
40
+ {seo.noindex && <meta name="robots" content="noindex, nofollow" />}
41
+
42
+ <meta property="og:type" content={seo.ogType} />
43
+ {/* The bare title, not the templated one — og:site_name already carries the brand. */}
44
+ <meta property="og:title" content={seo.bareTitle} />
45
+ <meta property="og:url" content={seo.canonical} />
46
+ {seo.description && <meta property="og:description" content={seo.description} />}
47
+ {seo.siteName && <meta property="og:site_name" content={seo.siteName} />}
48
+ {seo.locale && <meta property="og:locale" content={seo.locale} />}
49
+ {seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
50
+ {seo.ogImageAlt && <meta property="og:image:alt" content={seo.ogImageAlt} />}
51
+
52
+ <meta name="twitter:card" content={seo.ogImage ? "summary_large_image" : "summary"} />
53
+ <meta name="twitter:title" content={seo.bareTitle} />
54
+ {seo.description && <meta name="twitter:description" content={seo.description} />}
55
+ {seo.ogImage && <meta name="twitter:image" content={seo.ogImage} />}
56
+ {seo.twitterHandle && <meta name="twitter:site" content={seo.twitterHandle} />}
57
+ {seo.twitterHandle && <meta name="twitter:creator" content={seo.twitterHandle} />}
@@ -0,0 +1,137 @@
1
+ ---
2
+ // `<StageBar>` — a pipeline's progress as a segmented bar.
3
+ //
4
+ // Generalized from ghostfire's six-stage floor tracker, which hard-coded the
5
+ // segment count, the corner radii, the brand hex codes, and a mascot image.
6
+ // Here the stage list drives everything and the colours are theme tokens, so a
7
+ // four-stage onboarding flow and an eight-stage production line both work and
8
+ // a re-theme needs no edit.
9
+ //
10
+ // Rendered as an ordered list rather than a row of divs: the stages ARE a
11
+ // sequence, `aria-current` says which one is live, and a screen reader gets
12
+ // "step 3 of 6, Dried, current" instead of silence.
13
+ interface Props {
14
+ /** Stage labels, in order. */
15
+ stages: readonly string[];
16
+ /** Stage currently in progress. `>= stages.length` renders everything done. */
17
+ stage: number;
18
+ /**
19
+ * An optional marker (an emoji, a mascot glyph) that rides above the active
20
+ * segment. Off by default — the reference's bobbing ghost is brand, not
21
+ * mechanism.
22
+ */
23
+ marker?: string;
24
+ class?: string;
25
+ }
26
+
27
+ const { stages, stage, marker, class: className } = Astro.props;
28
+
29
+ // Clamp rather than trust: `stage` comes from a database column, and a value
30
+ // past the end is the normal "complete" state rather than an error.
31
+ const current = Math.max(0, Math.min(stage, stages.length));
32
+ const complete = current >= stages.length;
33
+
34
+ // Percent across the bar, centred on the active segment. Only computed when
35
+ // there is an active segment to point at.
36
+ const markerLeft = complete ? null : `${(((current + 0.5) / stages.length) * 100).toFixed(2)}%`;
37
+ ---
38
+
39
+ <div class:list={["astroid-stagebar", className]}>
40
+ {marker && markerLeft && (
41
+ <span aria-hidden="true" class="astroid-stagebar__marker" style={`left:${markerLeft}`}>
42
+ {marker}
43
+ </span>
44
+ )}
45
+ <ol class="astroid-stagebar__track">
46
+ {stages.map((label, i) => (
47
+ <li
48
+ class="astroid-stagebar__step"
49
+ data-state={i < current ? "done" : i === current ? "current" : "upcoming"}
50
+ aria-current={i === current ? "step" : undefined}
51
+ >
52
+ <span class="astroid-stagebar__seg" />
53
+ <span class="astroid-stagebar__label">{label}</span>
54
+ </li>
55
+ ))}
56
+ </ol>
57
+ </div>
58
+
59
+ <style>
60
+ .astroid-stagebar {
61
+ position: relative;
62
+ }
63
+ .astroid-stagebar__track {
64
+ display: flex;
65
+ gap: 0.25rem;
66
+ margin: 0;
67
+ padding: 0;
68
+ list-style: none;
69
+ }
70
+ .astroid-stagebar__step {
71
+ display: flex;
72
+ min-width: 0;
73
+ flex: 1 1 0;
74
+ flex-direction: column;
75
+ gap: 0.5rem;
76
+ }
77
+ .astroid-stagebar__seg {
78
+ height: 1.75rem;
79
+ border-radius: 0.25rem;
80
+ background: var(--fallback-b3, oklch(var(--b3)));
81
+ transition: background-color 300ms ease;
82
+ }
83
+ /* Round only the outer ends, so the bar reads as one track rather than a row
84
+ of separate pills — at any segment count. */
85
+ .astroid-stagebar__step:first-child .astroid-stagebar__seg {
86
+ border-start-start-radius: 999px;
87
+ border-end-start-radius: 999px;
88
+ }
89
+ .astroid-stagebar__step:last-child .astroid-stagebar__seg {
90
+ border-start-end-radius: 999px;
91
+ border-end-end-radius: 999px;
92
+ }
93
+ .astroid-stagebar__step[data-state="done"] .astroid-stagebar__seg {
94
+ background: var(--fallback-p, oklch(var(--p)));
95
+ }
96
+ .astroid-stagebar__step[data-state="current"] .astroid-stagebar__seg {
97
+ background: var(--fallback-a, oklch(var(--a)));
98
+ animation: astroid-stagebar-pulse 1.6s ease-in-out infinite;
99
+ }
100
+ .astroid-stagebar__label {
101
+ text-align: center;
102
+ font-size: 0.7rem;
103
+ font-weight: 800;
104
+ letter-spacing: 0.03em;
105
+ text-transform: uppercase;
106
+ opacity: 0.45;
107
+ }
108
+ .astroid-stagebar__step[data-state="done"] .astroid-stagebar__label,
109
+ .astroid-stagebar__step[data-state="current"] .astroid-stagebar__label {
110
+ opacity: 1;
111
+ }
112
+ .astroid-stagebar__marker {
113
+ position: absolute;
114
+ top: -1.9rem;
115
+ z-index: 1;
116
+ translate: -50%;
117
+ font-size: 1.5rem;
118
+ line-height: 1;
119
+ transition: left 500ms ease;
120
+ }
121
+
122
+ @keyframes astroid-stagebar-pulse {
123
+ 50% {
124
+ opacity: 0.6;
125
+ }
126
+ }
127
+ /* The pulse is decoration on top of a state that colour and `aria-current`
128
+ already convey, so it costs nothing to drop. */
129
+ @media (prefers-reduced-motion: reduce) {
130
+ .astroid-stagebar__step[data-state="current"] .astroid-stagebar__seg {
131
+ animation: none;
132
+ }
133
+ .astroid-stagebar__marker {
134
+ transition: none;
135
+ }
136
+ }
137
+ </style>
@@ -0,0 +1,33 @@
1
+ ---
2
+ // `<StructuredData>` — the schema.org JSON-LD `@graph` for rich results and AI
3
+ // answer surfaces: the business (its `@type` from the archetype), the WebSite,
4
+ // and optionally the entity this page is about.
5
+ //
6
+ // `is:inline` is required, not stylistic. Astro would otherwise treat the block
7
+ // as a script to process and hash; `application/ld+json` is a data block the
8
+ // browser never executes, so `script-src` doesn't govern it and it needs no
9
+ // hash. See `escapeJsonLd` for why the payload is escaped rather than stringified.
10
+ import { astroidStructuredData, escapeJsonLd } from "astroidjs";
11
+ import type { AstroidConfig, JsonLdNode, StructuredDataInput } from "astroidjs";
12
+
13
+ interface Props {
14
+ config: AstroidConfig;
15
+ settings: StructuredDataInput["settings"];
16
+ /** Absolute origin serving this page. Defaults to the current origin, which
17
+ * keeps a preview deploy describing itself rather than the live domain. */
18
+ siteUrl?: string;
19
+ /** The thing this page is about — a Product, VisualArtwork, Article. */
20
+ entity?: JsonLdNode;
21
+ }
22
+
23
+ const { config, settings, siteUrl, entity } = Astro.props;
24
+
25
+ const graph = astroidStructuredData({
26
+ config,
27
+ settings,
28
+ siteUrl: siteUrl ?? Astro.url.origin,
29
+ entity,
30
+ });
31
+ ---
32
+
33
+ <script type="application/ld+json" is:inline set:html={escapeJsonLd(graph)} />
@@ -0,0 +1,170 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Justified (Flickr-style) row balancing.
4
+ //
5
+ // The layout every photo grid actually wants: images keep their aspect ratios,
6
+ // each row is exactly as wide as the container, and rows land near a target
7
+ // height. CSS can't express it — `grid` needs uniform tracks, and `columns`
8
+ // gives you a masonry *column* flow where reading order runs down instead of
9
+ // across — so it's arithmetic, done once here rather than re-derived per site.
10
+ //
11
+ // Pure and synchronous on purpose. It's called twice for the same gallery:
12
+ // once during SSR with the aspect ratios the media library recorded, and again
13
+ // on the client after the images decode, with their true dimensions. Keeping it
14
+ // free of DOM access is what lets the same function serve both, and what lets it
15
+ // be tested without a browser.
16
+ //
17
+ // Self-contained by the same rule as `sections.ts`: this module ships as SOURCE
18
+ // (the `.astro` beside it imports it, and so does the client script it inlines),
19
+ // so it must not reach back into astroid's built `src/*`.
20
+
21
+ /** One item to lay out. `aspect` is width ÷ height. */
22
+ export interface JustifyItem {
23
+ /** Intrinsic aspect ratio (w/h). Non-finite or ≤ 0 falls back to {@link DEFAULT_ASPECT}. */
24
+ aspect: number;
25
+ }
26
+
27
+ /**
28
+ * One image in a `<JustifiedGallery>`.
29
+ *
30
+ * Declared here rather than in the `.astro` component so consumers can import
31
+ * the type from a plain module — an `.astro` file's non-`Props` exports are
32
+ * awkward to reach from TypeScript, and a scaffolded page shouldn't have to.
33
+ */
34
+ export interface GalleryItem {
35
+ src: string;
36
+ /**
37
+ * Alternative text. Required, and `""` is a legitimate value for a purely
38
+ * decorative tile — what must not happen is the attribute going missing.
39
+ */
40
+ alt: string;
41
+ /** Intrinsic dimensions, when the media library recorded them. Used for the
42
+ * pre-decode layout; the client corrects from the decoded image regardless. */
43
+ width?: number;
44
+ height?: number;
45
+ caption?: string;
46
+ /** Wrap the tile in a link (a detail page, a lightbox route). */
47
+ href?: string;
48
+ }
49
+
50
+ /** A laid-out item: its position in the input, at a concrete pixel size. */
51
+ export interface JustifiedBox {
52
+ /** Index into the input array — the caller maps this back to its own data. */
53
+ index: number;
54
+ width: number;
55
+ height: number;
56
+ }
57
+
58
+ /** One completed row. Every box in it shares `height`. */
59
+ export interface JustifiedRow {
60
+ height: number;
61
+ boxes: JustifiedBox[];
62
+ }
63
+
64
+ export interface JustifyOptions {
65
+ /** Content-box width available, in px. */
66
+ containerWidth: number;
67
+ /** The height rows aim for. Rows land near it, never exactly on it. */
68
+ targetHeight: number;
69
+ /** Horizontal gap between items in a row, in px. Default 8. */
70
+ gap?: number;
71
+ /**
72
+ * How much taller than `targetHeight` a trailing row may be before it's left
73
+ * un-stretched. Default 1.5.
74
+ *
75
+ * This is the knob that stops the classic justified-layout embarrassment: a
76
+ * gallery whose last row holds one landscape photo, stretched to the full
77
+ * container width and four times the height of every other row. Past this
78
+ * multiple the last row keeps `targetHeight` and simply ends early.
79
+ */
80
+ lastRowSlack?: number;
81
+ }
82
+
83
+ /** Aspect used for an item whose real ratio isn't known yet (3:2 landscape —
84
+ * the most common photographic frame, so first paint is usually close). */
85
+ export const DEFAULT_ASPECT = 1.5;
86
+
87
+ /** Clamp a caller-supplied aspect into something layout-safe. A zero or NaN
88
+ * ratio (an image that hasn't decoded, a media row with no dimensions) would
89
+ * otherwise divide the row height to Infinity and blow up the whole grid. */
90
+ function safeAspect(aspect: number): number {
91
+ return Number.isFinite(aspect) && aspect > 0 ? aspect : DEFAULT_ASPECT;
92
+ }
93
+
94
+ /**
95
+ * Balance `items` into rows that each fill `containerWidth` exactly.
96
+ *
97
+ * The rule per row: with n items of total aspect A and n−1 gaps, the height
98
+ * that makes the row exactly fill is `(containerWidth − gap·(n−1)) / A`. Adding
99
+ * items only ever *lowers* that height, so items are appended until it drops to
100
+ * `targetHeight` and the row closes — which is why rows come out near, but
101
+ * never exactly at, the target.
102
+ *
103
+ * ```ts
104
+ * const rows = justifyRows(photos.map((p) => ({ aspect: p.width / p.height })), {
105
+ * containerWidth: 960,
106
+ * targetHeight: 240,
107
+ * });
108
+ * ```
109
+ *
110
+ * Widths are distributed so each row sums to `containerWidth` to the pixel:
111
+ * rounding each box independently leaves a 1–2px seam that reads as a ragged
112
+ * right edge, so the last box in every row absorbs the rounding remainder.
113
+ */
114
+ export function justifyRows(items: JustifyItem[], options: JustifyOptions): JustifiedRow[] {
115
+ const { containerWidth, targetHeight, gap = 8, lastRowSlack = 1.5 } = options;
116
+
117
+ // A container with no width (SSR with no measurement, a display:none parent)
118
+ // has no meaningful layout. Returning empty lets the caller render its
119
+ // fallback rather than emitting boxes at NaN.
120
+ if (items.length === 0 || !(containerWidth > 0) || !(targetHeight > 0)) return [];
121
+
122
+ const rows: JustifiedRow[] = [];
123
+ let run: { index: number; aspect: number }[] = [];
124
+ let runAspect = 0;
125
+
126
+ /** Turn the accumulated run into a row at `height`, filling the width exactly. */
127
+ const flush = (height: number, stretch: boolean) => {
128
+ if (run.length === 0) return;
129
+ const h = Math.max(1, Math.round(height));
130
+ const available = containerWidth - gap * (run.length - 1);
131
+
132
+ let used = 0;
133
+ const boxes = run.map((item, i) => {
134
+ const last = i === run.length - 1;
135
+ // Every box but the last takes its proportional share; the last takes
136
+ // whatever is left, so the row's widths sum to `available` exactly and the
137
+ // right edge stays flush. An un-stretched trailing row keeps natural
138
+ // widths instead — it isn't meant to reach the edge.
139
+ const width =
140
+ stretch && last
141
+ ? available - used
142
+ : Math.round(stretch ? (available * item.aspect) / runAspect : h * item.aspect);
143
+ used += width;
144
+ return { index: item.index, width: Math.max(1, width), height: h };
145
+ });
146
+
147
+ rows.push({ height: h, boxes });
148
+ run = [];
149
+ runAspect = 0;
150
+ };
151
+
152
+ items.forEach((item, index) => {
153
+ const aspect = safeAspect(item.aspect);
154
+ run.push({ index, aspect });
155
+ runAspect += aspect;
156
+
157
+ const height = (containerWidth - gap * (run.length - 1)) / runAspect;
158
+ if (height <= targetHeight) flush(height, true);
159
+ });
160
+
161
+ // Whatever didn't fill a row. Stretching it to the full width is right when
162
+ // it's nearly full and grotesque when it holds one wide photo, so the slack
163
+ // multiple decides.
164
+ if (run.length > 0) {
165
+ const height = (containerWidth - gap * (run.length - 1)) / runAspect;
166
+ flush(Math.min(height, targetHeight * lastRowSlack), height <= targetHeight * lastRowSlack);
167
+ }
168
+
169
+ return rows;
170
+ }