@atelic-action/ui 0.17.2 → 1.1.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 +432 -45
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Note.tsx +9 -39
- package/src/artifact/Paragraph.tsx +65 -0
- package/src/artifact/Read.tsx +406 -0
- package/src/artifact/Writeup.tsx +7 -7
- package/src/artifact/index.ts +1 -0
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +759 -401
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends one GA4 event through the Google tag a site loads, and nothing else:
|
|
3
|
+
* the tag itself, and the delegated listener for the click events, stay in
|
|
4
|
+
* the site. A silent no op when the tag is absent (no tag id configured, the
|
|
5
|
+
* script blocked, prerender with no window), and it never throws, since
|
|
6
|
+
* analytics must never break the page. Beacon transport, so an event fired on
|
|
7
|
+
* the way out of the page survives the navigation.
|
|
8
|
+
*
|
|
9
|
+
* Params are closed vocabularies: never a name, a phone number, an email
|
|
10
|
+
* address, or a message.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type Gtag = (...args: unknown[]) => void;
|
|
14
|
+
|
|
15
|
+
export type EventParams = Record<string, string | number | boolean>;
|
|
16
|
+
|
|
17
|
+
export function trackEvent(name: string, params: EventParams = {}): void {
|
|
18
|
+
if (typeof window === "undefined") return;
|
|
19
|
+
try {
|
|
20
|
+
const gtag = (window as Window & { gtag?: Gtag }).gtag;
|
|
21
|
+
if (typeof gtag !== "function") return;
|
|
22
|
+
gtag("event", name, { ...params, transport_type: "beacon" });
|
|
23
|
+
} catch {
|
|
24
|
+
// Analytics must never break the page.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The max height an expanding body animates to. Put `ref` on the content
|
|
7
|
+
* inside the clipped box: the height is measured before paint when the row
|
|
8
|
+
* opens (so a row that mounts already open is never clipped to nothing) and
|
|
9
|
+
* re-measured whenever the content resizes (rotation, font swap, late image).
|
|
10
|
+
* The layout effect runs before paint, so the 0 it starts from is never seen
|
|
11
|
+
* clipped, and the first open animates like every later one.
|
|
12
|
+
*/
|
|
13
|
+
export function useOpenHeight(open: boolean) {
|
|
14
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
15
|
+
const [height, setHeight] = useState<number | null>(null);
|
|
16
|
+
|
|
17
|
+
useIsoLayoutEffect(() => {
|
|
18
|
+
const el = ref.current;
|
|
19
|
+
if (!open || !el) return;
|
|
20
|
+
setHeight(el.scrollHeight);
|
|
21
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
22
|
+
const observer = new ResizeObserver(() => setHeight(el.scrollHeight));
|
|
23
|
+
observer.observe(el);
|
|
24
|
+
return () => observer.disconnect();
|
|
25
|
+
}, [open]);
|
|
26
|
+
|
|
27
|
+
return { ref, maxHeight: open ? (height ?? 0) : 0 } as const;
|
|
28
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode } from "react";
|
|
2
2
|
import { newTabProps } from "../lib/newTabProps";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
3
4
|
|
|
4
5
|
export interface ButtonProps {
|
|
5
6
|
variant?: "primary" | "dark" | "ghost";
|
|
@@ -39,6 +40,7 @@ export function Button({
|
|
|
39
40
|
style,
|
|
40
41
|
children,
|
|
41
42
|
}: ButtonProps) {
|
|
43
|
+
const withBase = useWithBase();
|
|
42
44
|
const classes = [
|
|
43
45
|
"btn",
|
|
44
46
|
`btn-${variant}`,
|
|
@@ -63,7 +65,7 @@ export function Button({
|
|
|
63
65
|
if (href) {
|
|
64
66
|
return (
|
|
65
67
|
<a
|
|
66
|
-
href={href}
|
|
68
|
+
href={withBase(href)}
|
|
67
69
|
className={classes}
|
|
68
70
|
style={style}
|
|
69
71
|
aria-label={ariaLabel}
|
package/src/primitives/Chip.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
2
3
|
|
|
3
4
|
export interface ChipProps {
|
|
4
5
|
/** Shows the small brand-colored dot before the label. */
|
|
@@ -12,6 +13,7 @@ export interface ChipProps {
|
|
|
12
13
|
|
|
13
14
|
/** Small mono tag/badge, used for credentials, tags, and service areas. */
|
|
14
15
|
export function Chip({ dot = false, onDark = false, href, children }: ChipProps) {
|
|
16
|
+
const withBase = useWithBase();
|
|
15
17
|
const classes = ["chip", onDark && "on-dark"].filter(Boolean).join(" ");
|
|
16
18
|
const content = (
|
|
17
19
|
<>
|
|
@@ -21,7 +23,7 @@ export function Chip({ dot = false, onDark = false, href, children }: ChipProps)
|
|
|
21
23
|
);
|
|
22
24
|
if (href) {
|
|
23
25
|
return (
|
|
24
|
-
<a className={classes} href={href}>
|
|
26
|
+
<a className={classes} href={withBase(href)}>
|
|
25
27
|
{content}
|
|
26
28
|
</a>
|
|
27
29
|
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
3
|
+
|
|
4
|
+
export interface ImageFrameProps {
|
|
5
|
+
src?: string;
|
|
6
|
+
alt?: string;
|
|
7
|
+
/** CSS aspect-ratio for the frame, e.g. "4/5". */
|
|
8
|
+
aspectRatio?: string;
|
|
9
|
+
maxWidth?: string;
|
|
10
|
+
/** Placeholder label rendered when no src is provided yet. */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Set on above-the-fold imagery; defaults to lazy loading. */
|
|
13
|
+
priority?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Rounded, shadowed media frame; renders a labeled placeholder block until an image exists. */
|
|
19
|
+
export function ImageFrame({
|
|
20
|
+
src,
|
|
21
|
+
alt = "",
|
|
22
|
+
aspectRatio,
|
|
23
|
+
maxWidth,
|
|
24
|
+
placeholder,
|
|
25
|
+
priority = false,
|
|
26
|
+
className,
|
|
27
|
+
style,
|
|
28
|
+
}: ImageFrameProps) {
|
|
29
|
+
const withBase = useWithBase();
|
|
30
|
+
const frameStyle: CSSProperties = { aspectRatio, maxWidth, ...style };
|
|
31
|
+
if (!src) {
|
|
32
|
+
return (
|
|
33
|
+
<div className={["ph", className].filter(Boolean).join(" ")} style={frameStyle}>
|
|
34
|
+
{placeholder ?? "Image"}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<div className={["imgframe", className].filter(Boolean).join(" ")} style={frameStyle}>
|
|
40
|
+
<img
|
|
41
|
+
src={withBase(src)}
|
|
42
|
+
alt={alt}
|
|
43
|
+
loading={priority ? undefined : "lazy"}
|
|
44
|
+
fetchPriority={priority ? "high" : undefined}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One block of ported prose. A deliberately tiny vocabulary — headings,
|
|
3
|
+
* paragraphs, lists — because that is what survives extraction from a CMS
|
|
4
|
+
* intact, and because anything richer invites rewriting content that was
|
|
5
|
+
* meant to be carried across verbatim.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProseBlock {
|
|
8
|
+
type: "h" | "p" | "ul";
|
|
9
|
+
/** Present for "h" and "p". */
|
|
10
|
+
text?: string;
|
|
11
|
+
/** Present for "ul". */
|
|
12
|
+
items?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProseBlocksProps {
|
|
16
|
+
blocks: ProseBlock[];
|
|
17
|
+
/**
|
|
18
|
+
* Heading level for "h" blocks. Defaults to h2, since the page's h1
|
|
19
|
+
* belongs to whatever hero sits above this.
|
|
20
|
+
*/
|
|
21
|
+
headingLevel?: "h2" | "h3";
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders a document body from data rather than markup: legal pages, ported
|
|
27
|
+
* blog posts, anything whose words came from somewhere else and must not be
|
|
28
|
+
* quietly edited on the way in.
|
|
29
|
+
*
|
|
30
|
+
* Keys are derived from content plus position. Repeated text is common in
|
|
31
|
+
* ported prose ("Contact us" appears in three legal documents), so neither
|
|
32
|
+
* alone is stable.
|
|
33
|
+
*/
|
|
34
|
+
export function ProseBlocks({ blocks, headingLevel = "h2", className }: ProseBlocksProps) {
|
|
35
|
+
const Heading = headingLevel;
|
|
36
|
+
return (
|
|
37
|
+
<div className={["prose-blocks", className].filter(Boolean).join(" ")}>
|
|
38
|
+
{blocks.map((block, i) => {
|
|
39
|
+
if (block.type === "h") {
|
|
40
|
+
return (
|
|
41
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
|
|
42
|
+
<Heading className="pb-heading" key={`h-${i}-${block.text?.slice(0, 24)}`}>
|
|
43
|
+
{block.text}
|
|
44
|
+
</Heading>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (block.type === "ul") {
|
|
48
|
+
return (
|
|
49
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
|
|
50
|
+
<ul className="pb-list" key={`ul-${i}-${(block.items ?? [])[0]?.slice(0, 24)}`}>
|
|
51
|
+
{(block.items ?? []).map((item, j) => (
|
|
52
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
|
|
53
|
+
<li key={`${j}-${item.slice(0, 40)}`}>{item}</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
|
|
60
|
+
<p className="pb-body" key={`p-${i}-${block.text?.slice(0, 24)}`}>
|
|
61
|
+
{block.text}
|
|
62
|
+
</p>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface QuoteCardProps {
|
|
4
|
+
quote: ReactNode;
|
|
5
|
+
/** Attribution line, e.g. "Dr. Alex Rivers, PT, DPT". */
|
|
6
|
+
by: string;
|
|
7
|
+
center?: boolean;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Testimonial / pull-quote card with serif italic type. */
|
|
12
|
+
export function QuoteCard({ quote, by, center = false, className }: QuoteCardProps) {
|
|
13
|
+
const classes = ["quote-card", center && "center", className].filter(Boolean).join(" ");
|
|
14
|
+
return (
|
|
15
|
+
<figure className={classes}>
|
|
16
|
+
<blockquote>{quote}</blockquote>
|
|
17
|
+
<figcaption className="by">{by}</figcaption>
|
|
18
|
+
</figure>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Star } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export interface StarRowProps {
|
|
4
|
+
/** Star count, 1 to 5. Rounded and clamped into range. */
|
|
5
|
+
rating: number;
|
|
6
|
+
/** Glyph size in px. */
|
|
7
|
+
size?: number;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A row of filled stars, rendered as a single labeled image so screen
|
|
13
|
+
* readers announce "4 out of 5 stars" instead of four decorative glyphs.
|
|
14
|
+
* Shared by the review components; render one only when the source
|
|
15
|
+
* actually shows a rating — never invent one.
|
|
16
|
+
*/
|
|
17
|
+
export function StarRow({ rating, size = 15, className }: StarRowProps) {
|
|
18
|
+
const count = Math.max(1, Math.min(5, Math.round(rating)));
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
className={["star-row", className].filter(Boolean).join(" ")}
|
|
22
|
+
role="img"
|
|
23
|
+
aria-label={`${count} out of 5 stars`}
|
|
24
|
+
>
|
|
25
|
+
{Array.from({ length: count }, (_, i) => (
|
|
26
|
+
<Star
|
|
27
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static decorative row
|
|
28
|
+
key={i}
|
|
29
|
+
size={size}
|
|
30
|
+
strokeWidth={1.5}
|
|
31
|
+
fill="currentColor"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
/>
|
|
34
|
+
))}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface TickListProps {
|
|
5
|
+
items: ReactNode[];
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Checkmark bullet list used in feature details and pricing cards. */
|
|
10
|
+
export function TickList({ items, className }: TickListProps) {
|
|
11
|
+
return (
|
|
12
|
+
<ul className={["ticks", className].filter(Boolean).join(" ")}>
|
|
13
|
+
{items.map((item, i) => (
|
|
14
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static content lists never reorder
|
|
15
|
+
<li key={i}>
|
|
16
|
+
<span className="tick" aria-hidden="true">
|
|
17
|
+
<Check size={12} strokeWidth={3} />
|
|
18
|
+
</span>
|
|
19
|
+
{item}
|
|
20
|
+
</li>
|
|
21
|
+
))}
|
|
22
|
+
</ul>
|
|
23
|
+
);
|
|
24
|
+
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { Button, type ButtonProps } from "./Button";
|
|
2
2
|
export { Chip, type ChipProps } from "./Chip";
|
|
3
3
|
export { Eyebrow, type EyebrowProps } from "./Eyebrow";
|
|
4
|
+
export { ImageFrame, type ImageFrameProps } from "./ImageFrame";
|
|
4
5
|
export { Lead, type LeadProps } from "./Lead";
|
|
6
|
+
export { type ProseBlock, ProseBlocks, type ProseBlocksProps } from "./ProseBlocks";
|
|
7
|
+
export { QuoteCard, type QuoteCardProps } from "./QuoteCard";
|
|
5
8
|
export { SectionHeading, type SectionHeadingProps } from "./SectionHeading";
|
|
9
|
+
export { StarRow, type StarRowProps } from "./StarRow";
|
|
10
|
+
export { TickList, type TickListProps } from "./TickList";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext } from "react";
|
|
2
|
+
import { joinBase, normalizeBasePath } from "./basePath";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The base path every link the package renders carries. The package imports
|
|
6
|
+
* no router and reads no build setting, so a site says its base once, here,
|
|
7
|
+
* and `Button`, `Chip`, and every section that builds a link or an image
|
|
8
|
+
* source read it through `useBasePath`. `SiteShell` mounts the provider from
|
|
9
|
+
* its `basePath` prop, so a site that renders its pages inside the shell
|
|
10
|
+
* never mounts it by hand.
|
|
11
|
+
*
|
|
12
|
+
* With no provider above it the base is "", the root, and every path renders
|
|
13
|
+
* as given.
|
|
14
|
+
*/
|
|
15
|
+
const BasePathContext = createContext("");
|
|
16
|
+
|
|
17
|
+
export interface BasePathProviderProps {
|
|
18
|
+
/** The prefix the site is served under: "", "/", "proto", and "/proto/" are all read as written. */
|
|
19
|
+
base?: string;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function BasePathProvider({ base, children }: BasePathProviderProps) {
|
|
24
|
+
return (
|
|
25
|
+
<BasePathContext.Provider value={normalizeBasePath(base)}>{children}</BasePathContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The base path in force: "" at the root, otherwise "/proto" in normalized form. */
|
|
30
|
+
export function useBasePath(): string {
|
|
31
|
+
return useContext(BasePathContext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A function that puts the base in force on a root absolute path. External
|
|
36
|
+
* URLs, protocol links, fragments, and paths that already carry the base pass
|
|
37
|
+
* through unchanged, and so does `undefined`, so an optional prop can be
|
|
38
|
+
* handed over as it is.
|
|
39
|
+
*/
|
|
40
|
+
export function useWithBase(): <T extends string | undefined>(path: T) => T {
|
|
41
|
+
const base = useBasePath();
|
|
42
|
+
return useCallback(
|
|
43
|
+
<T extends string | undefined>(path: T): T =>
|
|
44
|
+
(path === undefined ? path : joinBase(base, path)) as T,
|
|
45
|
+
[base],
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base path: the prefix a site is served under when it does not own the
|
|
3
|
+
* root of its host (a website build previewed at `/proto` on the business's
|
|
4
|
+
* artifact host). A site sets it once; `BasePathProvider` hands it to every
|
|
5
|
+
* component below, and `marketingConfig` turns it into Vite's `base` and the
|
|
6
|
+
* router's `basepath`.
|
|
7
|
+
*
|
|
8
|
+
* Normalized form: "" for the root, otherwise a leading slash and no trailing
|
|
9
|
+
* slash ("/proto"). These helpers are pure and take the base explicitly, so
|
|
10
|
+
* the Vite config, the build commands, and the tests use them as they are.
|
|
11
|
+
* Plain TypeScript with no imports, since the built Vite entry imports it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SEGMENT = /^(\/[A-Za-z0-9._~-]+)+$/;
|
|
15
|
+
|
|
16
|
+
/** Turns a configured value ("", "/", "proto", "/proto/") into "" or "/proto". Throws on anything else. */
|
|
17
|
+
export function normalizeBasePath(raw: string | undefined): string {
|
|
18
|
+
const trimmed = (raw ?? "").trim();
|
|
19
|
+
if (trimmed === "" || trimmed === "/") return "";
|
|
20
|
+
const base = `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
|
|
21
|
+
if (!SEGMENT.test(base)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`basePath must be a path such as "/proto" (letters, digits, dot, dash, underscore, tilde): got ${JSON.stringify(raw)}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return base;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True for a path the base applies to: root absolute, not protocol relative. */
|
|
30
|
+
function isRootAbsolute(path: string): boolean {
|
|
31
|
+
return path.startsWith("/") && !path.startsWith("//");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when the path already carries the base (so applying it again is a no op). */
|
|
35
|
+
export function hasBase(base: string, path: string): boolean {
|
|
36
|
+
if (!base) return true;
|
|
37
|
+
if (!path.startsWith(base)) return false;
|
|
38
|
+
const next = path.charAt(base.length);
|
|
39
|
+
return next === "" || next === "/" || next === "?" || next === "#";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prefixes a root absolute path with the base. External URLs, protocol links
|
|
44
|
+
* (mailto:, tel:), fragments, relative paths, and paths that already carry the
|
|
45
|
+
* base come back unchanged, so it is safe on any href and idempotent.
|
|
46
|
+
*/
|
|
47
|
+
export function joinBase(base: string, path: string): string {
|
|
48
|
+
if (!base || !isRootAbsolute(path) || hasBase(base, path)) return path;
|
|
49
|
+
return `${base}${path}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Removes the base from a path that carries it ("/proto/faqs" to "/faqs", "/proto" to "/"). */
|
|
53
|
+
export function stripBase(base: string, path: string): string {
|
|
54
|
+
if (!base || !isRootAbsolute(path) || !path.startsWith(base) || !hasBase(base, path)) return path;
|
|
55
|
+
const rest = path.slice(base.length);
|
|
56
|
+
return rest === "" || !rest.startsWith("/") ? `/${rest}` : rest;
|
|
57
|
+
}
|
package/src/routing/index.ts
CHANGED
|
@@ -29,3 +29,11 @@ export function staticNotFoundRouting<Page>(page: Page) {
|
|
|
29
29
|
defaultPendingComponent: page,
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
BasePathProvider,
|
|
35
|
+
type BasePathProviderProps,
|
|
36
|
+
useBasePath,
|
|
37
|
+
useWithBase,
|
|
38
|
+
} from "./BasePathProvider";
|
|
39
|
+
export { hasBase, joinBase, normalizeBasePath, stripBase } from "./basePath";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fails the build when dist/client or vercel.json disagrees with the base
|
|
3
|
+
* path. The last step of a marketing site's `bun run build`; the rules and
|
|
4
|
+
* their reasons live in src/lib/basePathAudit.ts, and the setting itself is
|
|
5
|
+
* `basePath` in the site's src/site.config.ts.
|
|
6
|
+
*
|
|
7
|
+
* A `VITE_BASE_PATH` override that differs from the config is a rehearsal
|
|
8
|
+
* build (CI proves a site under "/proto" that way): its HTML is held to the
|
|
9
|
+
* override, but vercel.json still describes the committed config, so a
|
|
10
|
+
* rewrite mismatch only warns, except on Vercel, where it would ship.
|
|
11
|
+
*
|
|
12
|
+
* Usage: atelic-check-base-path (from the site's root)
|
|
13
|
+
* Exits 1 on any problem.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import {
|
|
19
|
+
cssRefs,
|
|
20
|
+
htmlRefs,
|
|
21
|
+
type Rewrite,
|
|
22
|
+
rewriteProblems,
|
|
23
|
+
unbasedRefs,
|
|
24
|
+
} from "../lib/basePathAudit";
|
|
25
|
+
import { normalizeBasePath } from "../routing/basePath";
|
|
26
|
+
import { SITE_CONFIG, siteConfigPath } from "./site-config";
|
|
27
|
+
|
|
28
|
+
/** Where the build writes the pages, from the site's root. */
|
|
29
|
+
export const BUILD_ROOT = "dist/client";
|
|
30
|
+
|
|
31
|
+
export interface BasePathInput {
|
|
32
|
+
/** The `basePath` the site's config commits to. */
|
|
33
|
+
configured: string | undefined;
|
|
34
|
+
/** `VITE_BASE_PATH`, when the build was a rehearsal under another base. */
|
|
35
|
+
override: string | undefined;
|
|
36
|
+
/** Every built HTML and CSS file, by its path, with its text. */
|
|
37
|
+
files: Array<{ path: string; text: string }>;
|
|
38
|
+
/** The site's vercel.json, parsed. */
|
|
39
|
+
vercel: { rewrites?: Rewrite[] };
|
|
40
|
+
/** Whether the build runs on Vercel, where a rehearsal mismatch would ship. */
|
|
41
|
+
onVercel: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BasePathReport {
|
|
45
|
+
base: string;
|
|
46
|
+
rehearsal: boolean;
|
|
47
|
+
checked: number;
|
|
48
|
+
problems: string[];
|
|
49
|
+
warnings: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The audit, kept pure so it can be tested without a build. */
|
|
53
|
+
export function auditBasePath(input: BasePathInput): BasePathReport {
|
|
54
|
+
const configured = normalizeBasePath(input.configured);
|
|
55
|
+
const base = normalizeBasePath(input.override ?? input.configured);
|
|
56
|
+
const rehearsal = input.override !== undefined && base !== configured;
|
|
57
|
+
|
|
58
|
+
const problems: string[] = [];
|
|
59
|
+
const warnings: string[] = [];
|
|
60
|
+
let checked = 0;
|
|
61
|
+
for (const file of input.files) {
|
|
62
|
+
const refs = file.path.endsWith(".html")
|
|
63
|
+
? htmlRefs(file.text)
|
|
64
|
+
: file.path.endsWith(".css")
|
|
65
|
+
? cssRefs(file.text)
|
|
66
|
+
: undefined;
|
|
67
|
+
if (!refs) continue;
|
|
68
|
+
checked++;
|
|
69
|
+
for (const ref of unbasedRefs(refs, base)) {
|
|
70
|
+
problems.push(`${file.path}: ${ref} lacks the base ${base}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rewrites = rewriteProblems(input.vercel, base).map((problem) => `vercel.json: ${problem}`);
|
|
75
|
+
if (rehearsal && !input.onVercel) {
|
|
76
|
+
warnings.push(...rewrites.map((problem) => `rehearsal build, not deployable: ${problem}`));
|
|
77
|
+
} else {
|
|
78
|
+
problems.push(...rewrites);
|
|
79
|
+
}
|
|
80
|
+
return { base, rehearsal, checked, problems, warnings };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function builtFiles(dir: string, root: string): Array<{ path: string; text: string }> {
|
|
84
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
85
|
+
const path = join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) return builtFiles(path, root);
|
|
87
|
+
if (!path.endsWith(".html") && !path.endsWith(".css")) return [];
|
|
88
|
+
return [
|
|
89
|
+
{ path: join(BUILD_ROOT, path.slice(root.length + 1)), text: readFileSync(path, "utf8") },
|
|
90
|
+
];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The command: audits the build of the site in `cwd`. */
|
|
95
|
+
export async function main(cwd: string = process.cwd()): Promise<void> {
|
|
96
|
+
const configPath = siteConfigPath(cwd);
|
|
97
|
+
if (!existsSync(configPath)) {
|
|
98
|
+
console.error(`[base-path] no ${SITE_CONFIG} in ${cwd}. Run this from the root of a site.`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const { site } = (await import(pathToFileURL(configPath).href)) as {
|
|
102
|
+
site?: { basePath?: string };
|
|
103
|
+
};
|
|
104
|
+
const root = resolve(cwd, BUILD_ROOT);
|
|
105
|
+
if (!existsSync(root)) {
|
|
106
|
+
console.error(`[base-path] no ${BUILD_ROOT} in ${cwd}. Build the site first.`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const vercelPath = resolve(cwd, "vercel.json");
|
|
110
|
+
const report = auditBasePath({
|
|
111
|
+
configured: site?.basePath,
|
|
112
|
+
override: process.env.VITE_BASE_PATH,
|
|
113
|
+
files: builtFiles(root, root),
|
|
114
|
+
vercel: existsSync(vercelPath) ? JSON.parse(readFileSync(vercelPath, "utf8")) : {},
|
|
115
|
+
onVercel: Boolean(process.env.VERCEL),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const warning of report.warnings) console.warn(`[base-path] ${warning}`);
|
|
119
|
+
if (report.problems.length > 0) {
|
|
120
|
+
for (const problem of report.problems) console.error(`[base-path] ${problem}`);
|
|
121
|
+
const count = report.problems.length;
|
|
122
|
+
console.error(
|
|
123
|
+
`[base-path] ${count} problem${count === 1 ? "" : "s"}; see the @atelic-action/ui README, Sections, Serving Under a Base Path`,
|
|
124
|
+
);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
console.log(
|
|
128
|
+
`[base-path] ${report.base || "root"}: ${report.checked} HTML and CSS files checked${report.rehearsal ? " (rehearsal)" : ""}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips `#fragment` and trailing slash duplicates out of the built
|
|
3
|
+
* dist/client/sitemap.xml. A step of a marketing site's `bun run build`, after
|
|
4
|
+
* vite writes the sitemap; the logic and its reasoning live in
|
|
5
|
+
* src/lib/sitemap.ts. A site with a base path ships no sitemap
|
|
6
|
+
* (`marketingConfig` turns it off), so there is nothing to clean.
|
|
7
|
+
*
|
|
8
|
+
* Usage: atelic-clean-sitemap (from the site's root)
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { dedupeSitemap } from "../lib/sitemap";
|
|
13
|
+
|
|
14
|
+
/** Where the build writes the sitemap, from the site's root. */
|
|
15
|
+
export const SITEMAP = "dist/client/sitemap.xml";
|
|
16
|
+
|
|
17
|
+
/** The command: cleans the sitemap of the build in `cwd`, if it built one. */
|
|
18
|
+
export function main(cwd: string = process.cwd()): void {
|
|
19
|
+
const path = resolve(cwd, SITEMAP);
|
|
20
|
+
if (!existsSync(path)) {
|
|
21
|
+
console.log(`[sitemap] ${SITEMAP} not built (base path set), nothing to clean`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const { xml, removed } = dedupeSitemap(readFileSync(path, "utf8"));
|
|
25
|
+
writeFileSync(path, xml);
|
|
26
|
+
console.log(`[sitemap] ${SITEMAP} (${removed} duplicate URL${removed === 1 ? "" : "s"} removed)`);
|
|
27
|
+
}
|