@caltho/site-kit 0.2.11 → 0.3.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 +84 -3
- package/dist/behaviors/accordion.d.ts +4 -0
- package/dist/behaviors/accordion.js +56 -0
- package/dist/behaviors/carousel.d.ts +4 -0
- package/dist/behaviors/carousel.js +127 -0
- package/dist/behaviors/filter.d.ts +1 -0
- package/dist/behaviors/filter.js +25 -0
- package/dist/behaviors/index.d.ts +14 -0
- package/dist/behaviors/index.js +78 -0
- package/dist/behaviors/motion.d.ts +13 -0
- package/dist/behaviors/motion.js +88 -0
- package/dist/behaviors/tabs.d.ts +3 -0
- package/dist/behaviors/tabs.js +55 -0
- package/dist/built/css.d.ts +2 -0
- package/dist/built/css.js +26 -0
- package/dist/built/theme.d.ts +19 -0
- package/dist/built/theme.js +86 -0
- package/dist/built/tree.d.ts +24 -0
- package/dist/built/tree.js +96 -0
- package/dist/built.test.d.ts +1 -0
- package/dist/built.test.js +257 -0
- package/dist/consent.d.ts +33 -0
- package/dist/consent.js +36 -0
- package/dist/consent.test.d.ts +1 -0
- package/dist/consent.test.js +61 -0
- package/dist/content/index.d.ts +170 -40
- package/dist/content/index.js +155 -0
- package/dist/content/lists.d.ts +69 -0
- package/dist/content/lists.js +77 -0
- package/dist/content/lists.test.d.ts +1 -0
- package/dist/content/lists.test.js +257 -0
- package/dist/content/look.d.ts +13 -13
- package/dist/content-migrate.d.ts +51 -0
- package/dist/content-migrate.js +69 -0
- package/dist/finish.d.ts +1 -1
- package/dist/finish.js +18 -5
- package/dist/frame.test.js +25 -0
- package/dist/next/built.d.ts +40 -0
- package/dist/next/built.js +318 -0
- package/dist/next/consent.d.ts +4 -0
- package/dist/next/consent.js +13 -0
- package/dist/next/data.d.ts +2 -2
- package/dist/next/data.js +6 -4
- package/dist/next/frame.d.ts +9 -1
- package/dist/next/frame.js +5 -2
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/metadata.js +3 -2
- package/dist/next/parts/Img.d.ts +6 -1
- package/dist/next/parts/Img.js +11 -3
- package/dist/next/preview.js +242 -15
- package/dist/next/sections.js +5 -0
- package/dist/policies.js +22 -4
- package/dist/prepare.d.ts +10 -0
- package/dist/prepare.js +67 -3
- package/dist/preview-match.d.ts +21 -0
- package/dist/preview-match.js +25 -0
- package/dist/schema.js +3 -1
- package/dist/sections/icons.d.ts +2 -0
- package/dist/sections/icons.js +4 -0
- package/dist/sections/index.d.ts +38 -16
- package/dist/sections/index.js +7 -2
- package/dist/sections.css +324 -0
- package/dist/seo.js +2 -1
- package/dist/tree/behaviors.d.ts +50 -0
- package/dist/tree/behaviors.js +67 -0
- package/dist/tree/classes.d.ts +24 -0
- package/dist/tree/classes.js +94 -0
- package/dist/tree/index.d.ts +145 -0
- package/dist/tree/index.js +196 -0
- package/dist/tree/messages.d.ts +45 -0
- package/dist/tree/messages.js +7 -0
- package/package.json +15 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Block, Page } from "./index.ts";
|
|
3
|
+
/** The kinds a list's field may be. */
|
|
4
|
+
export declare const FIELD_KINDS: readonly ["text", "longText", "image", "link", "number", "date"];
|
|
5
|
+
export type FieldKind = (typeof FIELD_KINDS)[number];
|
|
6
|
+
export type ListField = {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
kind: FieldKind;
|
|
10
|
+
};
|
|
11
|
+
/** An item: its id (stable, from CalOS), its address part, and a value per field. */
|
|
12
|
+
export type ListItem = {
|
|
13
|
+
id: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
[field: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
/** A list's page: where its items live, the fields that title and describe each, and its design. */
|
|
18
|
+
export type ListPage = {
|
|
19
|
+
path: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
blocks: Block[];
|
|
23
|
+
};
|
|
24
|
+
export type List = {
|
|
25
|
+
name: string;
|
|
26
|
+
fields: ListField[];
|
|
27
|
+
items: ListItem[];
|
|
28
|
+
page?: ListPage;
|
|
29
|
+
};
|
|
30
|
+
/** An item page's blocks carry the item they're drawn with (read by built sections as `this.`). */
|
|
31
|
+
export declare const THIS_ITEM = "thisItem";
|
|
32
|
+
type WithLists = {
|
|
33
|
+
pages: Page[];
|
|
34
|
+
lists?: Record<string, List>;
|
|
35
|
+
trailingSlash?: boolean;
|
|
36
|
+
};
|
|
37
|
+
/** An item page's address: under its list's page, by its address part. */
|
|
38
|
+
export declare const itemPath: (page: ListPage, slug: string, trailingSlash?: boolean) => string;
|
|
39
|
+
/** Every item page of every list that has a page, as pages: its design's blocks, each carrying the item. */
|
|
40
|
+
export declare function itemPages(site: WithLists): Page[];
|
|
41
|
+
/**
|
|
42
|
+
* A section's items taken from one of the site's lists, as CalOS sets it when the business picks a
|
|
43
|
+
* list for a section that repeats things (Services, Team, Reviews): which list, the section's array
|
|
44
|
+
* it fills (`into`, fixed by the section), and each of an item's fields from which of the list's
|
|
45
|
+
* (PAGE_LINK: the item's own page). The section's typed items stay, unused, until the list is unpicked.
|
|
46
|
+
*/
|
|
47
|
+
export type FromList = {
|
|
48
|
+
list: string;
|
|
49
|
+
into: string;
|
|
50
|
+
fields: Record<string, string>;
|
|
51
|
+
};
|
|
52
|
+
/** In a FromList's fields: the item's own page, where its list has pages. */
|
|
53
|
+
export declare const PAGE_LINK = "@page";
|
|
54
|
+
/**
|
|
55
|
+
* The field a section that repeats things adds to say its items can come from a list: `into` names
|
|
56
|
+
* its array (`items`, `people`). CalOS offers the site's lists for it and matches the fields.
|
|
57
|
+
*/
|
|
58
|
+
export declare const fromList: (into: string) => z.ZodOptional<z.ZodObject<{
|
|
59
|
+
list: z.ZodString;
|
|
60
|
+
into: z.ZodDefault<z.ZodLiteral<string>>;
|
|
61
|
+
fields: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
62
|
+
}, z.core.$strip>>;
|
|
63
|
+
/** A block with its items from a list, where it takes them from one (and the list is there): each item's fields as matched. */
|
|
64
|
+
export declare function withListItems(block: Block, site: Pick<WithLists, "lists" | "trailingSlash">): Block;
|
|
65
|
+
/** The site's pages and its lists' item pages: every address the site answers at, each section's items from its list where it takes them from one. */
|
|
66
|
+
export declare const allPages: (site: WithLists) => Page[];
|
|
67
|
+
/** Whether a page is an item page (its blocks carry an item). */
|
|
68
|
+
export declare const itemOf: (page: Page) => ListItem | null;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A site's lists (cal-os docs/design/site-lists.md): items of one kind (products, team members,
|
|
2
|
+
// testimonials) kept once in the content, repeated by built sections (Repeats for
|
|
3
|
+
// `lists.<key>.items`), and, where a list has a page, each item at its own address, drawn from one
|
|
4
|
+
// design: `/products/walnut-desk/` is the products page's blocks with that item, read as `this.<field>`.
|
|
5
|
+
// Pure: the build, the preview and CalOS find the same pages.
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
/** The kinds a list's field may be. */
|
|
8
|
+
export const FIELD_KINDS = ["text", "longText", "image", "link", "number", "date"];
|
|
9
|
+
/** An item page's blocks carry the item they're drawn with (read by built sections as `this.`). */
|
|
10
|
+
export const THIS_ITEM = "thisItem";
|
|
11
|
+
const listsOf = (site) => Object.entries(site.lists ?? {}).filter(([, l]) => l && typeof l === "object");
|
|
12
|
+
/** An item page's address: under its list's page, by its address part. */
|
|
13
|
+
export const itemPath = (page, slug, trailingSlash = true) => `${page.path.replace(/\/$/, "")}/${slug}${trailingSlash ? "/" : ""}`;
|
|
14
|
+
/** Words for a value used as a page's title or description. */
|
|
15
|
+
const words = (v) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
|
|
16
|
+
/** Every item page of every list that has a page, as pages: its design's blocks, each carrying the item. */
|
|
17
|
+
export function itemPages(site) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [, list] of listsOf(site)) {
|
|
20
|
+
const page = list.page;
|
|
21
|
+
if (!page || !Array.isArray(page.blocks) || !Array.isArray(list.items))
|
|
22
|
+
continue;
|
|
23
|
+
// The item's first picture is what it's shared with (no card made for each item).
|
|
24
|
+
const picture = list.fields.find((f) => f.kind === "image")?.key;
|
|
25
|
+
for (const item of list.items) {
|
|
26
|
+
if (!item?.slug)
|
|
27
|
+
continue;
|
|
28
|
+
const share = picture ? item[picture] : undefined;
|
|
29
|
+
out.push({
|
|
30
|
+
path: itemPath(page, item.slug, site.trailingSlash !== false),
|
|
31
|
+
title: words(item[page.title]) || item.slug,
|
|
32
|
+
description: page.description ? words(item[page.description]) : "",
|
|
33
|
+
noindex: false,
|
|
34
|
+
...(share && typeof share === "object" && share.src ? { share } : {}),
|
|
35
|
+
blocks: page.blocks.map((b) => ({ ...b, [THIS_ITEM]: item })),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** In a FromList's fields: the item's own page, where its list has pages. */
|
|
42
|
+
export const PAGE_LINK = "@page";
|
|
43
|
+
/**
|
|
44
|
+
* The field a section that repeats things adds to say its items can come from a list: `into` names
|
|
45
|
+
* its array (`items`, `people`). CalOS offers the site's lists for it and matches the fields.
|
|
46
|
+
*/
|
|
47
|
+
export const fromList = (into) => z
|
|
48
|
+
.object({
|
|
49
|
+
list: z.string().regex(/^[\w-]{1,60}$/),
|
|
50
|
+
into: z.literal(into).default(into),
|
|
51
|
+
fields: z.record(z.string().regex(/^[\w-]{1,60}$/), z.string().regex(/^(@page|[\w-]{1,60})$/)).default({}),
|
|
52
|
+
})
|
|
53
|
+
.optional()
|
|
54
|
+
.meta({ title: "Items from a list", "x-ui": "list-source", "x-into": into });
|
|
55
|
+
/** A block with its items from a list, where it takes them from one (and the list is there): each item's fields as matched. */
|
|
56
|
+
export function withListItems(block, site) {
|
|
57
|
+
const from = block.from;
|
|
58
|
+
if (!from?.list || !from.into)
|
|
59
|
+
return block;
|
|
60
|
+
const list = site.lists?.[from.list];
|
|
61
|
+
if (!list || !Array.isArray(list.items))
|
|
62
|
+
return block;
|
|
63
|
+
const kinds = new Map(list.fields.map((f) => [f.key, f.kind]));
|
|
64
|
+
const items = list.items.map((item) => Object.fromEntries(Object.entries(from.fields ?? {}).flatMap(([to, src]) => {
|
|
65
|
+
const v = src === PAGE_LINK ? (list.page && item.slug ? itemPath(list.page, item.slug, site.trailingSlash !== false) : undefined) : item[src];
|
|
66
|
+
if (v !== undefined && v !== null && v !== "")
|
|
67
|
+
return [[to, v]];
|
|
68
|
+
// Words left empty are empty words, so a section drawing them as text has something to draw.
|
|
69
|
+
const kind = src === PAGE_LINK ? undefined : kinds.get(src);
|
|
70
|
+
return kind === "text" || kind === "longText" ? [[to, ""]] : [];
|
|
71
|
+
})));
|
|
72
|
+
return { ...block, [from.into]: items };
|
|
73
|
+
}
|
|
74
|
+
/** The site's pages and its lists' item pages: every address the site answers at, each section's items from its list where it takes them from one. */
|
|
75
|
+
export const allPages = (site) => [...site.pages, ...itemPages(site)].map((p) => (p.blocks.some((b) => b.from?.list) ? { ...p, blocks: p.blocks.map((b) => withListItems(b, site)) } : p));
|
|
76
|
+
/** Whether a page is an item page (its blocks carry an item). */
|
|
77
|
+
export const itemOf = (page) => page.blocks[0]?.[THIS_ITEM] ?? null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { behaviorsIn, classesIn } from "../built/tree.js";
|
|
5
|
+
import { built, node, treeProblems } from "../tree/index.js";
|
|
6
|
+
import { BuiltSection } from "../next/built.js";
|
|
7
|
+
import { pageAt, setPrepared } from "../next/data.js";
|
|
8
|
+
import { sitemap, staticParams } from "../next/metadata.js";
|
|
9
|
+
import { sitemapXml } from "../seo.js";
|
|
10
|
+
import { allPages, defineSite, fromList, itemOf, text, withListItems, z } from "./index.js";
|
|
11
|
+
// Lists (cal-os docs/design/site-lists.md): items kept once in the content, repeated by built
|
|
12
|
+
// sections, and each item of a list with a page at its own address, drawn from one design.
|
|
13
|
+
const content = defineSite({ hero: z.object({ heading: text }), built, list: z.object({ heading: text }) });
|
|
14
|
+
const productPage = {
|
|
15
|
+
path: "/products/",
|
|
16
|
+
title: "name",
|
|
17
|
+
description: "summary",
|
|
18
|
+
blocks: [
|
|
19
|
+
{
|
|
20
|
+
type: "built",
|
|
21
|
+
root: {
|
|
22
|
+
id: "prodroot01",
|
|
23
|
+
tag: "section",
|
|
24
|
+
class: "p-6",
|
|
25
|
+
children: [
|
|
26
|
+
{ id: "prodname01", tag: "h1", class: "text-4xl", bind: { path: "this.name" } },
|
|
27
|
+
{ id: "prodpric01", tag: "p", bind: { path: "this.price" } },
|
|
28
|
+
{ id: "prodphot01", tag: "img", bind: { path: "this.photo" } },
|
|
29
|
+
{ id: "prodrela01", tag: "ul", children: [{ id: "prodrelb01", tag: "li", repeat: { path: "lists.products.items" }, bind: { path: "item.name" } }] },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
const raw = (over = {}) => ({
|
|
36
|
+
url: "https://acme.example",
|
|
37
|
+
business: { name: "Acme" },
|
|
38
|
+
pages: [
|
|
39
|
+
{ path: "/", title: "Acme", description: "Acme does things.", blocks: [{ type: "hero", heading: "Hello" }] },
|
|
40
|
+
{ path: "/products/", title: "Products", description: "What we make.", blocks: [{ type: "hero", heading: "Products" }] },
|
|
41
|
+
],
|
|
42
|
+
lists: {
|
|
43
|
+
products: {
|
|
44
|
+
name: "Products",
|
|
45
|
+
fields: [
|
|
46
|
+
{ key: "name", label: "Name", kind: "text" },
|
|
47
|
+
{ key: "summary", label: "Summary", kind: "longText" },
|
|
48
|
+
{ key: "price", label: "Price", kind: "number" },
|
|
49
|
+
{ key: "photo", label: "Photo", kind: "image" },
|
|
50
|
+
],
|
|
51
|
+
items: [
|
|
52
|
+
{ id: "rec-1", slug: "walnut-desk", name: "Walnut desk", summary: "Solid walnut.", price: 1200, photo: { src: "desk.jpg", alt: "The desk" } },
|
|
53
|
+
{ id: "rec-2", slug: "oak-chair", name: "Oak chair", summary: "Sits well.", price: 300 },
|
|
54
|
+
],
|
|
55
|
+
page: structuredClone(productPage),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
...over,
|
|
59
|
+
});
|
|
60
|
+
describe("a site's lists", () => {
|
|
61
|
+
it("takes lists, and a site without any has none", () => {
|
|
62
|
+
expect(content.parse(raw()).problems).toBeUndefined();
|
|
63
|
+
const none = raw();
|
|
64
|
+
delete none.lists;
|
|
65
|
+
expect(content.parse(none).site.lists).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
it("refuses a value of the wrong kind, a field the list hasn't, an address used twice or a page's address, and a title that isn't words", () => {
|
|
68
|
+
const r = raw();
|
|
69
|
+
const items = r.lists.products.items;
|
|
70
|
+
items[0].price = "a lot";
|
|
71
|
+
items[0].colour = "red";
|
|
72
|
+
items[1].slug = "walnut-desk";
|
|
73
|
+
r.pages.push({ path: "/products/oak-table/", title: "Oak table", description: "A page of its own.", blocks: [{ type: "hero", heading: "Oak" }] });
|
|
74
|
+
items.push({ id: "rec-3", slug: "oak-table", name: "Oak table" });
|
|
75
|
+
r.lists.products.page.title = "price";
|
|
76
|
+
const problems = content.parse(r).problems.join("\n");
|
|
77
|
+
expect(problems).toMatch(/price: Not a number value/);
|
|
78
|
+
expect(problems).toMatch(/colour isn't one of Products's fields/);
|
|
79
|
+
expect(problems).toMatch(/Two items have the address walnut-desk/);
|
|
80
|
+
expect(problems).toMatch(/There's a page at \/products\/oak-table\/ already/);
|
|
81
|
+
expect(problems).toMatch(/The title is one of the list's fields of words/);
|
|
82
|
+
});
|
|
83
|
+
it("refuses an address that isn't lowercase words and dashes, and a field called id or slug", () => {
|
|
84
|
+
const r = raw();
|
|
85
|
+
r.lists.products.items[0].slug = "Walnut Desk";
|
|
86
|
+
r.lists.products.fields.push({ key: "slug", label: "Slug", kind: "text" });
|
|
87
|
+
const problems = content.parse(r).problems.join("\n");
|
|
88
|
+
expect(problems).toMatch(/Lowercase letters, numbers and dashes/);
|
|
89
|
+
expect(problems).toMatch(/id and slug are an item's own/);
|
|
90
|
+
});
|
|
91
|
+
it("gives each item a page at its list's address, titled, described and shared by its own values", () => {
|
|
92
|
+
const site = content.parse(raw()).site;
|
|
93
|
+
const items = allPages(site).filter((p) => itemOf(p));
|
|
94
|
+
expect(items.map((p) => [p.path, p.title, p.description])).toEqual([
|
|
95
|
+
["/products/walnut-desk/", "Walnut desk", "Solid walnut."],
|
|
96
|
+
["/products/oak-chair/", "Oak chair", "Sits well."],
|
|
97
|
+
]);
|
|
98
|
+
expect(items[0].share).toEqual({ src: "desk.jpg", alt: "The desk" });
|
|
99
|
+
});
|
|
100
|
+
it("finds an item's page at its address, lists it for the build and the sitemap, and checks links to it", () => {
|
|
101
|
+
const site = content.parse(raw()).site;
|
|
102
|
+
setPrepared({ site, images: {}, csp: {} }, false);
|
|
103
|
+
expect(pageAt(["products", "oak-chair"])?.title).toBe("Oak chair");
|
|
104
|
+
expect(pageAt(["products"])?.title).toBe("Products");
|
|
105
|
+
expect(pageAt(["products", "nothing"])).toBeNull();
|
|
106
|
+
expect(staticParams()).toContainEqual({ path: ["products", "walnut-desk"] });
|
|
107
|
+
expect(sitemap().map((u) => u.url)).toContain("https://acme.example/products/oak-chair/");
|
|
108
|
+
expect(sitemapXml(site)).toContain("/products/walnut-desk/");
|
|
109
|
+
// A link to an item's page lands; one to an item there isn't doesn't.
|
|
110
|
+
const linked = raw();
|
|
111
|
+
linked.pages[0].blocks = [{ type: "built", root: { id: "linkroot01", tag: "a", text: "Desk", href: "/products/walnut-desk/" } }];
|
|
112
|
+
expect(content.parse(linked).problems).toBeUndefined();
|
|
113
|
+
linked.pages[0].blocks[0].root.href = "/products/pine-bed/";
|
|
114
|
+
expect(content.parse(linked).problems.join("\n")).toMatch(/No page at \/products\/pine-bed\//);
|
|
115
|
+
});
|
|
116
|
+
it("draws an item's page with this. for the item and item. for a repeat's", () => {
|
|
117
|
+
const site = content.parse(raw()).site;
|
|
118
|
+
setPrepared({ site, images: {}, csp: {} }, true);
|
|
119
|
+
const page = pageAt(["products", "walnut-desk"]);
|
|
120
|
+
const html = renderToStaticMarkup(_jsx(BuiltSection, { s: page.blocks[0] }));
|
|
121
|
+
expect(html).toContain(">Walnut desk</h1>");
|
|
122
|
+
expect(html).toContain(">1200</p>");
|
|
123
|
+
expect(html).toContain('alt="The desk"');
|
|
124
|
+
expect(html).toContain("<li");
|
|
125
|
+
expect(html).toContain(">Oak chair</li>");
|
|
126
|
+
});
|
|
127
|
+
it("builds CSS and behaviors for a list's page design, which only its items draw", () => {
|
|
128
|
+
const site = content.parse(raw()).site;
|
|
129
|
+
expect([...classesIn(site)]).toEqual(["kit:p-6", "kit:text-4xl"]);
|
|
130
|
+
expect([...behaviorsIn(site)]).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("a section's items from a list", () => {
|
|
134
|
+
const site = {
|
|
135
|
+
trailingSlash: true,
|
|
136
|
+
lists: {
|
|
137
|
+
services: {
|
|
138
|
+
name: "Services",
|
|
139
|
+
fields: [
|
|
140
|
+
{ key: "name", label: "Name", kind: "text" },
|
|
141
|
+
{ key: "summary", label: "Summary", kind: "longText" },
|
|
142
|
+
{ key: "photo", label: "Photo", kind: "image" },
|
|
143
|
+
],
|
|
144
|
+
items: [
|
|
145
|
+
{ id: "a", slug: "bookkeeping", name: "Bookkeeping", summary: "Monthly books.", photo: { src: "uploads/b.jpg", alt: "A ledger" } },
|
|
146
|
+
{ id: "b", slug: "payroll", name: "Payroll" },
|
|
147
|
+
],
|
|
148
|
+
page: { path: "/services/", title: "name", blocks: [] },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
const block = { type: "services", heading: "What we do", items: [{ title: "Typed", text: "Typed" }], from: { list: "services", into: "items", fields: { title: "name", text: "summary", image: "photo", href: "@page" } } };
|
|
153
|
+
it("fills the section's items from the list, each linked to its page", () => {
|
|
154
|
+
const out = withListItems(block, site);
|
|
155
|
+
expect(out.items).toEqual([
|
|
156
|
+
{ title: "Bookkeeping", text: "Monthly books.", image: { src: "uploads/b.jpg", alt: "A ledger" }, href: "/services/bookkeeping/" },
|
|
157
|
+
// Words it hasn't are empty words; a picture it hasn't is left out.
|
|
158
|
+
{ title: "Payroll", text: "", href: "/services/payroll/" },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
it("keeps the typed items where the list isn't there, and when none is picked", () => {
|
|
162
|
+
expect(withListItems({ ...block, from: { ...block.from, list: "gone" } }, site)).toEqual({ ...block, from: { ...block.from, list: "gone" } });
|
|
163
|
+
const { from: _, ...typed } = block;
|
|
164
|
+
expect(withListItems(typed, site)).toBe(typed);
|
|
165
|
+
});
|
|
166
|
+
it("is what a page draws", () => {
|
|
167
|
+
const pages = allPages({ ...site, pages: [{ path: "/services/", title: "Services", description: "", blocks: [block] }] });
|
|
168
|
+
expect(pages[0].blocks[0].items).toHaveLength(2);
|
|
169
|
+
});
|
|
170
|
+
it("is a field a section adds, naming the array it fills", () => {
|
|
171
|
+
const schema = z.object({ items: z.array(z.object({ title: text })), from: fromList("items") });
|
|
172
|
+
expect(schema.parse({ items: [], from: { list: "services", fields: { title: "name" } } }).from).toEqual({ list: "services", into: "items", fields: { title: "name" } });
|
|
173
|
+
expect(() => schema.parse({ items: [], from: { list: "services", into: "people" } })).toThrow();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe("a link bound to a list's value", () => {
|
|
177
|
+
it("goes to an email address as mailto: and a phone number as tel:, anything else as it is", async () => {
|
|
178
|
+
const { linkFor } = await import("../next/built.js");
|
|
179
|
+
expect(linkFor("team@caltho.com")).toBe("mailto:team@caltho.com");
|
|
180
|
+
expect(linkFor("(215) 555-0100")).toBe("tel:2155550100");
|
|
181
|
+
expect(linkFor("+1 215 555 0100")).toBe("tel:+12155550100");
|
|
182
|
+
expect(linkFor("/services/")).toBe("/services/");
|
|
183
|
+
expect(linkFor("https://caltho.com")).toBe("https://caltho.com");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("a filter over a list", () => {
|
|
187
|
+
it("marks each item with its value of the filter's field, and leaves its buttons as buttons", () => {
|
|
188
|
+
const site = { lists: { work: { name: "Work", fields: [{ key: "name", label: "Name", kind: "text" }, { key: "industry", label: "Industry", kind: "text" }], items: [{ id: "a", slug: "boat", name: "Boat", industry: "Hospitality" }, { id: "b", slug: "agency", name: "Agency", industry: "Real Estate" }] } } };
|
|
189
|
+
setPrepared({ site, images: {}, csp: {} }, false);
|
|
190
|
+
const block = {
|
|
191
|
+
type: "built",
|
|
192
|
+
root: {
|
|
193
|
+
id: "filtroot01", tag: "section", behavior: { name: "filter", field: "industry" }, children: [
|
|
194
|
+
{ id: "filtbtns01", tag: "div", children: [{ id: "filtall001", tag: "button", text: "All", class: "aria-pressed:bg-ink" }, { id: "filtre0001", tag: "button", text: "Real estate" }] },
|
|
195
|
+
{ id: "filtgrid01", tag: "ul", children: [{ id: "filtitem01", tag: "li", repeat: { path: "lists.work.items" }, bind: { path: "item.name" } }] },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const html = renderToStaticMarkup(_jsx(BuiltSection, { s: block }));
|
|
200
|
+
expect(html).toContain('data-kit-behavior="filter"');
|
|
201
|
+
expect(html).toMatch(/<li[^>]*data-kit-filter-value="hospitality"[^>]*>Boat<\/li>/);
|
|
202
|
+
expect(html).toMatch(/<li[^>]*data-kit-filter-value="real-estate"[^>]*>Agency<\/li>/);
|
|
203
|
+
expect(html).toMatch(/<button[^>]*type="button"[^>]*>All<\/button>/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe("a form in a built section", () => {
|
|
207
|
+
const form = {
|
|
208
|
+
id: "formroot01", tag: "form", class: "grid gap-4", children: [
|
|
209
|
+
{ id: "formlab001", tag: "label", for: "formname01", text: "Your name" },
|
|
210
|
+
{ id: "formname01", tag: "input", field: { name: "name", required: true, placeholder: "Jane" } },
|
|
211
|
+
{ id: "formmsg001", tag: "textarea", field: { name: "message", rows: 4 } },
|
|
212
|
+
{ id: "formhow001", tag: "select", field: { name: "how", placeholder: "Pick one" }, children: [{ id: "formopt001", tag: "option", text: "Email" }] },
|
|
213
|
+
{ id: "formsend01", tag: "button", text: "Send message" },
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
it("posts to the site's own form address with each field by its name, a label naming its field, the spam trap, and a button that sends", () => {
|
|
217
|
+
setPrepared({ site: { form: "https://forms.caltho.com/f/abc" }, images: {}, csp: {} }, false);
|
|
218
|
+
const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: form } }));
|
|
219
|
+
const tag = (name) => html.match(new RegExp(`<${name}\\b[^>]*>`))?.[0] ?? "";
|
|
220
|
+
expect(tag("form")).toContain('method="post"');
|
|
221
|
+
expect(tag("form")).toContain('action="https://forms.caltho.com/f/abc"');
|
|
222
|
+
expect(html).toMatch(/<label[^>]*for="kit-f-formname01"[^>]*>Your name<\/label>/);
|
|
223
|
+
const name = html.match(/<input[^>]*id="kit-f-formname01"[^>]*>/)?.[0] ?? "";
|
|
224
|
+
for (const a of ['name="name"', 'type="text"', 'required=""', 'placeholder="Jane"'])
|
|
225
|
+
expect(name).toContain(a);
|
|
226
|
+
expect(html).toMatch(/<textarea[^>]*name="message"[^>]*rows="4"/);
|
|
227
|
+
expect(html).toMatch(/<select[^>]*name="how"[^>]*>.*<option value="Email">Email<\/option>/);
|
|
228
|
+
expect(html).toContain('name="_website"');
|
|
229
|
+
expect(html).toMatch(/<button[^>]*type="submit"[^>]*>Send message<\/button>/);
|
|
230
|
+
});
|
|
231
|
+
it("can't be sent where the site has no form address yet, and a field outside a form is refused", () => {
|
|
232
|
+
setPrepared({ site: {}, images: {}, csp: {} }, false);
|
|
233
|
+
const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: form } }));
|
|
234
|
+
expect(html).not.toContain("<form");
|
|
235
|
+
expect(html).toContain("data-kit-form-unset");
|
|
236
|
+
expect(treeProblems({ id: "rootrootxx", tag: "div", children: [{ id: "lonefield1", tag: "input", field: { name: "email" } }] }).join(" ")).toMatch(/belongs inside a form/);
|
|
237
|
+
});
|
|
238
|
+
it("sends to the business's form it names, by key; one the site wasn't given isn't sent anywhere", () => {
|
|
239
|
+
const quote = { ...form, sendTo: "quote" };
|
|
240
|
+
setPrepared({ site: { form: "https://forms.caltho.com/f/abc", forms: { quote: "https://acme.caltho.app/api/forms/quote/k1" } }, images: {}, csp: {} }, false);
|
|
241
|
+
expect(renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: quote } }))).toContain('action="https://acme.caltho.app/api/forms/quote/k1"');
|
|
242
|
+
const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: { ...form, sendTo: "gone" } } }));
|
|
243
|
+
expect(html).not.toContain("<form");
|
|
244
|
+
expect(node.safeParse({ id: "abcdefgh12", tag: "div", sendTo: "quote" }).success).toBe(false);
|
|
245
|
+
expect(node.safeParse({ ...form, sendTo: "https://evil.example" }).success).toBe(false);
|
|
246
|
+
const picked = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: { ...form, children: [{ id: "byemail001", tag: "input", field: { name: "reach", type: "radio", value: "email", checked: true } }] } } }));
|
|
247
|
+
expect(picked).toMatch(/<input[^>]*checked=""[^>]*value="email"/);
|
|
248
|
+
});
|
|
249
|
+
it("shows a picture's phone version in its place on a small screen", () => {
|
|
250
|
+
const prepared = (src, w, h) => ({ src: `/_img/${src}`, width: w, height: h, srcset: [{ url: `/_img/${src}.800.webp`, w: 800 }] });
|
|
251
|
+
setPrepared({ site: {}, images: { "uploads/today.png": prepared("today.png", 2880, 1400), "uploads/today-phone.png": prepared("today-phone.png", 1170, 1992) }, csp: {} }, false);
|
|
252
|
+
const shot = { id: "shotshot01", tag: "img", class: "w-full", image: { src: "uploads/today.png", alt: "Today", phoneSrc: "uploads/today-phone.png" } };
|
|
253
|
+
const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: { id: "rootroot01", tag: "div", children: [shot] } } }));
|
|
254
|
+
expect(html).toMatch(/<picture><source media="\(max-width: 700px\)"[^>]*today-phone[^>]*\/><img[^>]*today\.png[^>]*alt="Today"/);
|
|
255
|
+
expect(node.safeParse({ ...shot, image: { ...shot.image, phoneSrc: "https://evil.example/x.png" } }).success).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
});
|
package/dist/content/look.d.ts
CHANGED
|
@@ -133,13 +133,13 @@ export declare function lookSchema(o: LookOptions): z.ZodOptional<z.ZodObject<{
|
|
|
133
133
|
floating: "floating";
|
|
134
134
|
}>>;
|
|
135
135
|
layout: z.ZodDefault<z.ZodEnum<{
|
|
136
|
+
left: "left";
|
|
136
137
|
spread: "spread";
|
|
137
138
|
centered: "centered";
|
|
138
|
-
left: "left";
|
|
139
139
|
}>>;
|
|
140
140
|
menuShows: z.ZodDefault<z.ZodEnum<{
|
|
141
|
-
both: "both";
|
|
142
141
|
icon: "icon";
|
|
142
|
+
both: "both";
|
|
143
143
|
word: "word";
|
|
144
144
|
}>>;
|
|
145
145
|
menuStyle: z.ZodOptional<z.ZodString>;
|
|
@@ -156,39 +156,39 @@ export declare function lookSchema(o: LookOptions): z.ZodOptional<z.ZodObject<{
|
|
|
156
156
|
color: z.ZodString;
|
|
157
157
|
}, z.core.$strip>>>;
|
|
158
158
|
type: z.ZodDefault<z.ZodObject<{
|
|
159
|
-
|
|
159
|
+
h1: z.ZodOptional<z.ZodObject<{
|
|
160
160
|
size: z.ZodOptional<z.ZodNumber>;
|
|
161
|
-
weight: z.ZodOptional<z.ZodLiteral<400 |
|
|
161
|
+
weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
|
|
162
162
|
tracking: z.ZodOptional<z.ZodNumber>;
|
|
163
163
|
leading: z.ZodOptional<z.ZodNumber>;
|
|
164
164
|
}, z.core.$strip>>;
|
|
165
|
-
|
|
165
|
+
h2: z.ZodOptional<z.ZodObject<{
|
|
166
166
|
size: z.ZodOptional<z.ZodNumber>;
|
|
167
|
-
weight: z.ZodOptional<z.ZodLiteral<400 |
|
|
167
|
+
weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
|
|
168
168
|
tracking: z.ZodOptional<z.ZodNumber>;
|
|
169
169
|
leading: z.ZodOptional<z.ZodNumber>;
|
|
170
170
|
}, z.core.$strip>>;
|
|
171
|
-
|
|
171
|
+
h3: z.ZodOptional<z.ZodObject<{
|
|
172
172
|
size: z.ZodOptional<z.ZodNumber>;
|
|
173
|
-
weight: z.ZodOptional<z.ZodLiteral<400 |
|
|
173
|
+
weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
|
|
174
174
|
tracking: z.ZodOptional<z.ZodNumber>;
|
|
175
175
|
leading: z.ZodOptional<z.ZodNumber>;
|
|
176
176
|
}, z.core.$strip>>;
|
|
177
|
-
|
|
177
|
+
small: z.ZodOptional<z.ZodObject<{
|
|
178
178
|
size: z.ZodOptional<z.ZodNumber>;
|
|
179
|
-
weight: z.ZodOptional<z.ZodLiteral<400 |
|
|
179
|
+
weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
|
|
180
180
|
tracking: z.ZodOptional<z.ZodNumber>;
|
|
181
181
|
leading: z.ZodOptional<z.ZodNumber>;
|
|
182
182
|
}, z.core.$strip>>;
|
|
183
183
|
body: z.ZodOptional<z.ZodObject<{
|
|
184
184
|
size: z.ZodOptional<z.ZodNumber>;
|
|
185
|
-
weight: z.ZodOptional<z.ZodLiteral<400 |
|
|
185
|
+
weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
|
|
186
186
|
tracking: z.ZodOptional<z.ZodNumber>;
|
|
187
187
|
leading: z.ZodOptional<z.ZodNumber>;
|
|
188
188
|
}, z.core.$strip>>;
|
|
189
|
-
|
|
189
|
+
display: z.ZodOptional<z.ZodObject<{
|
|
190
190
|
size: z.ZodOptional<z.ZodNumber>;
|
|
191
|
-
weight: z.ZodOptional<z.ZodLiteral<400 |
|
|
191
|
+
weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
|
|
192
192
|
tracking: z.ZodOptional<z.ZodNumber>;
|
|
193
193
|
leading: z.ZodOptional<z.ZodNumber>;
|
|
194
194
|
}, z.core.$strip>>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The format this kit's content is in. 0: before built sections; 1: built sections and components. */
|
|
2
|
+
export declare const CONTENT_FORMAT = 1;
|
|
3
|
+
export type Content = Record<string, unknown>;
|
|
4
|
+
export type ContentMigration = {
|
|
5
|
+
/** Stable, unique, in order: "0001-built-sections". */
|
|
6
|
+
id: string;
|
|
7
|
+
from: number;
|
|
8
|
+
to: number;
|
|
9
|
+
/** What it changes, for whoever looks at a site's history. */
|
|
10
|
+
title: string;
|
|
11
|
+
/** The content in format `from`, moved to `to` (a copy: the content given is never changed). Throws a MigrationError for what it can't move. */
|
|
12
|
+
run(content: Content): Content;
|
|
13
|
+
};
|
|
14
|
+
/** What a migration can't move, and where: a fault for Caltho to fix in the migration, never a site's to fix by hand. */
|
|
15
|
+
export declare class MigrationError extends Error {
|
|
16
|
+
readonly path: (string | number)[];
|
|
17
|
+
constructor(message: string, path?: (string | number)[]);
|
|
18
|
+
}
|
|
19
|
+
/** Every migration, in order: each one's `from` is the one before's `to`. */
|
|
20
|
+
export declare const CONTENT_MIGRATIONS: readonly ContentMigration[];
|
|
21
|
+
/** The format content is in: its `format`, or 0 for content from before formats were said. */
|
|
22
|
+
export declare function formatOf(content: unknown): number;
|
|
23
|
+
export type MigrateResult = {
|
|
24
|
+
ok: true;
|
|
25
|
+
content: Content;
|
|
26
|
+
from: number;
|
|
27
|
+
format: number;
|
|
28
|
+
applied: {
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
}[];
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
content: Content;
|
|
35
|
+
from: number;
|
|
36
|
+
format: number;
|
|
37
|
+
error: {
|
|
38
|
+
id: string;
|
|
39
|
+
message: string;
|
|
40
|
+
path: (string | number)[];
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Content moved to this kit's format (or `to`): each migration it hasn't had, in order, then its
|
|
45
|
+
* `format` set. Idempotent: content already there comes back as it was, with nothing applied. A
|
|
46
|
+
* migration that fails stops it: the content is returned as given, with the failure (`ok: false`).
|
|
47
|
+
* Content in a format newer than this kit knows is refused the same way (the site needs the newer kit).
|
|
48
|
+
*/
|
|
49
|
+
export declare function migrateContent(content: Content, o?: {
|
|
50
|
+
to?: number;
|
|
51
|
+
}): MigrateResult;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A site's content moved from one format to the next. Every client site is the starter plus its
|
|
2
|
+
// configuration (its content: pages, Look, built sections, components), with no code of its own,
|
|
3
|
+
// so the configuration's format is the kit's, and it changes only with the kit. The content says
|
|
4
|
+
// which format it's in (`format`; none is 0, the format before built sections). When a newer kit
|
|
5
|
+
// changes a shape, a migration here moves content from the format before to its own, as data:
|
|
6
|
+
// pure, in order, each once (content already at or past its format is left alone), never needing a
|
|
7
|
+
// person to edit a site by hand. One that can't move something says so (a MigrationError) for
|
|
8
|
+
// Caltho to fix in the migration; the content is then left exactly as it was.
|
|
9
|
+
//
|
|
10
|
+
// Most changes need none: a new kind of block (built, use) or a new optional field leaves existing
|
|
11
|
+
// content valid, and only the format number moves. CalOS runs these on a site's draft and published
|
|
12
|
+
// content when the site moves to a newer kit (migrateContent); the build runs them too (prepare),
|
|
13
|
+
// so content CalOS hasn't moved yet still builds. Code migrations (a site's files) are ./migrate.ts.
|
|
14
|
+
/** The format this kit's content is in. 0: before built sections; 1: built sections and components. */
|
|
15
|
+
export const CONTENT_FORMAT = 1;
|
|
16
|
+
/** What a migration can't move, and where: a fault for Caltho to fix in the migration, never a site's to fix by hand. */
|
|
17
|
+
export class MigrationError extends Error {
|
|
18
|
+
path;
|
|
19
|
+
constructor(message, path = []) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.path = path;
|
|
22
|
+
this.name = "MigrationError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Every migration, in order: each one's `from` is the one before's `to`. */
|
|
26
|
+
export const CONTENT_MIGRATIONS = [
|
|
27
|
+
{
|
|
28
|
+
id: "0001-built-sections",
|
|
29
|
+
from: 0,
|
|
30
|
+
to: 1,
|
|
31
|
+
title: "Built sections, components and the cookie banner: new kinds of block (built, use), the site's components and its consent setting, none of which changes existing content",
|
|
32
|
+
run: (c) => c,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
/** The format content is in: its `format`, or 0 for content from before formats were said. */
|
|
36
|
+
export function formatOf(content) {
|
|
37
|
+
const f = content?.format;
|
|
38
|
+
return typeof f === "number" && Number.isInteger(f) && f >= 0 ? f : 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Content moved to this kit's format (or `to`): each migration it hasn't had, in order, then its
|
|
42
|
+
* `format` set. Idempotent: content already there comes back as it was, with nothing applied. A
|
|
43
|
+
* migration that fails stops it: the content is returned as given, with the failure (`ok: false`).
|
|
44
|
+
* Content in a format newer than this kit knows is refused the same way (the site needs the newer kit).
|
|
45
|
+
*/
|
|
46
|
+
export function migrateContent(content, o = {}) {
|
|
47
|
+
const to = o.to ?? CONTENT_FORMAT;
|
|
48
|
+
const from = formatOf(content);
|
|
49
|
+
if (from > CONTENT_FORMAT)
|
|
50
|
+
return { ok: false, content, from, format: from, error: { id: "format", message: `The content is in format ${from}, newer than this kit's (${CONTENT_FORMAT}): the site needs a newer @caltho/site-kit`, path: ["format"] } };
|
|
51
|
+
let at = structuredClone(content);
|
|
52
|
+
const applied = [];
|
|
53
|
+
for (const m of CONTENT_MIGRATIONS) {
|
|
54
|
+
if (m.from < from || m.to > to)
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
at = m.run(structuredClone(at));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const e = err;
|
|
61
|
+
return { ok: false, content, from, format: from, error: { id: m.id, message: e.message, path: e instanceof MigrationError ? e.path : [] } };
|
|
62
|
+
}
|
|
63
|
+
at.format = m.to;
|
|
64
|
+
applied.push({ id: m.id, title: m.title });
|
|
65
|
+
}
|
|
66
|
+
if (from < to && formatOf(at) !== to)
|
|
67
|
+
at.format = to;
|
|
68
|
+
return { ok: true, content: at, from, format: formatOf(at), applied };
|
|
69
|
+
}
|
package/dist/finish.d.ts
CHANGED
|
@@ -6,5 +6,5 @@ export declare function finish(root: string, outDir: string, env?: Record<string
|
|
|
6
6
|
* out, and what's marked to count), and each phone link and map link marked: a call or directions
|
|
7
7
|
* tapped is what a business wants to know came from the site. No cookies: nothing to consent to.
|
|
8
8
|
*/
|
|
9
|
-
export declare function counted(html: string, a: Analytics): string;
|
|
9
|
+
export declare function counted(html: string, a: Analytics, asks?: boolean): string;
|
|
10
10
|
export {};
|