@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.
Files changed (73) hide show
  1. package/README.md +84 -3
  2. package/dist/behaviors/accordion.d.ts +4 -0
  3. package/dist/behaviors/accordion.js +56 -0
  4. package/dist/behaviors/carousel.d.ts +4 -0
  5. package/dist/behaviors/carousel.js +127 -0
  6. package/dist/behaviors/filter.d.ts +1 -0
  7. package/dist/behaviors/filter.js +25 -0
  8. package/dist/behaviors/index.d.ts +14 -0
  9. package/dist/behaviors/index.js +78 -0
  10. package/dist/behaviors/motion.d.ts +13 -0
  11. package/dist/behaviors/motion.js +88 -0
  12. package/dist/behaviors/tabs.d.ts +3 -0
  13. package/dist/behaviors/tabs.js +55 -0
  14. package/dist/built/css.d.ts +2 -0
  15. package/dist/built/css.js +26 -0
  16. package/dist/built/theme.d.ts +19 -0
  17. package/dist/built/theme.js +86 -0
  18. package/dist/built/tree.d.ts +24 -0
  19. package/dist/built/tree.js +96 -0
  20. package/dist/built.test.d.ts +1 -0
  21. package/dist/built.test.js +257 -0
  22. package/dist/consent.d.ts +33 -0
  23. package/dist/consent.js +36 -0
  24. package/dist/consent.test.d.ts +1 -0
  25. package/dist/consent.test.js +61 -0
  26. package/dist/content/index.d.ts +170 -40
  27. package/dist/content/index.js +155 -0
  28. package/dist/content/lists.d.ts +69 -0
  29. package/dist/content/lists.js +77 -0
  30. package/dist/content/lists.test.d.ts +1 -0
  31. package/dist/content/lists.test.js +257 -0
  32. package/dist/content/look.d.ts +13 -13
  33. package/dist/content-migrate.d.ts +51 -0
  34. package/dist/content-migrate.js +69 -0
  35. package/dist/finish.d.ts +1 -1
  36. package/dist/finish.js +18 -5
  37. package/dist/frame.test.js +25 -0
  38. package/dist/next/built.d.ts +40 -0
  39. package/dist/next/built.js +318 -0
  40. package/dist/next/consent.d.ts +4 -0
  41. package/dist/next/consent.js +13 -0
  42. package/dist/next/data.d.ts +2 -2
  43. package/dist/next/data.js +6 -4
  44. package/dist/next/frame.d.ts +9 -1
  45. package/dist/next/frame.js +5 -2
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +2 -0
  48. package/dist/next/metadata.js +3 -2
  49. package/dist/next/parts/Img.d.ts +6 -1
  50. package/dist/next/parts/Img.js +11 -3
  51. package/dist/next/preview.js +242 -15
  52. package/dist/next/sections.js +5 -0
  53. package/dist/policies.js +22 -4
  54. package/dist/prepare.d.ts +10 -0
  55. package/dist/prepare.js +67 -3
  56. package/dist/preview-match.d.ts +21 -0
  57. package/dist/preview-match.js +25 -0
  58. package/dist/schema.js +3 -1
  59. package/dist/sections/icons.d.ts +2 -0
  60. package/dist/sections/icons.js +4 -0
  61. package/dist/sections/index.d.ts +38 -16
  62. package/dist/sections/index.js +7 -2
  63. package/dist/sections.css +324 -0
  64. package/dist/seo.js +2 -1
  65. package/dist/tree/behaviors.d.ts +50 -0
  66. package/dist/tree/behaviors.js +67 -0
  67. package/dist/tree/classes.d.ts +24 -0
  68. package/dist/tree/classes.js +94 -0
  69. package/dist/tree/index.d.ts +145 -0
  70. package/dist/tree/index.js +196 -0
  71. package/dist/tree/messages.d.ts +45 -0
  72. package/dist/tree/messages.js +7 -0
  73. package/package.json +15 -1
