@caltho/site-kit 0.2.7 → 0.2.9
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 +33 -1
- package/dist/content/index.d.ts +2 -1
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/sections.d.ts +17 -0
- package/dist/next/sections.js +38 -0
- package/dist/next/slideshow.d.ts +8 -0
- package/dist/next/slideshow.js +44 -0
- package/dist/policies.js +9 -3
- package/dist/prepare.js +5 -0
- package/dist/sections/index.d.ts +76 -0
- package/dist/sections/index.js +36 -0
- package/dist/sections.css +193 -0
- package/dist/sections.test.d.ts +1 -0
- package/dist/sections.test.js +94 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ the kit only checks it's a URL.
|
|
|
57
57
|
| --- | --- |
|
|
58
58
|
| **Content** (`@caltho/site-kit/content`) | `defineSite` and the pieces to build blocks from (`text`, `md`, `image`, `link`, `href`, `business`, …); the checks, including that every link lands on a page and every `#part` exists; the JSON Schema. |
|
|
59
59
|
| **prepare** | Loads the content from CalOS (`CALOS_CONTENT_URL`, `CALOS_SITE_TOKEN`; the site's own while nothing's published there) or `content/site.json`, sends CalOS the schema, checks it, writes the schema, and makes every picture the content names as WebP at the widths screens need plus a 1200×630 share crop, named by content so they cache for a year. |
|
|
60
|
-
| **finish** | After the framework's build: a content security policy in every page (each inline script named by hash), `_headers` (security headers, long caching, `noindex` on preview addresses), and `_redirects` from CalOS (`CALOS_REDIRECTS_URL`; a build that can't reach CalOS fails rather than lose them). Where CalOS counts the site's visits (its content answer says where they go), the analytics script in every published page but the preview, with its addresses in the policy, and phone and map links marked so a tap is counted (`phone_tap`, `directions_tap`; a link marked `data-track` already is left as it is). No cookies. |
|
|
60
|
+
| **finish** | After the framework's build: a content security policy in every page (each inline script named by hash), `_headers` (security headers, long caching, `noindex` on preview addresses), and `_redirects` from CalOS (`CALOS_REDIRECTS_URL`; a build that can't reach CalOS fails rather than lose them). Where CalOS counts the site's visits (its content answer says where they go), the analytics script in every published page but the preview, with its addresses in the policy, and phone and map links marked so a tap is counted (`phone_tap`, `directions_tap`; a link marked `data-track` already is left as it is). No cookies. The standard privacy policy then leaves out its claim of no analytics, saying nothing in its place: what to say of the counting is the business's (`extra`). |
|
|
61
61
|
| **check** | Every page in a real browser on a computer and a phone: fails on anything the policy blocks and anything that doesn't load. What axe finds (WCAG 2.0–2.2 A and AA, best practice) is said with where, for the site to weigh (a colour the business chose over the contrast rule is their choice); `--strict` fails on it too, `--allow=rule,rule` leaves named rules out. |
|
|
62
62
|
| **Search** | Titles that fit, canonical addresses, the business as schema.org data (hours, areas, address), FAQ and breadcrumb data, robots.txt, the sitemap. |
|
|
63
63
|
| **Policies** | Privacy, accessibility and terms, written from the business's facts and what the site does. Optional: a site calls `policy()` where it wants them. |
|
|
@@ -66,6 +66,38 @@ the kit only checks it's a URL.
|
|
|
66
66
|
| **Preview** (`@caltho/site-kit/next/preview`) | `SitePreview`: CalOS's draft drawn with the site's own components, a click matched to its field, outlines with each section's name. |
|
|
67
67
|
| **Schema** (`@caltho/site-kit/schema`) | What CalOS needs of a declaration with no site code: validate content against the JSON Schema with messages a person can act on, make a blank block or entry, list the kinds of block, and the checks across pages. |
|
|
68
68
|
|
|
69
|
+
## Sections
|
|
70
|
+
|
|
71
|
+
Sections any site can use, built once here (accessible, sized for each screen, inside the content
|
|
72
|
+
security policy) so a site gets them with a kit update rather than code of its own. CalOS offers
|
|
73
|
+
them in its editor like the site's own, and Cal can add one. Today: **gallery** (photos in a grid,
|
|
74
|
+
each with an optional caption, opening its largest copy; 2 to 4 across, square, landscape, portrait
|
|
75
|
+
or as taken) and **slideshow** (photos one at a time, fading from one to the next, with captions).
|
|
76
|
+
|
|
77
|
+
`Slideshow` (from `@caltho/site-kit/next`) is also a building block a site's own sections use: SCV's
|
|
78
|
+
hero moves through its photos behind its words with `<Slideshow fill label="…">{photos}</Slideshow>`.
|
|
79
|
+
It waits while it's off screen, pointed at or focused, never moves for someone who's asked for less
|
|
80
|
+
motion, and has a button to stop it and one for each photo.
|
|
81
|
+
|
|
82
|
+
A site takes them in once, three lines:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// content/schema.ts: the kit's sections beside the site's own (one of its own by the same name is kept)
|
|
86
|
+
import { sections } from "@caltho/site-kit/sections";
|
|
87
|
+
export const blocks = { ...sections, hero, about /* … */ };
|
|
88
|
+
|
|
89
|
+
// the site's block renderer: whatever it hasn't a component for
|
|
90
|
+
default:
|
|
91
|
+
return <KitSection s={block} />;
|
|
92
|
+
|
|
93
|
+
// app/layout.tsx
|
|
94
|
+
import "@caltho/site-kit/sections.css";
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
From then on each section the kit adds reaches the site with `cal sites kit update`. They're themed by
|
|
98
|
+
the site's Look (`--look-*`); a site with a design of its own gives its own classes so they sit in
|
|
99
|
+
it like its own: `<KitSection s={block} classes={{ section: "", wrap: "mx-auto max-w-5xl px-4 py-12", heading: "font-display text-3xl" }} />`.
|
|
100
|
+
|
|
69
101
|
## Share cards
|
|
70
102
|
|
|
71
103
|
Each page is shared (Open Graph) with a 1200×630 card made at build from its words: its title and
|
package/dist/content/index.d.ts
CHANGED
|
@@ -170,7 +170,8 @@ export type SiteBase = {
|
|
|
170
170
|
footer?: Footer;
|
|
171
171
|
look?: Look;
|
|
172
172
|
redirects: Redirect[]; /** Whether page paths end in a slash (the default). */
|
|
173
|
-
trailingSlash?: boolean;
|
|
173
|
+
trailingSlash?: boolean; /** Set by prepare, never in content: CalOS counts this site's visits (its pages carry the script), which its privacy policy says. */
|
|
174
|
+
countsVisits?: boolean;
|
|
174
175
|
};
|
|
175
176
|
/**
|
|
176
177
|
* The site's menu, as its header draws it: the one edited in CalOS (its links in order, one picked out,
|
package/dist/next/index.d.ts
CHANGED
|
@@ -4,3 +4,5 @@ export { pageMetadata, robots, sitemap, staticParams } from "./metadata.ts";
|
|
|
4
4
|
export { Img, type Ratio } from "./parts/Img.tsx";
|
|
5
5
|
export { LookStyle } from "./parts/LookStyle.tsx";
|
|
6
6
|
export { Md } from "./parts/Md.tsx";
|
|
7
|
+
export { KitSection, type KitBlock, type KitClasses } from "./sections.tsx";
|
|
8
|
+
export { Slideshow } from "./slideshow.tsx";
|
package/dist/next/index.js
CHANGED
|
@@ -7,3 +7,5 @@ export { pageMetadata, robots, sitemap, staticParams } from "./metadata.js";
|
|
|
7
7
|
export { Img } from "./parts/Img.js";
|
|
8
8
|
export { LookStyle } from "./parts/LookStyle.js";
|
|
9
9
|
export { Md } from "./parts/Md.js";
|
|
10
|
+
export { KitSection } from "./sections.js";
|
|
11
|
+
export { Slideshow } from "./slideshow.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type KitBlock = {
|
|
2
|
+
type: string;
|
|
3
|
+
id?: string;
|
|
4
|
+
placement?: Record<string, unknown>;
|
|
5
|
+
} & Record<string, unknown>;
|
|
6
|
+
/** The site's own classes for a kit section's parts, each in place of the kit's: its band, its container, its heading. */
|
|
7
|
+
export type KitClasses = {
|
|
8
|
+
section?: string;
|
|
9
|
+
wrap?: string;
|
|
10
|
+
heading?: string;
|
|
11
|
+
};
|
|
12
|
+
/** A block of one of the kit's types, drawn; null for any other (the site's own to draw). */
|
|
13
|
+
export declare function KitSection({ s, level, classes }: {
|
|
14
|
+
s: KitBlock;
|
|
15
|
+
level?: number;
|
|
16
|
+
classes?: KitClasses;
|
|
17
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { placementClass } from "../content/index.js";
|
|
3
|
+
import { image } from "./data.js";
|
|
4
|
+
import { Img } from "./parts/Img.js";
|
|
5
|
+
import { Md } from "./parts/Md.js";
|
|
6
|
+
import { Slideshow } from "./slideshow.js";
|
|
7
|
+
/** A block of one of the kit's types, drawn; null for any other (the site's own to draw). */
|
|
8
|
+
export function KitSection({ s, level = 2, classes = {} }) {
|
|
9
|
+
switch (s.type) {
|
|
10
|
+
case "gallery":
|
|
11
|
+
return _jsx(Gallery, { s: s, level: level, classes: classes });
|
|
12
|
+
case "slideshow":
|
|
13
|
+
return _jsx(Slides, { s: s, level: level, classes: classes });
|
|
14
|
+
default:
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const RATIO = { square: "1/1", landscape: "4/3", portrait: "4/5", "as-taken": undefined };
|
|
19
|
+
function Heading({ level, id, className, children }) {
|
|
20
|
+
const H = `h${Math.min(Math.max(level, 1), 6)}`;
|
|
21
|
+
return (_jsx(H, { id: id, className: className, children: children }));
|
|
22
|
+
}
|
|
23
|
+
/** Photos in a grid, each opening its largest copy; its heading and intro above when it has them. */
|
|
24
|
+
function Gallery({ s, level, classes }) {
|
|
25
|
+
const columns = Number(s.columns ?? "3");
|
|
26
|
+
const headingId = s.heading ? `${s.id ?? "gallery"}-heading` : undefined;
|
|
27
|
+
return (_jsx("section", { className: [classes.section ?? "kit-section", "kit-gallery", placementClass(s)].filter(Boolean).join(" "), id: s.id, "aria-labelledby": headingId, children: _jsxs("div", { className: classes.wrap ?? "kit-wrap", children: [s.heading && (_jsx(Heading, { level: level, id: headingId, className: classes.heading ?? "kit-heading", children: s.heading })), s.intro && _jsx(Md, { text: s.intro, top: level + 1, className: "kit-intro" }), _jsx("ul", { className: `kit-gallery-grid kit-cols-${columns}`, children: s.images.map((img) => {
|
|
28
|
+
const copies = image(img.src).srcset;
|
|
29
|
+
const largest = copies.at(-1)?.url ?? image(img.src).src;
|
|
30
|
+
return (_jsx("li", { children: _jsxs("figure", { children: [_jsx("a", { href: largest, className: "kit-gallery-open", children: _jsx(Img, { src: img.src, alt: img.alt, sizes: `(min-width: 1024px) ${Math.round(100 / columns)}vw, 50vw`, ratio: RATIO[s.shape ?? "square"] }) }), img.caption && _jsx("figcaption", { children: img.caption })] }) }, img.src));
|
|
31
|
+
}) })] }) }));
|
|
32
|
+
}
|
|
33
|
+
const SLIDE_RATIO = { wide: "kit-shape-wide", landscape: "kit-shape-landscape", square: "kit-shape-square" };
|
|
34
|
+
/** Photos one at a time (Slideshow), each with its caption under it; its heading above when it has one. */
|
|
35
|
+
function Slides({ s, level, classes }) {
|
|
36
|
+
const headingId = s.heading ? `${s.id ?? "slideshow"}-heading` : undefined;
|
|
37
|
+
return (_jsx("section", { className: [classes.section ?? "kit-section", "kit-slideshow", placementClass(s)].filter(Boolean).join(" "), id: s.id, "aria-labelledby": headingId, children: _jsxs("div", { className: classes.wrap ?? "kit-wrap", children: [s.heading && (_jsx(Heading, { level: level, id: headingId, className: classes.heading ?? "kit-heading", children: s.heading })), _jsx(Slideshow, { label: s.heading ?? "Photos", seconds: s.seconds, className: SLIDE_RATIO[s.shape ?? "wide"], children: s.images.map((img, i) => (_jsxs("figure", { children: [_jsx(Img, { src: img.src, alt: img.alt, sizes: "(min-width: 1200px) 1200px, 100vw", eager: i === 0 }), img.caption && _jsx("figcaption", { children: img.caption })] }, img.src))) })] }) }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Photos shown one at a time, fading from one to the next: on its own (the slideshow section) or
|
|
4
|
+
// behind a site's own hero, whose words stay over it. The slides are drawn by the page (pictures at
|
|
5
|
+
// the sizes the build made); this moves between them. Every few seconds while it's on screen and
|
|
6
|
+
// not pointed at or focused, never for someone who's asked for less motion, with a button to stop it
|
|
7
|
+
// (moving content must be stoppable) and one to show each photo. Classes only, so the content
|
|
8
|
+
// security policy stands: data-active shows a slide, sections.css fades it.
|
|
9
|
+
import { Children, useEffect, useRef, useState } from "react";
|
|
10
|
+
export function Slideshow({ children, label, seconds = 5, className, fill = false }) {
|
|
11
|
+
const slides = Children.toArray(children);
|
|
12
|
+
const [at, setAt] = useState(0);
|
|
13
|
+
const [stopped, setStopped] = useState(false);
|
|
14
|
+
const [held, setHeld] = useState(false);
|
|
15
|
+
const [still, setStill] = useState(false);
|
|
16
|
+
const root = useRef(null);
|
|
17
|
+
const [seen, setSeen] = useState(true);
|
|
18
|
+
// Someone who's asked for less motion: it waits for them.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
21
|
+
const set = () => setStill(m.matches);
|
|
22
|
+
set();
|
|
23
|
+
m.addEventListener("change", set);
|
|
24
|
+
return () => m.removeEventListener("change", set);
|
|
25
|
+
}, []);
|
|
26
|
+
// Off screen, it waits.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!root.current || typeof IntersectionObserver === "undefined")
|
|
29
|
+
return;
|
|
30
|
+
const o = new IntersectionObserver(([e]) => setSeen(!!e?.isIntersecting));
|
|
31
|
+
o.observe(root.current);
|
|
32
|
+
return () => o.disconnect();
|
|
33
|
+
}, []);
|
|
34
|
+
const moving = slides.length > 1 && !stopped && !held && !still && seen;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!moving)
|
|
37
|
+
return;
|
|
38
|
+
const t = setTimeout(() => setAt((i) => (i + 1) % slides.length), Math.max(2, seconds) * 1000);
|
|
39
|
+
return () => clearTimeout(t);
|
|
40
|
+
}, [moving, at, seconds, slides.length]);
|
|
41
|
+
if (slides.length < 2)
|
|
42
|
+
return _jsx("div", { className: [fill ? "kit-slides kit-slides-fill" : "kit-slides", className].filter(Boolean).join(" "), children: slides });
|
|
43
|
+
return (_jsxs("div", { ref: root, role: "region", "aria-roledescription": "slideshow", "aria-label": label, className: [fill ? "kit-slides kit-slides-fill" : "kit-slides", className].filter(Boolean).join(" "), onPointerEnter: () => setHeld(true), onPointerLeave: () => setHeld(false), onFocus: () => setHeld(true), onBlur: (e) => !e.currentTarget.contains(e.relatedTarget) && setHeld(false), children: [slides.map((slide, i) => (_jsx("div", { className: "kit-slide", "data-active": i === at ? "" : undefined, "aria-hidden": i === at ? undefined : true, role: "group", "aria-roledescription": "slide", "aria-label": `${i + 1} of ${slides.length}`, children: slide }, i))), _jsxs("div", { className: "kit-slides-controls", children: [_jsx("button", { type: "button", className: "kit-slides-stop", onClick: () => setStopped((s) => !s), "aria-label": stopped || still ? "Play the slideshow" : "Pause the slideshow", "aria-pressed": stopped, children: _jsx("span", { "aria-hidden": "true", children: stopped || still ? "▶" : "❚❚" }) }), slides.map((_, i) => (_jsx("button", { type: "button", className: "kit-slides-dot", "aria-label": `Show photo ${i + 1} of ${slides.length}`, "aria-current": i === at ? "true" : undefined, onClick: () => setAt(i) }, i)))] })] }));
|
|
44
|
+
}
|
package/dist/policies.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// The policy pages a business's site should have, written from what the business has told us and
|
|
2
2
|
// what the template actually does: no cookies, no analytics, no third-party requests, a contact
|
|
3
3
|
// form that goes to the business's CalOS. So the words stay true as the site changes: add a
|
|
4
|
-
// tracker to the template and this is where it has to be said.
|
|
5
|
-
//
|
|
4
|
+
// tracker to the template and this is where it has to be said. Where the business has CalOS count
|
|
5
|
+
// its visits (prepare marks it: site.countsVisits), the claim of no analytics is left out and
|
|
6
|
+
// nothing said in its place: what to say of the counting is the business's, in `extra` (CalOS
|
|
7
|
+
// suggests a paragraph as they turn it on). Plain language, US-first; a business adds its own
|
|
8
|
+
// paragraphs with `extra`, and should have a lawyer read them once.
|
|
6
9
|
/**
|
|
7
10
|
* `collectsFromForm`: the site has a form that takes a visitor's name, email and message, which the
|
|
8
11
|
* privacy policy says (it defaults to the site having a form address).
|
|
@@ -13,6 +16,7 @@ export function policy(site, which, o = {}) {
|
|
|
13
16
|
const reach = [b.email ? `email [${b.email}](mailto:${b.email})` : null, b.phone ? `call ${b.phone}` : null].filter(Boolean).join(" or ");
|
|
14
17
|
const place = b.address ? `${b.address.locality}, ${b.address.region}` : null;
|
|
15
18
|
const hasForm = o.collectsFromForm ?? !!site.form;
|
|
19
|
+
const counts = !!site.countsVisits;
|
|
16
20
|
if (which === "privacy") {
|
|
17
21
|
return {
|
|
18
22
|
title: "Privacy policy",
|
|
@@ -27,7 +31,9 @@ export function policy(site, which, o = {}) {
|
|
|
27
31
|
]
|
|
28
32
|
.filter(Boolean)
|
|
29
33
|
.join("\n"),
|
|
30
|
-
|
|
34
|
+
counts
|
|
35
|
+
? `This site doesn't use cookies or advertising trackers. Its fonts and pictures come from this site itself.`
|
|
36
|
+
: `This site doesn't use cookies, advertising or analytics trackers. Its fonts and pictures come from this site itself, so visiting it doesn't tell anyone else you were here.`,
|
|
31
37
|
`## How we use it`,
|
|
32
38
|
`To answer you, to quote for and do the work you ask about, and to keep the records the law requires. We keep enquiries in our customer records system, which Caltho runs for us. We don't sell or rent your information, and we don't share it for advertising. The companies that help us run this site and our business (hosting, email, records) only use it to do that for us.`,
|
|
33
39
|
`## How long we keep it`,
|
package/dist/prepare.js
CHANGED
|
@@ -92,6 +92,11 @@ export async function prepare(root, env = process.env, log = console.log) {
|
|
|
92
92
|
if (parsed.problems)
|
|
93
93
|
throw new Error(`The site's content needs fixing (${from}):\n - ${parsed.problems.join("\n - ")}`);
|
|
94
94
|
const site = parsed.site;
|
|
95
|
+
// Its pages will count visits (finish puts the script in): what its privacy policy says follows.
|
|
96
|
+
if (analytics)
|
|
97
|
+
site.countsVisits = true;
|
|
98
|
+
else
|
|
99
|
+
delete site.countsVisits;
|
|
95
100
|
for (const w of parsed.warnings)
|
|
96
101
|
log(`site-kit: warning: ${w}`);
|
|
97
102
|
const schema = definition.jsonSchema();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Photos in a grid, each with a caption if it has one: their work, the place, the team at it. */
|
|
3
|
+
export declare const gallery: z.ZodObject<{
|
|
4
|
+
heading: z.ZodOptional<z.ZodString>;
|
|
5
|
+
intro: z.ZodOptional<z.ZodString>;
|
|
6
|
+
images: z.ZodArray<z.ZodObject<{
|
|
7
|
+
src: z.ZodString;
|
|
8
|
+
alt: z.ZodString;
|
|
9
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strip>>;
|
|
11
|
+
columns: z.ZodDefault<z.ZodEnum<{
|
|
12
|
+
2: "2";
|
|
13
|
+
3: "3";
|
|
14
|
+
4: "4";
|
|
15
|
+
}>>;
|
|
16
|
+
shape: z.ZodDefault<z.ZodEnum<{
|
|
17
|
+
square: "square";
|
|
18
|
+
landscape: "landscape";
|
|
19
|
+
portrait: "portrait";
|
|
20
|
+
"as-taken": "as-taken";
|
|
21
|
+
}>>;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
/** Photos one at a time, fading from one to the next, each with a caption if it has one. */
|
|
24
|
+
export declare const slideshow: z.ZodObject<{
|
|
25
|
+
heading: z.ZodOptional<z.ZodString>;
|
|
26
|
+
images: z.ZodArray<z.ZodObject<{
|
|
27
|
+
src: z.ZodString;
|
|
28
|
+
alt: z.ZodString;
|
|
29
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, z.core.$strip>>;
|
|
31
|
+
shape: z.ZodDefault<z.ZodEnum<{
|
|
32
|
+
square: "square";
|
|
33
|
+
wide: "wide";
|
|
34
|
+
landscape: "landscape";
|
|
35
|
+
}>>;
|
|
36
|
+
seconds: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
}, z.core.$strip>;
|
|
38
|
+
/** Every section the kit draws, by the type a block names. */
|
|
39
|
+
export declare const sections: {
|
|
40
|
+
readonly gallery: z.ZodObject<{
|
|
41
|
+
heading: z.ZodOptional<z.ZodString>;
|
|
42
|
+
intro: z.ZodOptional<z.ZodString>;
|
|
43
|
+
images: z.ZodArray<z.ZodObject<{
|
|
44
|
+
src: z.ZodString;
|
|
45
|
+
alt: z.ZodString;
|
|
46
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, z.core.$strip>>;
|
|
48
|
+
columns: z.ZodDefault<z.ZodEnum<{
|
|
49
|
+
2: "2";
|
|
50
|
+
3: "3";
|
|
51
|
+
4: "4";
|
|
52
|
+
}>>;
|
|
53
|
+
shape: z.ZodDefault<z.ZodEnum<{
|
|
54
|
+
square: "square";
|
|
55
|
+
landscape: "landscape";
|
|
56
|
+
portrait: "portrait";
|
|
57
|
+
"as-taken": "as-taken";
|
|
58
|
+
}>>;
|
|
59
|
+
}, z.core.$strip>;
|
|
60
|
+
readonly slideshow: z.ZodObject<{
|
|
61
|
+
heading: z.ZodOptional<z.ZodString>;
|
|
62
|
+
images: z.ZodArray<z.ZodObject<{
|
|
63
|
+
src: z.ZodString;
|
|
64
|
+
alt: z.ZodString;
|
|
65
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
shape: z.ZodDefault<z.ZodEnum<{
|
|
68
|
+
square: "square";
|
|
69
|
+
wide: "wide";
|
|
70
|
+
landscape: "landscape";
|
|
71
|
+
}>>;
|
|
72
|
+
seconds: z.ZodDefault<z.ZodNumber>;
|
|
73
|
+
}, z.core.$strip>;
|
|
74
|
+
};
|
|
75
|
+
export type KitSectionType = keyof typeof sections;
|
|
76
|
+
export declare const isKitSection: (type: string) => type is KitSectionType;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Sections any site can use, built once here so a site gets them with a kit update rather than code of
|
|
2
|
+
// its own (README, "Sections"): each one's fields here, drawn by KitSection (../next/sections.tsx) and
|
|
3
|
+
// styled by sections.css, themed by the site's Look. A site takes them all into its blocks
|
|
4
|
+
// (`defineSite({ ...blocks, ...sections })`); one of its own by the same name is kept over the kit's.
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { image, md, text } from "../content/index.js";
|
|
7
|
+
/** Photos in a grid, each with a caption if it has one: their work, the place, the team at it. */
|
|
8
|
+
export const gallery = z
|
|
9
|
+
.object({
|
|
10
|
+
heading: text.optional().meta({ title: "Heading" }),
|
|
11
|
+
intro: md.optional().meta({ title: "Intro" }),
|
|
12
|
+
images: z
|
|
13
|
+
.array(image.extend({ caption: text.optional().meta({ title: "Caption" }) }))
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(60)
|
|
16
|
+
.meta({ title: "Photos" }),
|
|
17
|
+
columns: z.enum(["2", "3", "4"]).default("3").meta({ title: "Photos across" }),
|
|
18
|
+
shape: z.enum(["square", "landscape", "portrait", "as-taken"]).default("square").meta({ title: "Shape" }),
|
|
19
|
+
})
|
|
20
|
+
.meta({ title: "Gallery", description: "Photos in a grid, each with an optional caption" });
|
|
21
|
+
/** Photos one at a time, fading from one to the next, each with a caption if it has one. */
|
|
22
|
+
export const slideshow = z
|
|
23
|
+
.object({
|
|
24
|
+
heading: text.optional().meta({ title: "Heading" }),
|
|
25
|
+
images: z
|
|
26
|
+
.array(image.extend({ caption: text.optional().meta({ title: "Caption" }) }))
|
|
27
|
+
.min(2)
|
|
28
|
+
.max(20)
|
|
29
|
+
.meta({ title: "Photos" }),
|
|
30
|
+
shape: z.enum(["wide", "landscape", "square"]).default("wide").meta({ title: "Shape" }),
|
|
31
|
+
seconds: z.number().int().min(3).max(15).default(5).meta({ title: "Seconds on each photo" }),
|
|
32
|
+
})
|
|
33
|
+
.meta({ title: "Slideshow", description: "Photos one at a time, fading from one to the next" });
|
|
34
|
+
/** Every section the kit draws, by the type a block names. */
|
|
35
|
+
export const sections = { gallery, slideshow };
|
|
36
|
+
export const isKitSection = (type) => Object.prototype.hasOwnProperty.call(sections, type);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The kit's sections (@caltho/site-kit/sections), styled to sit in any site: its width, spacing and
|
|
3
|
+
* type, themed by the site's Look (--look-*) where it has one and its own fonts and colours otherwise.
|
|
4
|
+
* Import once in the site's layout: import "@caltho/site-kit/sections.css".
|
|
5
|
+
*/
|
|
6
|
+
.kit-section {
|
|
7
|
+
padding-block: clamp(48px, 8vw, 96px);
|
|
8
|
+
}
|
|
9
|
+
.kit-wrap {
|
|
10
|
+
width: min(1200px, 100% - 40px);
|
|
11
|
+
margin-inline: auto;
|
|
12
|
+
}
|
|
13
|
+
.kit-heading {
|
|
14
|
+
margin: 0 0 12px;
|
|
15
|
+
font-family: var(--look-heading-font, inherit);
|
|
16
|
+
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
|
17
|
+
line-height: 1.15;
|
|
18
|
+
}
|
|
19
|
+
.kit-intro {
|
|
20
|
+
max-width: 65ch;
|
|
21
|
+
margin-top: 12px;
|
|
22
|
+
margin-bottom: 28px;
|
|
23
|
+
}
|
|
24
|
+
/* Room after a heading, whoever's class it wears (a site's own may have no margin). */
|
|
25
|
+
:is(h1, h2, h3, h4, h5, h6) + .kit-gallery-grid,
|
|
26
|
+
:is(h1, h2, h3, h4, h5, h6) + .kit-slides {
|
|
27
|
+
margin-top: 24px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Gallery: two across on a phone, as many as chosen from a tablet up. */
|
|
31
|
+
.kit-gallery-grid {
|
|
32
|
+
display: grid;
|
|
33
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
34
|
+
gap: clamp(8px, 1.5vw, 16px);
|
|
35
|
+
margin: 0;
|
|
36
|
+
padding: 0;
|
|
37
|
+
list-style: none;
|
|
38
|
+
}
|
|
39
|
+
@media (min-width: 720px) {
|
|
40
|
+
.kit-cols-3,
|
|
41
|
+
.kit-cols-4 {
|
|
42
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
@media (min-width: 1024px) {
|
|
46
|
+
.kit-cols-4 {
|
|
47
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
.kit-gallery figure {
|
|
51
|
+
margin: 0;
|
|
52
|
+
}
|
|
53
|
+
.kit-gallery-open {
|
|
54
|
+
display: block;
|
|
55
|
+
border-radius: var(--look-radius, 8px);
|
|
56
|
+
}
|
|
57
|
+
.kit-gallery-open:focus-visible {
|
|
58
|
+
outline: 3px solid var(--look-accent, currentColor);
|
|
59
|
+
outline-offset: 3px;
|
|
60
|
+
}
|
|
61
|
+
.kit-gallery img {
|
|
62
|
+
display: block;
|
|
63
|
+
width: 100%;
|
|
64
|
+
height: auto;
|
|
65
|
+
border-radius: var(--look-radius, 8px);
|
|
66
|
+
object-fit: cover;
|
|
67
|
+
}
|
|
68
|
+
.kit-gallery img.ratio-1-1 {
|
|
69
|
+
aspect-ratio: 1 / 1;
|
|
70
|
+
}
|
|
71
|
+
.kit-gallery img.ratio-4-3 {
|
|
72
|
+
aspect-ratio: 4 / 3;
|
|
73
|
+
}
|
|
74
|
+
.kit-gallery img.ratio-4-5 {
|
|
75
|
+
aspect-ratio: 4 / 5;
|
|
76
|
+
}
|
|
77
|
+
.kit-gallery figcaption {
|
|
78
|
+
margin-top: 8px;
|
|
79
|
+
font-size: 0.9rem;
|
|
80
|
+
opacity: 0.75;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Slideshow: the slides stacked, the one showing faded in. In a box of its own shape (the section),
|
|
85
|
+
* or filling what it's in (`fill`: behind a hero's words). Controls bottom right, over the photos.
|
|
86
|
+
*/
|
|
87
|
+
.kit-slides {
|
|
88
|
+
position: relative;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
border-radius: var(--look-radius, 8px);
|
|
91
|
+
}
|
|
92
|
+
.kit-slides-fill {
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset: 0;
|
|
95
|
+
border-radius: 0;
|
|
96
|
+
}
|
|
97
|
+
.kit-shape-wide {
|
|
98
|
+
aspect-ratio: 16 / 9;
|
|
99
|
+
}
|
|
100
|
+
.kit-shape-landscape {
|
|
101
|
+
aspect-ratio: 4 / 3;
|
|
102
|
+
}
|
|
103
|
+
.kit-shape-square {
|
|
104
|
+
aspect-ratio: 1 / 1;
|
|
105
|
+
}
|
|
106
|
+
.kit-slide {
|
|
107
|
+
position: absolute;
|
|
108
|
+
inset: 0;
|
|
109
|
+
opacity: 0;
|
|
110
|
+
visibility: hidden;
|
|
111
|
+
transition:
|
|
112
|
+
opacity 0.8s ease,
|
|
113
|
+
visibility 0.8s;
|
|
114
|
+
}
|
|
115
|
+
.kit-slide[data-active] {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
visibility: visible;
|
|
118
|
+
}
|
|
119
|
+
.kit-slide figure {
|
|
120
|
+
margin: 0;
|
|
121
|
+
height: 100%;
|
|
122
|
+
}
|
|
123
|
+
.kit-slide img {
|
|
124
|
+
display: block;
|
|
125
|
+
width: 100%;
|
|
126
|
+
height: 100%;
|
|
127
|
+
object-fit: cover;
|
|
128
|
+
}
|
|
129
|
+
.kit-slide figcaption {
|
|
130
|
+
position: absolute;
|
|
131
|
+
inset-inline: 0;
|
|
132
|
+
bottom: 0;
|
|
133
|
+
padding: 28px 16px 12px;
|
|
134
|
+
background: linear-gradient(transparent, rgb(0 0 0 / 0.55));
|
|
135
|
+
color: #fff;
|
|
136
|
+
font-size: 0.9rem;
|
|
137
|
+
}
|
|
138
|
+
.kit-slides-controls {
|
|
139
|
+
position: absolute;
|
|
140
|
+
right: 12px;
|
|
141
|
+
bottom: 12px;
|
|
142
|
+
z-index: 2;
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 8px;
|
|
146
|
+
padding: 6px 10px;
|
|
147
|
+
border-radius: 999px;
|
|
148
|
+
background: rgb(0 0 0 / 0.35);
|
|
149
|
+
}
|
|
150
|
+
.kit-slides-stop {
|
|
151
|
+
display: grid;
|
|
152
|
+
place-items: center;
|
|
153
|
+
width: 24px;
|
|
154
|
+
height: 24px;
|
|
155
|
+
border: 0;
|
|
156
|
+
padding: 0;
|
|
157
|
+
background: none;
|
|
158
|
+
color: #fff;
|
|
159
|
+
font-size: 10px;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
}
|
|
162
|
+
.kit-slides-dot {
|
|
163
|
+
width: 8px;
|
|
164
|
+
height: 8px;
|
|
165
|
+
border: 0;
|
|
166
|
+
padding: 0;
|
|
167
|
+
border-radius: 999px;
|
|
168
|
+
background: rgb(255 255 255 / 0.5);
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
}
|
|
171
|
+
.kit-slides-dot[aria-current] {
|
|
172
|
+
background: #fff;
|
|
173
|
+
}
|
|
174
|
+
.kit-slides-stop:focus-visible,
|
|
175
|
+
.kit-slides-dot:focus-visible {
|
|
176
|
+
outline: 2px solid #fff;
|
|
177
|
+
outline-offset: 2px;
|
|
178
|
+
}
|
|
179
|
+
/* Bigger targets for a finger, the dot itself as it was. */
|
|
180
|
+
@media (pointer: coarse) {
|
|
181
|
+
.kit-slides-dot {
|
|
182
|
+
width: 10px;
|
|
183
|
+
height: 10px;
|
|
184
|
+
}
|
|
185
|
+
.kit-slides-controls {
|
|
186
|
+
gap: 12px;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
@media (prefers-reduced-motion: reduce) {
|
|
190
|
+
.kit-slide {
|
|
191
|
+
transition: none;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { defineSite, z } from "./content/index.js";
|
|
5
|
+
import { setPrepared } from "./next/data.js";
|
|
6
|
+
import { KitSection } from "./next/sections.js";
|
|
7
|
+
import { Slideshow } from "./next/slideshow.js";
|
|
8
|
+
import { gallery, isKitSection, sections, slideshow } from "./sections/index.js";
|
|
9
|
+
// Sections any site can use: their fields, as a site takes them into its own, and drawn.
|
|
10
|
+
const own = { hero: z.object({ heading: z.string() }) };
|
|
11
|
+
describe("the kit's sections", () => {
|
|
12
|
+
it("join a site's own blocks, and one of the site's by the same name is kept over the kit's", () => {
|
|
13
|
+
const site = defineSite({ ...sections, ...own });
|
|
14
|
+
const page = (blocks) => ({ url: "https://acme.example", business: { name: "Acme" }, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] });
|
|
15
|
+
const ok = site.parse(page([{ type: "gallery", images: [{ src: "a.jpg", alt: "A deck" }] }]));
|
|
16
|
+
expect(ok.problems).toBeUndefined();
|
|
17
|
+
// Its defaults: three across, square.
|
|
18
|
+
expect(ok.site.pages[0].blocks[0]).toMatchObject({ type: "gallery", columns: "3", shape: "square" });
|
|
19
|
+
expect(site.parse(page([{ type: "gallery", images: [] }])).problems?.join(" ")).toMatch(/images/);
|
|
20
|
+
const theirs = defineSite({ ...sections, gallery: z.object({ photos: z.array(z.string()) }) });
|
|
21
|
+
expect(theirs.parse(page([{ type: "gallery", photos: ["a.jpg"] }])).problems).toBeUndefined();
|
|
22
|
+
expect(isKitSection("gallery")).toBe(true);
|
|
23
|
+
expect(isKitSection("hero")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it("are named for CalOS's editor", () => {
|
|
26
|
+
expect(gallery.meta()?.title).toBe("Gallery");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("a gallery, drawn", () => {
|
|
30
|
+
setPrepared({
|
|
31
|
+
site: { url: "https://acme.example", lang: "en", business: { name: "Acme" }, redirects: [], pages: [] },
|
|
32
|
+
images: {
|
|
33
|
+
"a.jpg": { width: 1600, height: 1200, src: "/_img/a.1600.jpg", srcset: [{ url: "/_img/a.640.webp", w: 640 }, { url: "/_img/a.1600.webp", w: 1600 }] },
|
|
34
|
+
"b.jpg": { width: 800, height: 1000, src: "/_img/b.800.jpg", srcset: [] },
|
|
35
|
+
},
|
|
36
|
+
csp: {},
|
|
37
|
+
});
|
|
38
|
+
it("is a grid of its photos, each opening its largest copy, with its heading, intro and captions", () => {
|
|
39
|
+
const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", id: "work", heading: "Our decks", intro: "Built this **summer**.", columns: "4", shape: "landscape", images: [{ src: "a.jpg", alt: "A cedar deck", caption: "Cedar, 2026" }, { src: "b.jpg", alt: "A composite deck" }] }, level: 2 }));
|
|
40
|
+
expect(html).toContain('<section class="kit-section kit-gallery" id="work" aria-labelledby="work-heading">');
|
|
41
|
+
expect(html).toContain('<h2 id="work-heading" class="kit-heading">Our decks</h2>');
|
|
42
|
+
expect(html).toContain("<strong>summer</strong>");
|
|
43
|
+
expect(html).toContain('class="kit-gallery-grid kit-cols-4"');
|
|
44
|
+
expect(html).toContain('<a href="/_img/a.1600.webp" class="kit-gallery-open">');
|
|
45
|
+
expect(html).toContain('<a href="/_img/b.800.jpg" class="kit-gallery-open">');
|
|
46
|
+
expect(html).toContain('alt="A cedar deck"');
|
|
47
|
+
expect(html).toContain("ratio-4-3");
|
|
48
|
+
expect(html).toContain("<figcaption>Cedar, 2026</figcaption>");
|
|
49
|
+
expect(html.match(/<figcaption>/g)).toHaveLength(1);
|
|
50
|
+
// No style attribute: the content security policy would block it.
|
|
51
|
+
expect(html).not.toMatch(/\sstyle=/);
|
|
52
|
+
});
|
|
53
|
+
it("sits in a site with a design of its own: its section, container and heading classes in place of the kit's", () => {
|
|
54
|
+
const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", heading: "Inside", images: [{ src: "a.jpg", alt: "A cabin" }] }, classes: { section: "", wrap: "mx-auto max-w-5xl px-4", heading: "font-display text-3xl" } }));
|
|
55
|
+
expect(html).toContain('<section class="kit-gallery"');
|
|
56
|
+
expect(html).toContain('<div class="mx-auto max-w-5xl px-4">');
|
|
57
|
+
expect(html).toContain('class="font-display text-3xl">Inside</h2>');
|
|
58
|
+
expect(html).not.toContain("kit-wrap");
|
|
59
|
+
});
|
|
60
|
+
it("draws nothing for a type that isn't the kit's: the site's own to draw", () => {
|
|
61
|
+
expect(renderToStaticMarkup(_jsx(KitSection, { s: { type: "hero", heading: "Hi" } }))).toBe("");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("a slideshow", () => {
|
|
65
|
+
it("shows the first photo, the rest hidden from a screen reader till they're shown, with a button to stop it and one for each photo", () => {
|
|
66
|
+
const html = renderToStaticMarkup(_jsxs(Slideshow, { label: "The boat", seconds: 6, children: [_jsx("img", { alt: "One", src: "/1.jpg" }), _jsx("img", { alt: "Two", src: "/2.jpg" }), _jsx("img", { alt: "Three", src: "/3.jpg" })] }));
|
|
67
|
+
expect(html).toContain('role="region" aria-roledescription="slideshow" aria-label="The boat"');
|
|
68
|
+
expect(html.match(/class="kit-slide"/g)).toHaveLength(3);
|
|
69
|
+
expect(html).toContain('class="kit-slide" data-active="" role="group" aria-roledescription="slide" aria-label="1 of 3"');
|
|
70
|
+
expect(html.match(/aria-hidden="true" role="group"/g)).toHaveLength(2);
|
|
71
|
+
expect(html).toContain('aria-label="Pause the slideshow"');
|
|
72
|
+
expect(html).toContain('aria-label="Show photo 3 of 3"');
|
|
73
|
+
expect(html.match(/aria-current="true"/g)).toHaveLength(1);
|
|
74
|
+
expect(html).not.toMatch(/\sstyle=/);
|
|
75
|
+
});
|
|
76
|
+
it("is a single photo, with nothing to move between, when it has one", () => {
|
|
77
|
+
const html = renderToStaticMarkup(_jsx(Slideshow, { label: "The boat", fill: true, children: _jsx("img", { alt: "One", src: "/1.jpg" }) }));
|
|
78
|
+
expect(html).toContain('<div class="kit-slides kit-slides-fill"><img alt="One" src="/1.jpg"/></div>');
|
|
79
|
+
expect(html).not.toContain("kit-slides-controls");
|
|
80
|
+
});
|
|
81
|
+
it("as a section: two photos at least, five seconds each unless it says, wide unless it says", () => {
|
|
82
|
+
const site = defineSite({ ...sections });
|
|
83
|
+
const page = (blocks) => ({ url: "https://acme.example", business: { name: "Acme" }, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] });
|
|
84
|
+
const ok = site.parse(page([{ type: "slideshow", images: [{ src: "a.jpg", alt: "A" }, { src: "b.jpg", alt: "B" }] }]));
|
|
85
|
+
expect(ok.problems).toBeUndefined();
|
|
86
|
+
expect(ok.site.pages[0].blocks[0]).toMatchObject({ seconds: 5, shape: "wide" });
|
|
87
|
+
expect(site.parse(page([{ type: "slideshow", images: [{ src: "a.jpg", alt: "A" }] }])).problems?.join(" ")).toMatch(/images/);
|
|
88
|
+
expect(slideshow.meta()?.title).toBe("Slideshow");
|
|
89
|
+
const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "slideshow", heading: "On board", shape: "landscape", seconds: 4, images: [{ src: "a.jpg", alt: "A cabin", caption: "Cabin" }, { src: "b.jpg", alt: "The deck" }] } }));
|
|
90
|
+
expect(html).toContain('class="kit-slides kit-shape-landscape"');
|
|
91
|
+
expect(html).toContain("<figcaption>Cabin</figcaption>");
|
|
92
|
+
expect(html).toContain('aria-label="On board"');
|
|
93
|
+
});
|
|
94
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caltho/site-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "What every Caltho site is built with: the contract with CalOS (content, and how a site declares what is editable), images, search and security, and the checks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,13 +44,18 @@
|
|
|
44
44
|
"./next/preview": {
|
|
45
45
|
"types": "./dist/next/preview.d.ts",
|
|
46
46
|
"default": "./dist/next/preview.js"
|
|
47
|
-
}
|
|
47
|
+
},
|
|
48
|
+
"./sections": {
|
|
49
|
+
"types": "./dist/sections/index.d.ts",
|
|
50
|
+
"default": "./dist/sections/index.js"
|
|
51
|
+
},
|
|
52
|
+
"./sections.css": "./dist/sections.css"
|
|
48
53
|
},
|
|
49
54
|
"engines": {
|
|
50
55
|
"node": ">=22.18"
|
|
51
56
|
},
|
|
52
57
|
"scripts": {
|
|
53
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/fonts dist/fonts && chmod +x dist/cli/index.js",
|
|
58
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/fonts dist/fonts && cp src/sections.css dist/sections.css && chmod +x dist/cli/index.js",
|
|
54
59
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
55
60
|
"test": "bun test src",
|
|
56
61
|
"prepack": "bun run build"
|
|
@@ -86,10 +91,12 @@
|
|
|
86
91
|
"@types/bun": "1.4.2",
|
|
87
92
|
"@types/node": "22.20.5",
|
|
88
93
|
"@types/react": "19.3.0",
|
|
94
|
+
"@types/react-dom": "19.2.3",
|
|
89
95
|
"axe-core": "4.14.0",
|
|
90
96
|
"next": "16.4.1",
|
|
91
97
|
"playwright": "1.63.0",
|
|
92
98
|
"react": "19.3.0",
|
|
99
|
+
"react-dom": "19.3.0",
|
|
93
100
|
"typescript": "6.0.3"
|
|
94
101
|
},
|
|
95
102
|
"bugs": {
|