@u-krupaveho-kraba/form-schemas 0.3.0 → 0.5.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 CHANGED
@@ -36,6 +36,20 @@ if (!result.success) {
36
36
  }
37
37
  ```
38
38
 
39
+ ### Types
40
+
41
+ Every schema's inferred type ships alongside it. The four most-reached-for
42
+ shapes are exported under their plain names as well as the historical
43
+ `*SchemaOutput` ones:
44
+
45
+ ```ts
46
+ import type { Form, FormTheme, Variant, CampaignItem } from "@topol/form-schemas";
47
+ ```
48
+
49
+ `Variant`/`CampaignItem` here are the **wire** shapes, carrying `definition`
50
+ (an inline form or a `{ref}` pointer). The *editor-facing* equivalents, which
51
+ carry a resolved `form` instead, live in `@topol/form-editor-schemas`.
52
+
39
53
  ### Generating JSON Schema
40
54
 
41
55
  Both schema families can be serialized to standard JSON Schema (draft
@@ -0,0 +1,137 @@
1
+ import xss from "xss";
2
+ // xss's CJS build attaches `whiteList` (and escapeAttrValue/safeAttrValue,
3
+ // see sanitizeHtml.ts) as extra properties on the same object it
4
+ // default-exports, via a runtime loop rather than a literal `exports.x =`
5
+ // statement -- Node's native ESM interop (cjs-module-lexer) only detects
6
+ // the latter statically, so `import { whiteList } from "xss"` type-checks
7
+ // (xss's own hand-written .d.ts declares it) but resolves to `undefined` at
8
+ // runtime under a plain Node ESM loader. Surfaced by testing/form-screenshot's
9
+ // Playwright suite, which runs test files directly under Node -- every
10
+ // Vite-bundled app sidesteps this via esbuild's more permissive interop.
11
+ // The default import is the one thing every runtime agrees on (it IS the
12
+ // whole module.exports object), so read the rest off of it instead.
13
+ const xssDefaultWhiteList = xss.whiteList;
14
+ // Extends xss's own default whitelist with extra per-tag attributes a
15
+ // hand-authored HTML snippet plausibly needs (links, images, tables, form
16
+ // controls) -- ported from landing-page-editor's getXssWhiteList.ts, which
17
+ // turned out to have nothing landing-page-specific in it. Deliberately
18
+ // drops the email/MSO-specific handling landing-page-editor and
19
+ // email-editor layer on top of this in their own editor components (Outlook
20
+ // conditional comments, unused/unclosed-tag repair) -- not applicable to a
21
+ // form, which never renders in an email client.
22
+ export function getHtmlWhiteList() {
23
+ const whiteList = JSON.parse(JSON.stringify(xssDefaultWhiteList));
24
+ whiteList["a"] = [
25
+ "download",
26
+ "href",
27
+ "hreflang",
28
+ "ping",
29
+ "referrerpolicy",
30
+ "rel",
31
+ "target",
32
+ "type",
33
+ "title",
34
+ "charset",
35
+ "coords",
36
+ "name",
37
+ "rev",
38
+ "shape",
39
+ ];
40
+ whiteList["area"] = [
41
+ "alt",
42
+ "coords",
43
+ "download",
44
+ "href",
45
+ "ping",
46
+ "referrerpolicy",
47
+ "rel",
48
+ "shape",
49
+ "target",
50
+ ];
51
+ // `formaction`/`formmethod`/`formtarget` are omitted deliberately: author
52
+ // HTML renders inside the widget's real <form>, so a pasted button
53
+ // carrying them re-points native submission at an arbitrary host. The
54
+ // widget's own submit button is type="button", which would make a pasted
55
+ // one the only native submit in the form. Nothing was lost by dropping
56
+ // them -- this list came from landing-page-editor, which never renders
57
+ // author HTML inside a form at all.
58
+ whiteList["button"] = [
59
+ "autofocus",
60
+ "disabled",
61
+ "form",
62
+ "formenctype",
63
+ "formnovalidate",
64
+ "name",
65
+ "type",
66
+ "value",
67
+ ];
68
+ whiteList["img"] = [
69
+ "alt",
70
+ "crossorigin",
71
+ "decoding",
72
+ "height",
73
+ "loading",
74
+ "referrerpolicy",
75
+ "sizes",
76
+ "src",
77
+ "srcset",
78
+ "usemap",
79
+ "width",
80
+ "align",
81
+ "border",
82
+ "hspace",
83
+ "vspace",
84
+ ];
85
+ whiteList["li"] = ["value"];
86
+ whiteList["ol"] = ["reversed", "start", "type"];
87
+ whiteList["style"] = [];
88
+ whiteList["table"] = [
89
+ "align",
90
+ "bgcolor",
91
+ "border",
92
+ "cellpadding",
93
+ "cellspacing",
94
+ "frame",
95
+ "rules",
96
+ "summary",
97
+ "width",
98
+ ];
99
+ whiteList["tbody"] =
100
+ whiteList["tfoot"] =
101
+ whiteList["thead"] =
102
+ ["align", "bgcolor", "char", "charoff", "valign"];
103
+ whiteList["td"] = whiteList["th"] = [
104
+ "abbr",
105
+ "align",
106
+ "bgcolor",
107
+ "char",
108
+ "charoff",
109
+ "colspan",
110
+ "headers",
111
+ "height",
112
+ "rowspan",
113
+ "scope",
114
+ "valign",
115
+ "width",
116
+ ];
117
+ whiteList["tr"] = ["align", "bgcolor", "char", "charoff", "valign"];
118
+ // Attributes every tag gets, regardless of what's listed above -- without
119
+ // this, `style`/`class`/`id` are simply absent from xss's own default
120
+ // whitelist (e.g. `div: []` allows no attributes at all), so any inline
121
+ // styling or CSS hook on a hand-typed snippet would silently vanish.
122
+ const globalAttributes = [
123
+ "class",
124
+ "id",
125
+ "style",
126
+ "title",
127
+ "dir",
128
+ "lang",
129
+ "role",
130
+ "tabindex",
131
+ "hidden",
132
+ ];
133
+ for (const tag in whiteList) {
134
+ whiteList[tag] = whiteList[tag].concat(globalAttributes);
135
+ }
136
+ return whiteList;
137
+ }
package/index.js CHANGED
@@ -8,12 +8,31 @@ export * from "./schemas/element.js";
8
8
  export * from "./schemas/step.js";
9
9
  export * from "./schemas/form.js";
10
10
  export * from "./schemas/campaign.js";
11
+ export { sanitizeHtml } from "./sanitizeHtml.js";
12
+ const JSON_TYPE_BY_ZOD_TYPE = {
13
+ string: "string",
14
+ number: "number",
15
+ boolean: "boolean",
16
+ };
17
+ const jsonSchemaParams = {
18
+ unrepresentable: "any",
19
+ override: ({ zodSchema, jsonSchema }) => {
20
+ const def = zodSchema._zod.def;
21
+ if (def.type !== "pipe")
22
+ return;
23
+ const zodType = def.in?._zod?.def?.type;
24
+ if (zodType && zodType in JSON_TYPE_BY_ZOD_TYPE) {
25
+ jsonSchema.type =
26
+ JSON_TYPE_BY_ZOD_TYPE[zodType];
27
+ }
28
+ },
29
+ };
11
30
  /**
12
31
  * Serializes the full form type tree as standard JSON Schema —
13
32
  * used to tell an LLM the exact shape of valid form JSON (issue 024).
14
33
  */
15
34
  export function getFormJsonSchema() {
16
- return z.toJSONSchema(FormSchema);
35
+ return z.toJSONSchema(FormSchema, jsonSchemaParams);
17
36
  }
18
37
  /**
19
38
  * Serializes the CampaignItem contract as standard JSON Schema — the
@@ -24,5 +43,5 @@ export function getFormJsonSchema() {
24
43
  * now the atomic authored/published unit.
25
44
  */
26
45
  export function getCampaignItemJsonSchema() {
27
- return z.toJSONSchema(CampaignItemSchema);
46
+ return z.toJSONSchema(CampaignItemSchema, jsonSchemaParams);
28
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@u-krupaveho-kraba/form-schemas",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Zod schemas for Topol form JSON (validation gate + AI generation)",
5
5
  "author": "Topol.io",
6
6
  "license": "ISC",
@@ -23,6 +23,9 @@
23
23
  "publishConfig": {
24
24
  "access": "public"
25
25
  },
26
+ "dependencies": {
27
+ "xss": "^1.0.15"
28
+ },
26
29
  "peerDependencies": {
27
30
  "zod": "^4.1.11"
28
31
  }
@@ -0,0 +1,57 @@
1
+ import xss from "xss";
2
+ import { getHtmlWhiteList } from "./htmlWhiteList.js";
3
+ // See htmlWhiteList.ts's comment: escapeAttrValue and getDefaultCSSWhiteList
4
+ // are runtime-loop-assigned CJS properties, not statically-detectable named
5
+ // exports -- read them off the default import (the same object, guaranteed)
6
+ // rather than `import { escapeAttrValue } from "xss"` directly.
7
+ const { escapeAttrValue, getDefaultCSSWhiteList } = xss;
8
+ // Every CSS property xss already knows about, all allowed. Sourced from xss
9
+ // rather than importing `cssfilter` directly: xss owns that dependency, and
10
+ // reaching past it into an untyped transitive package is what forced an
11
+ // ambient `cssfilter.d.ts` shim into six separate form packages and apps,
12
+ // most of which never referenced cssfilter at all.
13
+ // Built from the keys rather than spread: xss types the returned whitelist as
14
+ // carrying a `process` member, which it does not at runtime (verified -- every
15
+ // entry is a CSS property name with a boolean value).
16
+ const cssWhiteList = Object.fromEntries(Object.keys(getDefaultCSSWhiteList()).map((property) => [property, true]));
17
+ function allowDataAttributes(_tag, name, value) {
18
+ if (name.startsWith("data-")) {
19
+ return `${name}="${escapeAttrValue(value)}"`;
20
+ }
21
+ }
22
+ const whiteList = getHtmlWhiteList();
23
+ // The security control for every stored html/text property (ticket 169):
24
+ // applied as a schema .transform() in elementProperties.ts, so it runs on
25
+ // the READ path (migrateForm/migrateCampaignItem, both apps) rather than
26
+ // only when content is authored. Lives here, not in the editor, because
27
+ // the transform needs it at schema-definition time -- form-schemas has no
28
+ // dependency on form-shared (it's the other way around), so the sanitizer
29
+ // can't live in form-shared without a circular package dependency. It's
30
+ // re-exported from @topol/form-shared's public API so both apps can still
31
+ // reach it from the package the ticket named.
32
+ export function sanitizeHtml(html) {
33
+ return xss(html, {
34
+ allowCommentTag: true,
35
+ whiteList,
36
+ // NOT `stripIgnoreTag: true`. That removed disallowed tags outright, and
37
+ // since xss treats any `<`...`>` run as a tag it also deleted ordinary
38
+ // copy: "5 < 3 & 4 > 2" became "5 2", and "Hi <FirstName>" became "Hi ".
39
+ // Author text is destroyed silently, on load, for every form carrying a
40
+ // comparison or a placeholder.
41
+ //
42
+ // Escaping instead keeps that text (it becomes `&lt;`/`&gt;`), and the
43
+ // reason the option was set does not apply: it was chosen so a stray
44
+ // <script> would not survive as garbled on-page text, but <script> is
45
+ // removed entirely either way by stripIgnoreTagBody below -- tag and body
46
+ // both. What changes is that a disallowed *real* tag (<iframe>, <object>)
47
+ // now renders as visible escaped text rather than vanishing, which is the
48
+ // better failure: the author can see that something was rejected.
49
+ stripIgnoreTagBody: ["script"],
50
+ onIgnoreTagAttr: allowDataAttributes,
51
+ // xss applies this to `style` attributes through its own CSS filter, which
52
+ // is what the hand-written safeAttrValue used to do by calling cssfilter
53
+ // directly -- and that call needed a `@ts-expect-error`, because xss's own
54
+ // signature wants a cssFilter instance it was never given.
55
+ css: { whiteList: cssWhiteList },
56
+ });
57
+ }