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
|
@@ -1,46 +1,112 @@
|
|
|
1
1
|
---
|
|
2
|
-
// `<Section>` — the section-library dispatcher (ADR 0003
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// is
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
}
|