@@ -0,0 +1,24 @@
1
+ /** Variants a class may carry: screen sizes, states, and a few structural ones. */
2
+ export declare const VARIANTS: Set<string>;
3
+ export type ClassCheck = {
4
+ ok: boolean;
5
+ bad: {
6
+ token: string;
7
+ why: string;
8
+ }[];
9
+ };
10
+ /** The classes of a class string: split on whitespace, empties dropped. */
11
+ export declare const classTokens: (c: string) => string[];
12
+ /** A class's variants and its utility, split on colons outside brackets. */
13
+ export declare function splitVariants(token: string): {
14
+ variants: string[];
15
+ utility: string;
16
+ };
17
+ /** Why one class isn't allowed, or null when it is. */
18
+ export declare function classProblem(token: string, known?: (utility: string) => boolean): string | null;
19
+ /** A class string checked whole: ok, or each class that isn't and why. */
20
+ export declare function checkClasses(c: string, o?: {
21
+ known?: (utility: string) => boolean;
22
+ }): ClassCheck;
23
+ /** The allowed classes of a string (what the kit draws: a bad one is dropped, the rest kept). */
24
+ export declare const safeClasses: (c: string | undefined) => string;
@@ -0,0 +1,94 @@
1
+ // Which Tailwind classes a built section may carry (cal-os docs/design/site-builder.md, "Tailwind"):
2
+ // any utility, with the variants a site needs, and arbitrary values that are only a number, length,
3
+ // colour, percentage or the site's own variables. Never a class that loads another site (url()), writes
4
+ // words into the page (content-[…]), or sets an arbitrary property ([mask:…]). Pure: the kit runs it
5
+ // to drop a bad class from a tree it draws, CalOS to refuse one, Cal to be told why.
6
+ //
7
+ // It checks a class's shape, not that Tailwind knows the utility: `known` (from Tailwind's own design
8
+ // system, where it's loaded, in CalOS) adds that. A shape-valid class Tailwind doesn't know just draws nothing.
9
+ /** Variants a class may carry: screen sizes, states, and a few structural ones. */
10
+ export const VARIANTS = new Set([
11
+ "sm", "md", "lg", "xl", "2xl",
12
+ "max-sm", "max-md", "max-lg", "max-xl", "max-2xl",
13
+ "hover", "focus", "focus-visible", "focus-within", "active", "disabled", "visited",
14
+ "group-hover", "group-focus", "group-focus-within", "peer-hover", "peer-focus", "peer-checked",
15
+ // A menu button drawn open (its lines turned to a cross) while what it opens is: the accordion says aria-expanded on it.
16
+ "group-aria-expanded", "peer-aria-expanded",
17
+ "first", "last", "odd", "even", "only", "empty",
18
+ "motion-safe", "motion-reduce", "print", "dark",
19
+ "before", "after", "placeholder", "marker", "selection", "file",
20
+ "open", "aria-expanded", "aria-selected", "aria-current", "aria-disabled", "aria-pressed",
21
+ ]);
22
+ /** An arbitrary value that's only data: a number, a length, a colour, a percentage, a calc of those, or a var(--…). */
23
+ const SAFE_ARBITRARY = /^(?:-?[\d.]+(?:px|rem|em|%|vh|vw|svh|dvh|lvh|ch|ex|fr|deg|turn|s|ms)?|#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color-mix|calc|min|max|clamp|var|repeat|minmax|fit-content)\([\w\s.,%#+\-*/()]*\)|[\d.]+\/[\d.]+|auto|none|[a-z-]+)(?:_[\w.#%(),+\-*/]+)*$/;
24
+ /** Utilities never allowed, whatever their value: they write words, or run the browser's code. */
25
+ const NEVER = [/^content-\[/, /^will-change-/];
26
+ /** The classes of a class string: split on whitespace, empties dropped. */
27
+ export const classTokens = (c) => c.split(/\s+/).filter(Boolean);
28
+ /** A class's variants and its utility, split on colons outside brackets. */
29
+ export function splitVariants(token) {
30
+ const parts = [];
31
+ let depth = 0;
32
+ let cur = "";
33
+ for (const ch of token) {
34
+ if (ch === "[" || ch === "(")
35
+ depth++;
36
+ if (ch === "]" || ch === ")")
37
+ depth--;
38
+ if (ch === ":" && depth === 0) {
39
+ parts.push(cur);
40
+ cur = "";
41
+ }
42
+ else
43
+ cur += ch;
44
+ }
45
+ parts.push(cur);
46
+ return { variants: parts.slice(0, -1), utility: parts.at(-1) };
47
+ }
48
+ /** Why one class isn't allowed, or null when it is. */
49
+ export function classProblem(token, known) {
50
+ if (token.length > 120)
51
+ return "too long";
52
+ if (!/^[\w\-:/.[\]()%#,!*+@&>~=]+$/.test(token))
53
+ return "characters a class can't have";
54
+ const { variants, utility } = splitVariants(token);
55
+ for (const v of variants)
56
+ if (!VARIANTS.has(v))
57
+ return `the variant ${v}: isn't offered`;
58
+ const u = utility.replace(/^!/, "").replace(/!$/, "").replace(/^-/, "");
59
+ if (!u)
60
+ return "no utility";
61
+ if (u.startsWith("["))
62
+ return "an arbitrary property ([prop:value]) isn't allowed";
63
+ if (NEVER.some((re) => re.test(u)))
64
+ return "this utility isn't allowed";
65
+ const arb = /\[(.*)\]/.exec(u);
66
+ if (arb) {
67
+ const v = arb[1];
68
+ if (/url\(|['"`\\;{}@]|javascript:|expression\(/i.test(v))
69
+ return "a value that loads or runs something";
70
+ if (!SAFE_ARBITRARY.test(v))
71
+ return "an arbitrary value that isn't a number, length, colour or the site's variable";
72
+ }
73
+ // An opacity after a slash (bg-ink/80, bg-ink/[.5]) is taken off before the shape is checked; a slash
74
+ // inside an arbitrary value (aspect-[4/3], w-[calc(100%/3)]) is the value's own.
75
+ const base = /\]$/.test(u) && !/\/\[[^\]]*\]$/.test(u) ? u : u.replace(/\/(?:\[[^\]]*\]|[\w.]+)$/, "");
76
+ // A half step (p-0.5, gap-1.5) and a number with a % (a gradient's stop: via-32%) are a utility's own values too.
77
+ if (!/^[a-z@][\w-]*(?:\.\d+)?(?:\d%)?(?:-\[.*\])?$/.test(base))
78
+ return "not a Tailwind utility's shape";
79
+ if (known && !known(utility))
80
+ return "Tailwind has no such class";
81
+ return null;
82
+ }
83
+ /** A class string checked whole: ok, or each class that isn't and why. */
84
+ export function checkClasses(c, o = {}) {
85
+ const bad = [];
86
+ for (const token of classTokens(c)) {
87
+ const why = classProblem(token, o.known);
88
+ if (why)
89
+ bad.push({ token, why });
90
+ }
91
+ return { ok: bad.length === 0, bad };
92
+ }
93
+ /** The allowed classes of a string (what the kit draws: a bad one is dropped, the rest kept). */
94
+ export const safeClasses = (c) => (c ? classTokens(c).filter((t) => !classProblem(t)).join(" ") : "");
@@ -0,0 +1,145 @@
1
+ import { z } from "zod";
2
+ import { behavior } from "./behaviors.ts";
3
+ export { checkClasses, classProblem, classTokens, safeClasses, splitVariants, VARIANTS, type ClassCheck } from "./classes.ts";
4
+ export { behavior, BEHAVIORS, type Behavior, type BehaviorName } from "./behaviors.ts";
5
+ export * from "./messages.ts";
6
+ /** The elements a tree may hold: what a page's sections are made of, never script, style or embeds. */
7
+ export declare const TAGS: readonly ["section", "div", "header", "footer", "nav", "article", "aside", "h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "strong", "em", "small", "a", "button", "ul", "ol", "li", "figure", "figcaption", "img", "blockquote", "hr", "br", "icon", "video", "form", "label", "input", "textarea", "select", "option"];
8
+ export type Tag = (typeof TAGS)[number];
9
+ /** Elements that hold no children and no words. */
10
+ export declare const VOID_TAGS: readonly Tag[];
11
+ /** A form's fields: an input, a longer one, a list of choices. Each only inside a form. */
12
+ export declare const FIELD_TAGS: readonly Tag[];
13
+ /** What a field holds: the kinds of input a visitor fills in or picks. */
14
+ export declare const INPUT_KINDS: readonly ["text", "email", "tel", "number", "date", "url", "checkbox", "radio"];
15
+ /**
16
+ * A form field's own settings: the name its answer is sent as (CalOS takes each by its name: email,
17
+ * phone, name, message, or a field's key), what it holds, whether it must be filled, the words shown
18
+ * while it's empty, and a radio's or checkbox's value. Never a name starting _ (the kit's own: its spam trap).
19
+ */
20
+ export declare const fieldSpec: z.ZodObject<{
21
+ name: z.ZodString;
22
+ type: z.ZodOptional<z.ZodEnum<{
23
+ number: "number";
24
+ date: "date";
25
+ text: "text";
26
+ email: "email";
27
+ tel: "tel";
28
+ url: "url";
29
+ checkbox: "checkbox";
30
+ radio: "radio";
31
+ }>>;
32
+ required: z.ZodOptional<z.ZodBoolean>;
33
+ placeholder: z.ZodOptional<z.ZodString>;
34
+ value: z.ZodOptional<z.ZodString>;
35
+ rows: z.ZodOptional<z.ZodNumber>;
36
+ checked: z.ZodOptional<z.ZodBoolean>;
37
+ }, z.core.$strict>;
38
+ export type FieldSpec = z.infer<typeof fieldSpec>;
39
+ /** How deep a tree goes, how many nodes it has, how long a node's classes and words are. */
40
+ export declare const LIMITS: {
41
+ readonly depth: 16;
42
+ readonly nodes: 600;
43
+ readonly classLength: 1200;
44
+ readonly textLength: 4000;
45
+ };
46
+ /** A node's stable id: made by CalOS when a node is added, kept as it moves; the preview reports clicks by it. */
47
+ export declare const nodeId: z.ZodString;
48
+ /** Where a link goes: a page of the site, a part of a page, or another site over https. Nothing else (no javascript:, data:). */
49
+ export declare const safeHref: z.ZodString;
50
+ export declare const imageRef: z.ZodObject<{
51
+ src: z.ZodString;
52
+ alt: z.ZodDefault<z.ZodString>;
53
+ phoneSrc: z.ZodOptional<z.ZodString>;
54
+ }, z.core.$strip>;
55
+ export type ImageRef = z.infer<typeof imageRef>;
56
+ /**
57
+ * Shared content a node reads: a dotted path into the site's content ("company.email",
58
+ * "caseStudies"). With `repeat`, a list whose items the node is copied for; inside it, `bind` paths
59
+ * starting with "item." read the item ("item.name").
60
+ */
61
+ export declare const contentPath: z.ZodString;
62
+ export type Node = {
63
+ id: string;
64
+ tag: Tag;
65
+ /** Tailwind classes (classes.ts says which are allowed). */
66
+ class?: string;
67
+ /** Its words, light Markdown (bold, italic, links). Not on void tags. */
68
+ text?: string;
69
+ href?: string;
70
+ /** Opens in a new tab (an outside link). */
71
+ external?: boolean;
72
+ image?: ImageRef;
73
+ /** icon: a name from the kit's icon set (../sections/icons.ts). */
74
+ icon?: string;
75
+ /** video: a YouTube or Vimeo video by its id; the kit builds the embed's address itself. */
76
+ video?: {
77
+ provider: "youtube" | "vimeo";
78
+ id: string;
79
+ };
80
+ /** What it's called in the editor's layers ("Slide", "Card"); its tag otherwise. */
81
+ name?: string;
82
+ /** Shows a shared value in place of `text`, `href` or `image` (whichever the value is). */
83
+ bind?: {
84
+ path: string;
85
+ as?: "text" | "href" | "image";
86
+ };
87
+ /** One copy of this node per item of a shared list; inside, binds start with "item.". */
88
+ repeat?: {
89
+ path: string;
90
+ limit?: number;
91
+ };
92
+ behavior?: z.infer<typeof behavior>;
93
+ /** A slot of a component, filled where it's used ({ type: "use" } blocks). */
94
+ slot?: string;
95
+ /** Hidden on the page, kept in the tree (a draft part). */
96
+ hidden?: boolean;
97
+ /** Accessibility: its label where its words don't say what it is (an icon button). */
98
+ label?: string;
99
+ /** A form field's settings (input, textarea, select). */
100
+ field?: FieldSpec;
101
+ /** A label: the field it names, by its id (in the same tree). */
102
+ for?: string;
103
+ /** A form's: the business's form it sends to, by key. */
104
+ sendTo?: string;
105
+ children?: Node[];
106
+ };
107
+ export declare const node: z.ZodType<Node>;
108
+ /** Problems with a whole tree beyond each node's own: depth, size, ids used twice. */
109
+ export declare function treeProblems(root: Node): string[];
110
+ /** The section: a tree, as a block of a page. */
111
+ export declare const built: z.ZodObject<{
112
+ name: z.ZodOptional<z.ZodString>;
113
+ root: z.ZodType<Node, unknown, z.core.$ZodTypeInternals<Node, unknown>>;
114
+ }, z.core.$strip>;
115
+ /** A tree kept once and used by name, its slots filled where it's used. */
116
+ export declare const component: z.ZodObject<{
117
+ key: z.ZodString;
118
+ name: z.ZodString;
119
+ root: z.ZodType<Node, unknown, z.core.$ZodTypeInternals<Node, unknown>>;
120
+ }, z.core.$strip>;
121
+ export type Component = z.infer<typeof component>;
122
+ /** A use of a component: its slots' words, links and pictures. */
123
+ export declare const use: z.ZodObject<{
124
+ component: z.ZodString;
125
+ slots: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
126
+ text: z.ZodOptional<z.ZodString>;
127
+ href: z.ZodOptional<z.ZodString>;
128
+ image: z.ZodOptional<z.ZodObject<{
129
+ src: z.ZodString;
130
+ alt: z.ZodDefault<z.ZodString>;
131
+ phoneSrc: z.ZodOptional<z.ZodString>;
132
+ }, z.core.$strip>>;
133
+ }, z.core.$strip>>>;
134
+ }, z.core.$strip>;
135
+ /** Every node of a tree, depth first, with its parent and place. */
136
+ export declare function walk(root: Node, parent?: Node | null, index?: number): Generator<{
137
+ node: Node;
138
+ parent: Node | null;
139
+ index: number;
140
+ depth: number;
141
+ }>;
142
+ /** The node with this id, if the tree has it. */
143
+ export declare function findNode(root: Node, id: string): Node | null;
144
+ /** A new node id, as CalOS makes them. */
145
+ export declare function newNodeId(): string;
@@ -0,0 +1,196 @@
1
+ // A section built in CalOS as a tree of elements styled with Tailwind (cal-os docs/design/site-builder.md):
2
+ // anything a designer builds with HTML and Tailwind, a business (or Cal for it) builds and changes
3
+ // without code. This file is the contract every side keeps to: the kit draws a tree (server and
4
+ // preview), CalOS edits it, Cal proposes changes to it, and all of them check it the same way.
5
+ //
6
+ // A tree is content like any other block: { type: "built", root: Node }. A component is a tree kept
7
+ // once in the site's content (`components`) and used by name with its slots filled.
8
+ import { z } from "zod";
9
+ import { checkClasses } from "./classes.js";
10
+ import { behavior } from "./behaviors.js";
11
+ export { checkClasses, classProblem, classTokens, safeClasses, splitVariants, VARIANTS } from "./classes.js";
12
+ export { behavior, BEHAVIORS } from "./behaviors.js";
13
+ export * from "./messages.js";
14
+ /** The elements a tree may hold: what a page's sections are made of, never script, style or embeds. */
15
+ export const TAGS = ["section", "div", "header", "footer", "nav", "article", "aside", "h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "strong", "em", "small", "a", "button", "ul", "ol", "li", "figure", "figcaption", "img", "blockquote", "hr", "br", "icon", "video", "form", "label", "input", "textarea", "select", "option"];
16
+ /** Elements that hold no children and no words. */
17
+ export const VOID_TAGS = ["img", "hr", "br", "icon", "video", "input", "textarea"];
18
+ /** A form's fields: an input, a longer one, a list of choices. Each only inside a form. */
19
+ export const FIELD_TAGS = ["input", "textarea", "select"];
20
+ /** What a field holds: the kinds of input a visitor fills in or picks. */
21
+ export const INPUT_KINDS = ["text", "email", "tel", "number", "date", "url", "checkbox", "radio"];
22
+ /**
23
+ * A form field's own settings: the name its answer is sent as (CalOS takes each by its name: email,
24
+ * phone, name, message, or a field's key), what it holds, whether it must be filled, the words shown
25
+ * while it's empty, and a radio's or checkbox's value. Never a name starting _ (the kit's own: its spam trap).
26
+ */
27
+ export const fieldSpec = z
28
+ .object({
29
+ name: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/, { error: "A field's name is lowercase letters, numbers and _, like email or best_time" }),
30
+ type: z.enum(INPUT_KINDS).optional(),
31
+ required: z.boolean().optional(),
32
+ placeholder: z.string().max(120).optional(),
33
+ value: z.string().max(120).optional(),
34
+ rows: z.number().int().min(2).max(20).optional(),
35
+ /** A tick box or a choice (radio): chosen at first. */
36
+ checked: z.boolean().optional(),
37
+ })
38
+ .strict();
39
+ /** How deep a tree goes, how many nodes it has, how long a node's classes and words are. */
40
+ export const LIMITS = { depth: 16, nodes: 600, classLength: 1200, textLength: 4000 };
41
+ /** A node's stable id: made by CalOS when a node is added, kept as it moves; the preview reports clicks by it. */
42
+ export const nodeId = z.string().regex(/^[a-z0-9]{6,24}$/);
43
+ /** Where a link goes: a page of the site, a part of a page, or another site over https. Nothing else (no javascript:, data:). */
44
+ export const safeHref = z
45
+ .string()
46
+ .max(500)
47
+ .refine((h) => /^\/(?!\/)[^\s]*$/.test(h) || /^#[\w-]+$/.test(h) || /^https?:\/\/[^\s]+$/i.test(h) || /^(mailto|tel):[^\s]+$/i.test(h), "A page path, #part, http(s) address, mailto: or tel:");
48
+ /** A picture: one of the site's own (uploads/… or its own public files), never another site's address. */
49
+ const ownPicture = z.string().max(500).refine((s) => !/^[a-z][a-z0-9+.-]*:/i.test(s) && !s.startsWith("//"), "One of the site's own pictures");
50
+ export const imageRef = z.object({
51
+ src: ownPicture,
52
+ alt: z.string().max(300).default(""),
53
+ /** Another picture of the same thing for phones (a tall screenshot of the phone layout), shown on small screens in its place. */
54
+ phoneSrc: ownPicture.optional(),
55
+ });
56
+ /**
57
+ * Shared content a node reads: a dotted path into the site's content ("company.email",
58
+ * "caseStudies"). With `repeat`, a list whose items the node is copied for; inside it, `bind` paths
59
+ * starting with "item." read the item ("item.name").
60
+ */
61
+ export const contentPath = z.string().regex(/^(item\.)?[a-zA-Z][\w]*(\.[a-zA-Z0-9_]+)*$/).max(120);
62
+ const classes = z
63
+ .string()
64
+ .max(LIMITS.classLength)
65
+ .superRefine((c, ctx) => {
66
+ const r = checkClasses(c);
67
+ if (!r.ok)
68
+ ctx.addIssue({ code: "custom", message: `Not allowed: ${r.bad.map((b) => `${b.token} (${b.why})`).join(", ")}` });
69
+ });
70
+ export const node = z.lazy(() => z
71
+ .object({
72
+ id: nodeId,
73
+ tag: z.enum(TAGS),
74
+ class: classes.optional(),
75
+ text: z.string().max(LIMITS.textLength).optional(),
76
+ href: safeHref.optional(),
77
+ external: z.boolean().optional(),
78
+ image: imageRef.optional(),
79
+ icon: z.string().max(60).optional(),
80
+ video: z.object({ provider: z.enum(["youtube", "vimeo"]), id: z.string().regex(/^[\w-]{6,20}$/) }).strict().optional(),
81
+ name: z.string().max(60).optional(),
82
+ bind: z.object({ path: contentPath, as: z.enum(["text", "href", "image"]).optional() }).optional(),
83
+ repeat: z.object({ path: contentPath, limit: z.number().int().min(1).max(100).optional() }).optional(),
84
+ behavior: behavior.optional(),
85
+ slot: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/).optional(),
86
+ hidden: z.boolean().optional(),
87
+ label: z.string().max(200).optional(),
88
+ field: fieldSpec.optional(),
89
+ for: nodeId.optional(),
90
+ /** A form's: which of the business's forms it sends to, by its key (contact), never an address. */
91
+ sendTo: z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/, { error: "A form's key is lowercase letters, numbers and -, like contact" }).optional(),
92
+ children: z.array(node).max(200).optional(),
93
+ })
94
+ .strict()
95
+ .superRefine((n, ctx) => {
96
+ if (VOID_TAGS.includes(n.tag) && (n.children?.length || n.text))
97
+ ctx.addIssue({ code: "custom", message: `A ${n.tag} holds no words or children` });
98
+ if (n.tag === "img" && !n.image && !n.bind)
99
+ ctx.addIssue({ code: "custom", message: "A picture needs its picture" });
100
+ if (n.tag === "icon" && !n.icon)
101
+ ctx.addIssue({ code: "custom", message: "An icon needs its name" });
102
+ if (FIELD_TAGS.includes(n.tag) && !n.field)
103
+ ctx.addIssue({ code: "custom", message: `A ${n.tag === "select" ? "list of choices" : "field"} needs its name (field.name)` });
104
+ if (n.field && !FIELD_TAGS.includes(n.tag))
105
+ ctx.addIssue({ code: "custom", message: "Only a form's fields (input, textarea, select) have field settings" });
106
+ if (n.sendTo && n.tag !== "form")
107
+ ctx.addIssue({ code: "custom", message: "Only a form sends to one of the business's forms (sendTo)" });
108
+ if (n.for && n.tag !== "label")
109
+ ctx.addIssue({ code: "custom", message: "Only a label names a field (for)" });
110
+ if (n.tag === "select" && n.children?.some((c) => c.tag !== "option"))
111
+ ctx.addIssue({ code: "custom", message: "A list of choices holds only its choices (option)" });
112
+ }));
113
+ /** Problems with a whole tree beyond each node's own: depth, size, ids used twice. */
114
+ export function treeProblems(root) {
115
+ const out = [];
116
+ const seen = new Set();
117
+ let count = 0;
118
+ const fields = new Set();
119
+ const labels = [];
120
+ const walk = (n, depth, inForm = false) => {
121
+ count++;
122
+ if (depth > LIMITS.depth)
123
+ out.push(`Nested more than ${LIMITS.depth} deep at ${n.id}`);
124
+ // A form's parts belong in a form, and a form in no other.
125
+ if (n.tag === "form" && inForm)
126
+ out.push(`A form can't be inside another form (${n.id})`);
127
+ if ((FIELD_TAGS.includes(n.tag) || n.tag === "option") && !inForm && n.tag !== "option")
128
+ out.push(`A field belongs inside a form (${n.id})`);
129
+ if (FIELD_TAGS.includes(n.tag))
130
+ fields.add(n.id);
131
+ if (n.tag === "label" && n.for)
132
+ labels.push({ id: n.id, for: n.for });
133
+ if (seen.has(n.id))
134
+ out.push(`The id ${n.id} is used twice`);
135
+ seen.add(n.id);
136
+ for (const c of n.children ?? [])
137
+ walk(c, depth + 1, inForm || n.tag === "form");
138
+ };
139
+ walk(root, 1);
140
+ for (const l of labels)
141
+ if (!fields.has(l.for))
142
+ out.push(`The label ${l.id} names ${l.for}, which isn't a field of this tree`);
143
+ if (count > LIMITS.nodes)
144
+ out.push(`More than ${LIMITS.nodes} elements`);
145
+ return out;
146
+ }
147
+ /** The section: a tree, as a block of a page. */
148
+ export const built = z
149
+ .object({
150
+ name: z.string().max(80).optional().meta({ title: "Name" }),
151
+ root: node,
152
+ })
153
+ .superRefine((b, ctx) => {
154
+ for (const p of treeProblems(b.root))
155
+ ctx.addIssue({ code: "custom", message: p });
156
+ })
157
+ .meta({ title: "Built section", description: "Built from elements and Tailwind classes, in the editor or by Cal" });
158
+ /** A tree kept once and used by name, its slots filled where it's used. */
159
+ export const component = z.object({
160
+ key: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/),
161
+ name: z.string().max(80),
162
+ root: node,
163
+ });
164
+ /** A use of a component: its slots' words, links and pictures. */
165
+ export const use = z
166
+ .object({
167
+ component: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/),
168
+ slots: z.record(z.string(), z.object({ text: z.string().max(LIMITS.textLength).optional(), href: safeHref.optional(), image: imageRef.optional() })).default({}),
169
+ })
170
+ .meta({ title: "Component", description: "One of the site's own components, its words and pictures filled in" });
171
+ /** Every node of a tree, depth first, with its parent and place. */
172
+ export function* walk(root, parent = null, index = 0) {
173
+ const stack = [{ node: root, parent, index, depth: 0 }];
174
+ while (stack.length) {
175
+ const at = stack.pop();
176
+ yield at;
177
+ const kids = at.node.children ?? [];
178
+ for (let i = kids.length - 1; i >= 0; i--)
179
+ stack.push({ node: kids[i], parent: at.node, index: i, depth: at.depth + 1 });
180
+ }
181
+ }
182
+ /** The node with this id, if the tree has it. */
183
+ export function findNode(root, id) {
184
+ for (const { node: n } of walk(root))
185
+ if (n.id === id)
186
+ return n;
187
+ return null;
188
+ }
189
+ /** A new node id, as CalOS makes them. */
190
+ export function newNodeId() {
191
+ const a = "abcdefghijklmnopqrstuvwxyz0123456789";
192
+ let s = "";
193
+ for (let i = 0; i < 10; i++)
194
+ s += a[Math.floor(Math.random() * a.length)];
195
+ return s;
196
+ }
@@ -0,0 +1,45 @@
1
+ /** From the preview to CalOS. */
2
+ export type PreviewToEditor =
3
+ /** An element of a built section was clicked: its block's place on the page and its node's id. */
4
+ {
5
+ type: "calos:built-select";
6
+ block: number;
7
+ node: string;
8
+ shift: boolean;
9
+ }
10
+ /** The pointer is over an element (for the editor to outline it in its layers). */
11
+ | {
12
+ type: "calos:built-hover";
13
+ block: number;
14
+ node: string | null;
15
+ }
16
+ /** Where each built element sits on screen, for the editor's overlays (handles, spacing guides). */
17
+ | {
18
+ type: "calos:built-boxes";
19
+ block: number;
20
+ boxes: {
21
+ node: string;
22
+ x: number;
23
+ y: number;
24
+ w: number;
25
+ h: number;
26
+ }[];
27
+ };
28
+ /** From CalOS to the preview. */
29
+ export type EditorToPreview =
30
+ /** Outline these nodes (the selection), scrolled into view. */
31
+ {
32
+ type: "calos:built-highlight";
33
+ block: number;
34
+ nodes: string[];
35
+ }
36
+ /** Draw a node as hovered or focused, while its hover or focus classes are edited. */
37
+ | {
38
+ type: "calos:built-state";
39
+ block: number;
40
+ node: string;
41
+ state: "normal" | "hover" | "focus";
42
+ };
43
+ export declare const isPreviewToEditor: (m: unknown) => m is PreviewToEditor;
44
+ /** The attribute that marks a built element with its node's id in the preview. */
45
+ export declare const NODE_ATTR = "data-node";
@@ -0,0 +1,7 @@
1
+ // What CalOS's editor and the site's preview page (/calos-preview) say to each other about built
2
+ // sections, by postMessage. The preview draws a tree with each element marked by its node's id
3
+ // (data-node, in the preview only), so a click is never guessed from its words, as it is for a coded
4
+ // section (../preview-match.ts).
5
+ export const isPreviewToEditor = (m) => !!m && typeof m === "object" && typeof m.type === "string" && m.type.startsWith("calos:built-");
6
+ /** The attribute that marks a built element with its node's id in the preview. */
7
+ export const NODE_ATTR = "data-node";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.11",
3
+ "version": "0.3.0",
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",
@@ -49,6 +49,18 @@
49
49
  "types": "./dist/sections/index.d.ts",
50
50
  "default": "./dist/sections/index.js"
51
51
  },
52
+ "./tree": {
53
+ "types": "./dist/tree/index.d.ts",
54
+ "default": "./dist/tree/index.js"
55
+ },
56
+ "./content-migrate": {
57
+ "types": "./dist/content-migrate.d.ts",
58
+ "default": "./dist/content-migrate.js"
59
+ },
60
+ "./theme": {
61
+ "types": "./dist/built/theme.d.ts",
62
+ "default": "./dist/built/theme.js"
63
+ },
52
64
  "./migrate": {
53
65
  "types": "./dist/migrate.d.ts",
54
66
  "default": "./dist/migrate.js"
@@ -67,9 +79,11 @@
67
79
  },
68
80
  "dependencies": {
69
81
  "@resvg/resvg-js": "2.6.2",
82
+ "@tailwindcss/browser": "4.3.3",
70
83
  "marked": "18.1.0",
71
84
  "satori": "0.33.4",
72
85
  "sharp": "0.35.5",
86
+ "tailwindcss": "4.3.3",
73
87
  "zod": "4.6.7"
74
88
  },
75
89
  "peerDependencies": {