justskill-design 0.0.1 → 3.2.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.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
package/lib/table.mjs
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/table.mjs — the format table. The single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Six consumers read this file:
|
|
5
|
+
* 1. fragment compilation (which properties and values are accepted)
|
|
6
|
+
* 2. validation (what is legal)
|
|
7
|
+
* 3. normalization (the one spelling per intent)
|
|
8
|
+
* 4. hints ("here is what this property accepts")
|
|
9
|
+
* 5. rendering (what gets emitted)
|
|
10
|
+
* 6. generated reference documents
|
|
11
|
+
*
|
|
12
|
+
* If a rule lives anywhere else, the six drift apart, and drift between the
|
|
13
|
+
* validator and the renderer is the chronic disease of tools like this one.
|
|
14
|
+
*
|
|
15
|
+
* Pure. No node: imports. Runs in the browser.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// -- element contract --------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** `data-type` to the tags that may carry it. */
|
|
21
|
+
export const TYPE_TAGS = {
|
|
22
|
+
page: ["section"],
|
|
23
|
+
frame: ["div"],
|
|
24
|
+
text: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span"],
|
|
25
|
+
image: ["img"],
|
|
26
|
+
shape: ["div"],
|
|
27
|
+
icon: ["svg"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The default type for a bare tag. `div` is `frame` unless a data-* says otherwise. */
|
|
31
|
+
export const TAG_TO_TYPE = {
|
|
32
|
+
section: "page",
|
|
33
|
+
div: "frame",
|
|
34
|
+
p: "text",
|
|
35
|
+
h1: "text",
|
|
36
|
+
h2: "text",
|
|
37
|
+
h3: "text",
|
|
38
|
+
h4: "text",
|
|
39
|
+
h5: "text",
|
|
40
|
+
h6: "text",
|
|
41
|
+
span: "text",
|
|
42
|
+
img: "image",
|
|
43
|
+
svg: "icon",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Types that may carry children. */
|
|
47
|
+
export const TYPE_HAS_CHILDREN = new Set(["page", "frame"]);
|
|
48
|
+
|
|
49
|
+
/** Required non-style attributes per type. */
|
|
50
|
+
export const ATTR_BY_TYPE = {
|
|
51
|
+
page: { required: [], optional: ["data-canvas"] },
|
|
52
|
+
frame: { required: [], optional: [] },
|
|
53
|
+
text: { required: [], optional: [] },
|
|
54
|
+
image: { required: ["src", "alt"], optional: ["data-credit"] },
|
|
55
|
+
shape: { required: ["data-kind"], optional: [] },
|
|
56
|
+
// `viewbox` is spelled lowercase here on purpose: every name in this table is
|
|
57
|
+
// the parser's normalized name, and the parser lowercases attribute names and
|
|
58
|
+
// keeps the author's spelling in `rawName`. The compiler writes `rawName`
|
|
59
|
+
// back, so the file keeps `viewBox` while this lookup stays case-insensitive.
|
|
60
|
+
//
|
|
61
|
+
// It sits under `optional`, not `required`, because it is only required when
|
|
62
|
+
// the icon has geometry — `structure/icon-without-viewbox` already says that,
|
|
63
|
+
// with the better hint ("so the browser clips it instead of scaling it").
|
|
64
|
+
// Listing it as unconditionally required would make every empty `<svg>` report
|
|
65
|
+
// `structure/missing-attribute` as well, and would report the same absence
|
|
66
|
+
// twice on a real icon.
|
|
67
|
+
//
|
|
68
|
+
// Why it was missing at all: the fragment compiler filters attributes *by this
|
|
69
|
+
// table*, so a hand-written `<svg viewBox="0 0 24 24">` was dropped on the way
|
|
70
|
+
// in and the whole `add` was then refused for the very thing just removed. An
|
|
71
|
+
// icon could not be added through the documented path.
|
|
72
|
+
//
|
|
73
|
+
// `fill`, `stroke`, `stroke-width` and `data-fill` are the presentation
|
|
74
|
+
// attributes the file's own base rules read: the stroke rule keys on
|
|
75
|
+
// `svg:not([data-fill])` and on `[fill]`/`[stroke]` on the children, and
|
|
76
|
+
// `data-fill` is the opt-out from that rule.
|
|
77
|
+
icon: {
|
|
78
|
+
required: ["data-icon", "data-icon-library"],
|
|
79
|
+
optional: ["viewbox", "fill", "stroke", "stroke-width", "data-fill"],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Optional `data-*` attributes accepted on every type. */
|
|
84
|
+
export const COMMON_OPTIONAL = ["data-name", "data-hidden", "data-locked"];
|
|
85
|
+
|
|
86
|
+
// Two kinds, because these are the two the format can draw: `rect` is the `div`
|
|
87
|
+
// and `ellipse` is the same `div` under `border-radius:50%`. `line` was here and
|
|
88
|
+
// nothing drew it — a `data-kind="line"` node rendered as a rectangle, a value
|
|
89
|
+
// the format accepted and then quietly ignored. A separator is a `rect` with a
|
|
90
|
+
// 1px side and a fill, which is what pen's exporter writes too.
|
|
91
|
+
export const SHAPE_KINDS = ["rect", "ellipse"];
|
|
92
|
+
export const ICON_LIBRARIES = ["lucide"];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The declarations that place an element on the canvas.
|
|
96
|
+
*
|
|
97
|
+
* A page's place is `data-canvas`, and that is the only spelling for it. A
|
|
98
|
+
* canvas drag used to write the same place as CSS on the page as well, which
|
|
99
|
+
* left a file with two answers: the canvas reads `data-canvas` and shows one
|
|
100
|
+
* position, and the deliverable — where a standalone page has nothing to be
|
|
101
|
+
* positioned against — shipped another.
|
|
102
|
+
*/
|
|
103
|
+
export const PAGE_PLACEMENT = new Set(["position", "left", "top", "right", "bottom", "z-index"]);
|
|
104
|
+
|
|
105
|
+
export const ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
106
|
+
export const TOKEN_NAME_PATTERN = /^--[a-z]+-[A-Za-z0-9_-]+$/;
|
|
107
|
+
|
|
108
|
+
/** The seven closed token groups. The group is the text before the first hyphen. */
|
|
109
|
+
export const TOKEN_GROUPS = ["color", "font", "space", "radius", "size", "shadow", "border"];
|
|
110
|
+
|
|
111
|
+
/** The unit each numeric token group carries. */
|
|
112
|
+
export const TOKEN_GROUP_UNIT = {
|
|
113
|
+
space: "px",
|
|
114
|
+
radius: "px",
|
|
115
|
+
size: "px",
|
|
116
|
+
font: null,
|
|
117
|
+
color: null,
|
|
118
|
+
shadow: null,
|
|
119
|
+
border: null,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// -- style properties --------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Emit order. The `style` attribute is always written in this order. */
|
|
125
|
+
export const CSS_ORDER = [
|
|
126
|
+
"display",
|
|
127
|
+
"flex-direction",
|
|
128
|
+
"flex-wrap",
|
|
129
|
+
"align-items",
|
|
130
|
+
"justify-content",
|
|
131
|
+
"gap",
|
|
132
|
+
"padding",
|
|
133
|
+
"position",
|
|
134
|
+
"left",
|
|
135
|
+
"top",
|
|
136
|
+
"width",
|
|
137
|
+
"height",
|
|
138
|
+
"min-width",
|
|
139
|
+
"max-width",
|
|
140
|
+
"min-height",
|
|
141
|
+
"max-height",
|
|
142
|
+
"flex",
|
|
143
|
+
"flex-shrink",
|
|
144
|
+
"align-self",
|
|
145
|
+
"margin",
|
|
146
|
+
"background-color",
|
|
147
|
+
"border",
|
|
148
|
+
"border-width",
|
|
149
|
+
"border-style",
|
|
150
|
+
"border-color",
|
|
151
|
+
"border-radius",
|
|
152
|
+
"box-shadow",
|
|
153
|
+
"opacity",
|
|
154
|
+
"backdrop-filter",
|
|
155
|
+
"transform",
|
|
156
|
+
"overflow",
|
|
157
|
+
"outline",
|
|
158
|
+
"outline-offset",
|
|
159
|
+
"font-family",
|
|
160
|
+
"font-size",
|
|
161
|
+
"font-weight",
|
|
162
|
+
"font-style",
|
|
163
|
+
"line-height",
|
|
164
|
+
"letter-spacing",
|
|
165
|
+
"color",
|
|
166
|
+
"text-align",
|
|
167
|
+
"text-transform",
|
|
168
|
+
"white-space",
|
|
169
|
+
"object-fit",
|
|
170
|
+
"background-image",
|
|
171
|
+
"background-size",
|
|
172
|
+
"background-position",
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
export const CSS_SET = new Set(CSS_ORDER);
|
|
176
|
+
|
|
177
|
+
const EMIT_INDEX = new Map(CSS_ORDER.map((name, i) => [name, i]));
|
|
178
|
+
|
|
179
|
+
export function compareProperties(a, b) {
|
|
180
|
+
const ai = EMIT_INDEX.has(a) ? EMIT_INDEX.get(a) : 1000;
|
|
181
|
+
const bi = EMIT_INDEX.has(b) ? EMIT_INDEX.get(b) : 1000;
|
|
182
|
+
if (ai !== bi) return ai - bi;
|
|
183
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isAllowedProperty(name) {
|
|
187
|
+
return EMIT_INDEX.has(name);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -- value grammars ----------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const C = (hint) => ({ kind: "color", hint });
|
|
193
|
+
const L = (hint, extra = {}) => ({ kind: "length", hint, ...extra });
|
|
194
|
+
const N = (hint, extra = {}) => ({ kind: "number", hint, ...extra });
|
|
195
|
+
const E = (values, hint) => ({ kind: "enum", values, hint });
|
|
196
|
+
const K = (values, hint) => ({ kind: "keyword-set", values, hint });
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One entry per allowed property. `kind` drives both the value check and the
|
|
200
|
+
* normalizer, so a property can never be legal in one and unknown in the other.
|
|
201
|
+
*/
|
|
202
|
+
export const CSS_ALLOWED = {
|
|
203
|
+
display: E(["flex", "block", "none"], "flex | block | none"),
|
|
204
|
+
"flex-direction": E(["row", "column"], "row | column"),
|
|
205
|
+
"flex-wrap": E(["wrap", "nowrap"], "wrap | nowrap"),
|
|
206
|
+
"align-items": K(["flex-start", "center", "flex-end", "stretch", "baseline"], "flex-start | center | flex-end | stretch | baseline"),
|
|
207
|
+
"justify-content": K(["flex-start", "center", "flex-end", "space-between", "space-around"], "flex-start | center | flex-end | space-between | space-around"),
|
|
208
|
+
gap: L("12px | var(--space-md)"),
|
|
209
|
+
padding: L("16px | 16px 24px | 16px 24px 8px 24px"),
|
|
210
|
+
position: E(["absolute", "relative"], "absolute | relative"),
|
|
211
|
+
left: L("-8px | 0 | var(--space-sm)"),
|
|
212
|
+
top: L("-8px | 0 | var(--space-sm)"),
|
|
213
|
+
width: L("236px | 100% | 50% | fit-content | 100vh | 100vw"),
|
|
214
|
+
height: L("844px | 100% | 50% | fit-content | 100vh | 100vw"),
|
|
215
|
+
"min-width": L("0 | 200px"),
|
|
216
|
+
"max-width": L("480px | 100%"),
|
|
217
|
+
"min-height": L("0 | 44px"),
|
|
218
|
+
"max-height": L("100% | 640px"),
|
|
219
|
+
flex: { kind: "flex", hint: "1 1 0% | 0 0 auto | 1 1 auto" },
|
|
220
|
+
"flex-shrink": N("0 | 1", { min: 0, max: 10 }),
|
|
221
|
+
"align-self": K(["flex-start", "center", "flex-end", "stretch"], "flex-start | center | flex-end | stretch"),
|
|
222
|
+
margin: L("0 | -8px | 16px 0"),
|
|
223
|
+
"background-color": C("#ffffff | #ffffffee | transparent | var(--color-bg)"),
|
|
224
|
+
border: { kind: "border", hint: "1px solid #e5e7eb | var(--border-hairline)" },
|
|
225
|
+
"border-width": L("1px | 1px 0 1px 0"),
|
|
226
|
+
"border-style": K(["solid", "dashed", "dotted"], "solid | dashed | dotted"),
|
|
227
|
+
"border-color": C("#e5e7eb | var(--color-line)"),
|
|
228
|
+
"border-radius": L("12px | 50% | var(--radius-lg)"),
|
|
229
|
+
"box-shadow": { kind: "shadow", hint: "0 6px 16px #0000001f | 0 1px 2px #0000001a, 0 4px 8px #00000014" },
|
|
230
|
+
opacity: N("0 | 0.5 | 1", { min: 0, max: 1 }),
|
|
231
|
+
// Paint-only, like `box-shadow`: it changes what the surface shows, never
|
|
232
|
+
// where anything sits, so no geometry rule has to know about it. Two
|
|
233
|
+
// functions, not a filter chain — see normalizeBackdropFilter for why.
|
|
234
|
+
"backdrop-filter": { kind: "backdrop", hint: "blur(20px) | blur(20px) saturate(1.4)" },
|
|
235
|
+
// One function, not a transform list: a rotation is the only one whose effect a
|
|
236
|
+
// static design and its checks can describe (a box turns, its layout does not).
|
|
237
|
+
// `translate()` is `left`/`top`, and `scale()` changes a size — both already
|
|
238
|
+
// have a spelling, and a second one is how the two drift apart.
|
|
239
|
+
transform: { kind: "transform", hint: "rotate(-1.5deg) | none" },
|
|
240
|
+
overflow: E(["hidden", "visible", "auto", "scroll"], "hidden | visible | auto | scroll"),
|
|
241
|
+
outline: { kind: "outline", hint: "1px solid #b8325e" },
|
|
242
|
+
"outline-offset": L("-2px | 2px"),
|
|
243
|
+
"font-family": { kind: "text", hint: "Inter, system-ui, sans-serif | var(--font-body)" },
|
|
244
|
+
"font-size": L("14px | var(--size-body)"),
|
|
245
|
+
"font-weight": { kind: "font-weight", hint: "400 | 500 | 600 | 700 | bold" },
|
|
246
|
+
"font-style": E(["normal", "italic"], "normal | italic"),
|
|
247
|
+
"line-height": { kind: "line-height", hint: "1.5 | 20px" },
|
|
248
|
+
"letter-spacing": L("0.02em | 1px"),
|
|
249
|
+
color: C("#111827 | var(--color-ink)"),
|
|
250
|
+
"text-align": E(["left", "center", "right"], "left | center | right"),
|
|
251
|
+
"text-transform": E(["uppercase", "lowercase", "capitalize", "none"], "uppercase | lowercase | capitalize | none"),
|
|
252
|
+
// `pre-line` is the one that means "the breaks I wrote, and nothing else":
|
|
253
|
+
// it keeps a newline and still folds a run of spaces, which is what pen's own
|
|
254
|
+
// export renders (`<br>` inside ordinary whitespace). `pre-wrap` keeps the
|
|
255
|
+
// spaces too, and a space at the end of a line then takes part in centring —
|
|
256
|
+
// 11px of shift on the imported welcome document, measured against pen's export
|
|
257
|
+
// of the same file. Both are here because both are real intents.
|
|
258
|
+
"white-space": E(["normal", "nowrap", "pre-wrap", "pre-line"], "normal | nowrap | pre-wrap | pre-line"),
|
|
259
|
+
"object-fit": E(["cover", "contain", "fill"], "cover | contain | fill"),
|
|
260
|
+
"background-image": { kind: "image", hint: 'url("photo.png") | linear-gradient(180deg, #00000000, #000000b3) | var(--img-hero)' },
|
|
261
|
+
// `100% 100%` is how the format spells a stretch: pen writes an image fill's
|
|
262
|
+
// `mode` as `fill`, and `cover`, `contain` and `fill` are the three modes a
|
|
263
|
+
// design actually carries. Without the pair a stretched fill has no spelling,
|
|
264
|
+
// and a fill that cannot be spelled is a fill that silently gets the wrong one.
|
|
265
|
+
// A pair, not only a keyword: a repeating texture needs a tile that `cover`
|
|
266
|
+
// cannot express — a dot grid is one dot every 26px — and the only spelling
|
|
267
|
+
// before this was a data-URI SVG whose intrinsic size carried the number.
|
|
268
|
+
// `100% 100%` is no longer special-cased: it is a pair of lengths like any
|
|
269
|
+
// other, and it normalizes to itself.
|
|
270
|
+
"background-size": { kind: "size", hint: "cover | contain | auto | 26px 26px" },
|
|
271
|
+
"background-position": K(["center", "top", "bottom", "left", "right"], "center | top | bottom | left | right"),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export function specFor(name) {
|
|
275
|
+
return CSS_ALLOWED[name] || null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// -- aliases and shorthands --------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/** Properties that are not part of the format, and what to do with them. */
|
|
281
|
+
export const CSS_ALIASES = {
|
|
282
|
+
background: "background-color",
|
|
283
|
+
"background-colour": "background-color",
|
|
284
|
+
"bg-color": "background-color",
|
|
285
|
+
// Safari's prefixed spelling is still what half the glass snippets on the web
|
|
286
|
+
// carry. It is the same declaration, so it is rewritten instead of refused.
|
|
287
|
+
"-webkit-backdrop-filter": "backdrop-filter",
|
|
288
|
+
"-webkit-transform": "transform",
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Values that mean the same thing, normalized to one spelling. */
|
|
292
|
+
export const VALUE_ALIASES = {
|
|
293
|
+
"align-items": { "flex-left": "flex-start", start: "flex-start", end: "flex-end", "space-between": "space-between" },
|
|
294
|
+
"justify-content": { start: "flex-start", end: "flex-end", "space-evenly": "space-around" },
|
|
295
|
+
"flex-direction": { horizontal: "row", vertical: "column" },
|
|
296
|
+
"font-weight": { normal: "400", regular: "400", medium: "500", semibold: "600", bold: "700" },
|
|
297
|
+
"text-align": { start: "left", end: "right" },
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Properties written by a model that the format does not use, with the
|
|
302
|
+
* replacement, or `null` to refuse.
|
|
303
|
+
*
|
|
304
|
+
* The reason field is printed in the receipt, so a refusal teaches the model
|
|
305
|
+
* what this format wants instead of only what it rejected.
|
|
306
|
+
*/
|
|
307
|
+
export const REJECTED_PROPERTIES = {
|
|
308
|
+
// `margin` is in the table, and it is not the spacing system: `gap` and
|
|
309
|
+
// `padding` are, and they are what a fragment should use. Margin is accepted
|
|
310
|
+
// because offsetting one element by a hair is a real need — an imported file
|
|
311
|
+
// uses it to nudge a focus ring — and refusing it outright would force a
|
|
312
|
+
// wrapper element for a half-pixel.
|
|
313
|
+
"margin-top": { replacement: "margin with four values", reason: "A one-sided margin is a second spelling of the same intent." },
|
|
314
|
+
"margin-right": { replacement: "gap on the parent, or margin on this element", reason: "Write the shorthand: margin: 0 8px 0 0." },
|
|
315
|
+
"margin-bottom": { replacement: "gap on the parent, or margin on this element", reason: "Write the shorthand: margin: 0 0 8px 0." },
|
|
316
|
+
"margin-left": { replacement: "gap on the parent, or margin on this element", reason: "Write the shorthand: margin: 0 0 0 8px." },
|
|
317
|
+
cursor: { replacement: null, reason: "The format describes appearance, not interaction. A cursor is behavior." },
|
|
318
|
+
|
|
319
|
+
transition: { replacement: null, reason: "No animation. A design file is a still." },
|
|
320
|
+
animation: { replacement: null, reason: "No animation. A design file is a still." },
|
|
321
|
+
"font": { replacement: "font-family, font-size, font-weight and line-height", reason: "Write the longhand properties." },
|
|
322
|
+
"border-top": { replacement: "border-width: 1px 0 0 0", reason: "Use border-width with four values for one edge." },
|
|
323
|
+
"border-bottom": { replacement: "border-width: 0 0 1px 0", reason: "Use border-width with four values for one edge." },
|
|
324
|
+
"border-left": { replacement: "border-width: 0 0 0 1px", reason: "Use border-width with four values for one edge." },
|
|
325
|
+
"border-right": { replacement: "border-width: 0 1px 0 0", reason: "Use border-width with four values for one edge." },
|
|
326
|
+
"grid-template-columns": { replacement: "display:flex with flex:1 on the children", reason: "The format has no grid. Flex covers the same layouts." },
|
|
327
|
+
"grid-template-rows": { replacement: "display:flex with flex:1 on the children", reason: "The format has no grid. Flex covers the same layouts." },
|
|
328
|
+
"grid-gap": { replacement: "gap", reason: "The property is named gap." },
|
|
329
|
+
rotate: { replacement: "transform: rotate(<deg>)", reason: "A rotation is written as transform: rotate(), so there is one spelling, not two." },
|
|
330
|
+
"z-index": { replacement: "document order", reason: "Paint order follows document order. Move the element instead." },
|
|
331
|
+
"text-decoration": { replacement: null, reason: "Not in the format. Use a border-width on the bottom edge for an underline." },
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// -- normalization -----------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
export class TableError extends Error {
|
|
337
|
+
constructor(code, message, extra = {}) {
|
|
338
|
+
super(message);
|
|
339
|
+
this.name = "TableError";
|
|
340
|
+
this.code = code;
|
|
341
|
+
Object.assign(this, extra);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
346
|
+
const COLOR_FUNCTION = /^(rgb|rgba|hsl|hsla)\(([^()]*)\)$/i;
|
|
347
|
+
const VAR_OR_TOKEN = /^(var\([^()]+\)|\$[A-Za-z][A-Za-z0-9_-]*)$/;
|
|
348
|
+
|
|
349
|
+
const CSS_NAMED_COLORS = {
|
|
350
|
+
white: "#ffffff", black: "#000000", red: "#ff0000", green: "#008000", blue: "#0000ff",
|
|
351
|
+
yellow: "#ffff00", orange: "#ffa500", purple: "#800080", pink: "#ffc0cb", gray: "#808080",
|
|
352
|
+
grey: "#808080", silver: "#c0c0c0", navy: "#000080", teal: "#008080", cyan: "#00ffff",
|
|
353
|
+
magenta: "#ff00ff", maroon: "#800000", olive: "#808000", lime: "#00ff00",
|
|
354
|
+
transparent: null,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The canonical spelling of a color. Throws `TableError` with a hint when the
|
|
359
|
+
* value cannot be normalized.
|
|
360
|
+
*/
|
|
361
|
+
export function normalizeColor(value) {
|
|
362
|
+
const raw = String(value ?? "").trim();
|
|
363
|
+
if (raw === "") throw new TableError("style/empty-value", "The value is empty.", { hint: "#rrggbb" });
|
|
364
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
365
|
+
if (raw.toLowerCase() === "transparent") return "transparent";
|
|
366
|
+
if (HEX_COLOR.test(raw)) return expandHex(raw);
|
|
367
|
+
const lower = raw.toLowerCase();
|
|
368
|
+
if (Object.hasOwn(CSS_NAMED_COLORS, lower)) {
|
|
369
|
+
const named = CSS_NAMED_COLORS[lower];
|
|
370
|
+
return named === null ? "transparent" : named;
|
|
371
|
+
}
|
|
372
|
+
const fn = COLOR_FUNCTION.exec(raw);
|
|
373
|
+
if (fn) {
|
|
374
|
+
const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
|
|
375
|
+
if (parts.length < 3) throw new TableError("style/bad-color", `Cannot read the color ${raw}.`, { hint: "rgb(17 24 39)" });
|
|
376
|
+
const [r, g, b] = parts.slice(0, 3).map((p) => {
|
|
377
|
+
const n = p.endsWith("%") ? Math.round((parseFloat(p) / 100) * 255) : parseFloat(p);
|
|
378
|
+
if (!Number.isFinite(n)) throw new TableError("style/bad-color", `Cannot read the color ${raw}.`, { hint: "rgb(17 24 39)" });
|
|
379
|
+
return clampInt(n);
|
|
380
|
+
});
|
|
381
|
+
let alpha = null;
|
|
382
|
+
if (parts.length >= 4) {
|
|
383
|
+
const a = parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
|
|
384
|
+
if (Number.isFinite(a)) alpha = a;
|
|
385
|
+
}
|
|
386
|
+
return alpha === null || alpha >= 1 ? toHex(r, g, b) : toHex(r, g, b, Math.round(alpha * 255));
|
|
387
|
+
}
|
|
388
|
+
throw new TableError("style/bad-color", `Cannot read the color ${raw}.`, {
|
|
389
|
+
hint: "Use six or eight digit hex, such as #ffffff or #00000080.",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function clampInt(n) {
|
|
394
|
+
return Math.max(0, Math.min(255, Math.round(n)));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function toHex(r, g, b, a) {
|
|
398
|
+
const hex = (n) => n.toString(16).padStart(2, "0");
|
|
399
|
+
const base = `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
400
|
+
return a === undefined ? base : `${base}${hex(a)}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `#fff` to `#ffffff`, `#abcd` to `#aabbccdd`, and lower case. */
|
|
404
|
+
export function expandHex(value) {
|
|
405
|
+
const body = value.slice(1).toLowerCase();
|
|
406
|
+
if (body.length === 3) return `#${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}`;
|
|
407
|
+
if (body.length === 4) return `#${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}${body[3]}${body[3]}`;
|
|
408
|
+
return `#${body}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const LENGTH = /^(-?\d+(?:\.\d+)?)(px|%|em|rem|vh|vw|pt)?$/;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The canonical spelling of a length or size.
|
|
415
|
+
* Zero is `0`. Integers lose the `.0`. Values keep three decimals at most.
|
|
416
|
+
*/
|
|
417
|
+
export function normalizeLength(value, options = {}) {
|
|
418
|
+
const raw = String(value ?? "").trim();
|
|
419
|
+
if (raw === "") throw new TableError("style/empty-value", "The value is empty.", { hint: "0 | 12px" });
|
|
420
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
421
|
+
const keyword = raw.toLowerCase();
|
|
422
|
+
if (["auto", "fit-content", "max-content", "min-content", "inherit", "100%"].includes(keyword)) return keyword;
|
|
423
|
+
if (keyword === "0px" || keyword === "0%") return "0";
|
|
424
|
+
const match = LENGTH.exec(keyword);
|
|
425
|
+
if (!match) throw new TableError("style/bad-length", `Cannot read the length ${raw}.`, { hint: "Use px, such as 12px. Use 0 for zero." });
|
|
426
|
+
const number = Number(match[1]);
|
|
427
|
+
const unit = match[2] || (number === 0 ? "" : "px");
|
|
428
|
+
const rounded = round3(number);
|
|
429
|
+
if (rounded === 0 && unit !== "%" && unit !== "em" && unit !== "rem" && unit !== "vh" && unit !== "vw") return "0";
|
|
430
|
+
return `${formatNumber(rounded)}${unit}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function round3(value) {
|
|
434
|
+
return Math.round(value * 1000) / 1000;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function formatNumber(value) {
|
|
438
|
+
if (Number.isInteger(value)) return String(value);
|
|
439
|
+
return String(round3(value)).replace(/0+$/, "").replace(/\.$/, "");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Expand the CSS box shorthand to four values.
|
|
444
|
+
*
|
|
445
|
+
* Three values are the problem case: `8px 24px 18px` means left follows right,
|
|
446
|
+
* which is `8px 24px 18px 24px`. Compressing that back to three would read as
|
|
447
|
+
* something else, so the canonical form expands first and compresses after.
|
|
448
|
+
* Without that step, normalizing a file twice can change the design.
|
|
449
|
+
*/
|
|
450
|
+
export function expandBox(values) {
|
|
451
|
+
const list = values.slice(0, 4);
|
|
452
|
+
if (list.length === 0) return [];
|
|
453
|
+
if (list.length === 1) return [list[0], list[0], list[0], list[0]];
|
|
454
|
+
if (list.length === 2) return [list[0], list[1], list[0], list[1]];
|
|
455
|
+
if (list.length === 3) return [list[0], list[1], list[2], list[1]];
|
|
456
|
+
return list;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Four values to the shortest equal spelling, using the 1/2/4 value forms. */
|
|
460
|
+
export function compressBox(values) {
|
|
461
|
+
const [top, right, bottom, left] = expandBox(values);
|
|
462
|
+
if (top === right && right === bottom && bottom === left) return [top];
|
|
463
|
+
if (top === bottom && right === left) return [top, right];
|
|
464
|
+
// The three value form is never emitted: it is the one spelling that cannot
|
|
465
|
+
// round trip, because the author may have meant `right` for `left`.
|
|
466
|
+
return [top, right, bottom, left];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const WHITESPACE_OUTSIDE = /\s+/;
|
|
470
|
+
|
|
471
|
+
/** Split a value on whitespace that is not inside parentheses or quotes. */
|
|
472
|
+
export function splitValueParts(value) {
|
|
473
|
+
const raw = String(value ?? "").trim();
|
|
474
|
+
const parts = [];
|
|
475
|
+
let depth = 0;
|
|
476
|
+
let quote = null;
|
|
477
|
+
let current = "";
|
|
478
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
479
|
+
const ch = raw[i];
|
|
480
|
+
if (quote) {
|
|
481
|
+
current += ch;
|
|
482
|
+
if (ch === "\\") { current += raw[i + 1] ?? ""; i += 1; continue; }
|
|
483
|
+
if (ch === quote) quote = null;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
|
|
487
|
+
if (ch === "(") { depth += 1; current += ch; continue; }
|
|
488
|
+
if (ch === ")") { depth = Math.max(0, depth - 1); current += ch; continue; }
|
|
489
|
+
if (depth === 0 && WHITESPACE_OUTSIDE.test(ch)) {
|
|
490
|
+
if (current) { parts.push(current); current = ""; }
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
current += ch;
|
|
494
|
+
}
|
|
495
|
+
if (current) parts.push(current);
|
|
496
|
+
return parts;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Normalize one declaration value for one property.
|
|
501
|
+
* @returns {{ value: string, changed: boolean }}
|
|
502
|
+
*/
|
|
503
|
+
export function normalizeValue(property, value) {
|
|
504
|
+
const raw = String(value ?? "").trim();
|
|
505
|
+
const spec = CSS_ALLOWED[property];
|
|
506
|
+
|
|
507
|
+
const aliasMap = VALUE_ALIASES[property];
|
|
508
|
+
if (aliasMap && aliasMap[raw.toLowerCase()]) {
|
|
509
|
+
return { value: aliasMap[raw.toLowerCase()], changed: aliasMap[raw.toLowerCase()] !== raw };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (property === "font-family") {
|
|
513
|
+
return { value: normalizeFontFamily(raw), changed: normalizeFontFamily(raw) !== raw };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (property === "box-shadow") {
|
|
517
|
+
const normalized = normalizeShadowValue(raw);
|
|
518
|
+
return { value: normalized, changed: normalized !== raw };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (property === "line-height") {
|
|
522
|
+
if (VAR_OR_TOKEN.test(raw)) return { value: raw, changed: false };
|
|
523
|
+
if (raw.endsWith("px") || raw.endsWith("em") || raw.endsWith("rem")) {
|
|
524
|
+
const n = normalizeLength(raw);
|
|
525
|
+
return { value: n, changed: n !== raw };
|
|
526
|
+
}
|
|
527
|
+
const n = Number(raw);
|
|
528
|
+
if (!Number.isFinite(n)) {
|
|
529
|
+
throw new TableError("style/bad-number", `Cannot read the line height ${raw}.`, { hint: "1.5 | 20px" });
|
|
530
|
+
}
|
|
531
|
+
return { value: formatNumber(round3(n)), changed: formatNumber(round3(n)) !== raw };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (property === "font-weight") {
|
|
535
|
+
const lower = raw.toLowerCase();
|
|
536
|
+
const mapped = VALUE_ALIASES["font-weight"][lower];
|
|
537
|
+
if (mapped) return { value: mapped, changed: mapped !== raw };
|
|
538
|
+
const n = Number(raw);
|
|
539
|
+
if (!Number.isFinite(n)) throw new TableError("style/bad-number", `Cannot read the font weight ${raw}.`, { hint: "400 | 500 | 600 | 700" });
|
|
540
|
+
return { value: String(Math.round(n)), changed: String(Math.round(n)) !== raw };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (property === "opacity") {
|
|
544
|
+
const n = Number(raw);
|
|
545
|
+
if (!Number.isFinite(n)) throw new TableError("style/bad-number", `Cannot read the opacity ${raw}.`, { hint: "0 to 1" });
|
|
546
|
+
const clamped = Math.max(0, Math.min(1, round3(n)));
|
|
547
|
+
return { value: formatNumber(clamped), changed: formatNumber(clamped) !== raw };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (property === "outline") {
|
|
551
|
+
const raw2 = String(value ?? "").trim();
|
|
552
|
+
if (VAR_OR_TOKEN.test(raw2) || raw2 === "none") return { value: raw2, changed: false };
|
|
553
|
+
const parts = splitValueParts(raw2);
|
|
554
|
+
let width = null; let lineStyle = null; let color = null;
|
|
555
|
+
for (const part of parts) {
|
|
556
|
+
const lower = part.toLowerCase();
|
|
557
|
+
if (["solid", "dashed", "dotted", "none"].includes(lower)) { lineStyle = lower; continue; }
|
|
558
|
+
try { color = normalizeColor(part); continue; } catch { /* not a colour */ }
|
|
559
|
+
width = normalizeLength(part);
|
|
560
|
+
}
|
|
561
|
+
const out = `${width ?? "1px"} ${lineStyle ?? "solid"} ${color ?? "#000000"}`;
|
|
562
|
+
return { value: out, changed: out !== raw2 };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (property === "flex") {
|
|
566
|
+
if (VAR_OR_TOKEN.test(raw)) return { value: raw, changed: false };
|
|
567
|
+
const parts = splitValueParts(raw);
|
|
568
|
+
const normalized = parts.map((p) => (p === "0" ? "0" : p)).join(" ");
|
|
569
|
+
if (parts.length === 1) {
|
|
570
|
+
const n = Number(parts[0]);
|
|
571
|
+
if (Number.isFinite(n)) return { value: `${formatNumber(n)} 1 0%`, changed: true };
|
|
572
|
+
}
|
|
573
|
+
if (parts.length === 2) {
|
|
574
|
+
const out = `${parts[0]} ${parts[1]} 0%`;
|
|
575
|
+
return { value: out, changed: out !== raw };
|
|
576
|
+
}
|
|
577
|
+
return { value: normalized, changed: normalized !== raw };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (property === "background-image") {
|
|
581
|
+
return { value: normalizeUrlQuoting(raw), changed: normalizeUrlQuoting(raw) !== raw };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!spec) throw new TableError("style/unknown-property", `The property ${property} is not in the format.`, {});
|
|
585
|
+
|
|
586
|
+
switch (spec.kind) {
|
|
587
|
+
case "color": {
|
|
588
|
+
const out = normalizeColor(raw);
|
|
589
|
+
return { value: out, changed: out !== raw };
|
|
590
|
+
}
|
|
591
|
+
case "length": {
|
|
592
|
+
const parts = splitValueParts(raw);
|
|
593
|
+
// A three value box is the one spelling that cannot round trip, so it is
|
|
594
|
+
// expanded to four. `margin` is a box like `padding`.
|
|
595
|
+
const isBox = property === "padding" || property === "margin" || property.startsWith("border-width");
|
|
596
|
+
const outParts = parts.map((p) => normalizeLength(p));
|
|
597
|
+
const out = isBox ? compressBox(outParts).join(" ") : outParts.join(" ");
|
|
598
|
+
return { value: out, changed: out !== raw };
|
|
599
|
+
}
|
|
600
|
+
case "number": {
|
|
601
|
+
const n = Number(raw);
|
|
602
|
+
if (!Number.isFinite(n)) throw new TableError("style/bad-number", `Cannot read the number ${raw}.`, { hint: spec.hint });
|
|
603
|
+
const bounded = Math.max(spec.min ?? -Infinity, Math.min(spec.max ?? Infinity, round3(n)));
|
|
604
|
+
return { value: formatNumber(bounded), changed: formatNumber(bounded) !== raw };
|
|
605
|
+
}
|
|
606
|
+
case "enum":
|
|
607
|
+
case "keyword-set": {
|
|
608
|
+
const lower = raw.toLowerCase();
|
|
609
|
+
const values = spec.values.map((v) => v.toLowerCase());
|
|
610
|
+
const index = values.indexOf(lower);
|
|
611
|
+
if (index === -1) {
|
|
612
|
+
throw new TableError("style/bad-enum", `${raw} is not a value of ${property}.`, {
|
|
613
|
+
hint: `Accepted: ${spec.hint}`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const out = spec.values[index];
|
|
617
|
+
return { value: out, changed: out !== raw };
|
|
618
|
+
}
|
|
619
|
+
case "border": {
|
|
620
|
+
const value = normalizeBorderValue(raw);
|
|
621
|
+
return { value, changed: value !== raw };
|
|
622
|
+
}
|
|
623
|
+
case "shadow": {
|
|
624
|
+
const value = normalizeShadowValue(raw);
|
|
625
|
+
return { value, changed: value !== raw };
|
|
626
|
+
}
|
|
627
|
+
case "size": {
|
|
628
|
+
const size = normalizeBackgroundSize(raw);
|
|
629
|
+
return { value: size, changed: size !== raw };
|
|
630
|
+
}
|
|
631
|
+
case "backdrop": {
|
|
632
|
+
const filter = normalizeBackdropFilter(raw);
|
|
633
|
+
return { value: filter, changed: filter !== raw };
|
|
634
|
+
}
|
|
635
|
+
case "transform": {
|
|
636
|
+
const transform = normalizeTransform(raw);
|
|
637
|
+
return { value: transform, changed: transform !== raw };
|
|
638
|
+
}
|
|
639
|
+
case "image":
|
|
640
|
+
case "text":
|
|
641
|
+
default:
|
|
642
|
+
return { value: raw, changed: false };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Quote every `url(...)` argument, so a value never needs a space to be read
|
|
648
|
+
* back and a `;` inside the URL cannot split the declaration.
|
|
649
|
+
*
|
|
650
|
+
* A hand-written file often carries `url("a.png")`. That is HTML
|
|
651
|
+
* attribute escaping, not part of the URL, so it is read as the quote it
|
|
652
|
+
* represents and written back as a plain `"` inside the argument. Attribute
|
|
653
|
+
* escaping then happens exactly once, at serialization. Without this step a
|
|
654
|
+
* second normalization pass would turn `"` into `&quot;`, which is how
|
|
655
|
+
* a value grows an extra layer of escaping on every save.
|
|
656
|
+
*/
|
|
657
|
+
export function normalizeUrlQuoting(value) {
|
|
658
|
+
const text = String(value);
|
|
659
|
+
return text.replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]+))\s*\)/g, (_m, d, s, bare) => {
|
|
660
|
+
const target = unwrapQuoteEntities(d !== undefined ? d : s !== undefined ? s : bare);
|
|
661
|
+
return `url("${target}")`;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Strip a pair of quote character references around a value.
|
|
667
|
+
* `"a.png"` becomes `a.png`. Only a matching outer pair is removed, so
|
|
668
|
+
* a URL that legitimately contains `"` in the middle keeps it.
|
|
669
|
+
*/
|
|
670
|
+
export function unwrapQuoteEntities(value) {
|
|
671
|
+
const text = String(value);
|
|
672
|
+
const head = /^&(?:quot|#0*34|#x0*22);/i.exec(text);
|
|
673
|
+
if (!head) return text;
|
|
674
|
+
const tail = /&(?:quot|#0*34|#x0*22);$/i.exec(text);
|
|
675
|
+
if (!tail || tail.index < head[0].length) return text;
|
|
676
|
+
return text.slice(head[0].length, tail.index);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** `"` and `"` read as the `"` they represent. */
|
|
680
|
+
export function decodeQuoteEntities(value) {
|
|
681
|
+
return String(value).replace(/&(?:quot|#0*34|#x0*22);/gi, '"').replace(/&(?:apos|#0*39|#x0*27);/gi, "'");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Font families are written as given. No fallback tail is appended. */
|
|
685
|
+
/**
|
|
686
|
+
* The CSS-wide family keywords. A `font-family` list that ends in one of these
|
|
687
|
+
* ends in something every browser has; a list that ends in `"Noto Serif SC"`
|
|
688
|
+
* ends in something the browser may not have, and the fallback is then the
|
|
689
|
+
* document default, which is a serif face on most engines and a different layout
|
|
690
|
+
* on all of them.
|
|
691
|
+
*/
|
|
692
|
+
export const GENERIC_FAMILIES = new Set([
|
|
693
|
+
"serif", "sans-serif", "monospace", "cursive", "fantasy",
|
|
694
|
+
"system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded",
|
|
695
|
+
"math", "emoji", "fangsong",
|
|
696
|
+
]);
|
|
697
|
+
|
|
698
|
+
/** The pair pen's exporter appends, and the pair this format writes. */
|
|
699
|
+
export const FONT_FALLBACK = "system-ui, sans-serif";
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* A family list with a generic family at the end, if it had none.
|
|
703
|
+
*
|
|
704
|
+
* `"Noto Serif SC"` alone means "Noto Serif SC, or whatever the document
|
|
705
|
+
* default is". The document default is not the same on every machine, so a
|
|
706
|
+
* design that loads its webfont looks right and a design that cannot — offline,
|
|
707
|
+
* behind a proxy, on a build machine — does not look like the same design.
|
|
708
|
+
*/
|
|
709
|
+
export function ensureGenericFamily(value) {
|
|
710
|
+
const raw = String(value ?? "").trim();
|
|
711
|
+
if (!raw || VAR_OR_TOKEN.test(raw)) return raw;
|
|
712
|
+
const parts = splitTopLevel(raw, ",").map((part) => part.trim()).filter(Boolean);
|
|
713
|
+
if (parts.length === 0) return raw;
|
|
714
|
+
const last = parts[parts.length - 1].replace(/^["']|["']$/g, "").trim().toLowerCase();
|
|
715
|
+
if (GENERIC_FAMILIES.has(last)) return raw;
|
|
716
|
+
return `${parts.join(", ")}, ${FONT_FALLBACK}`;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function normalizeFontFamily(value) {
|
|
720
|
+
const raw = String(value ?? "").trim();
|
|
721
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
722
|
+
const parts = splitTopLevel(raw, ",").map((p) => p.trim()).filter(Boolean);
|
|
723
|
+
return parts.map((part) => {
|
|
724
|
+
// A family written as `"Foo;Bar"` is the quoted family "Foo;Bar".
|
|
725
|
+
// Reading the escaping first keeps the `;` where it belongs: inside a name,
|
|
726
|
+
// not between two declarations.
|
|
727
|
+
const unescaped = unwrapQuoteEntities(part);
|
|
728
|
+
if (unescaped !== part) return `"${unescaped}"`;
|
|
729
|
+
if (part.startsWith('"') || part.startsWith("'")) return `"${part.slice(1, -1)}"`;
|
|
730
|
+
const lower = part.toLowerCase();
|
|
731
|
+
if (GENERIC_FAMILIES.has(lower)) return lower;
|
|
732
|
+
if (lower.startsWith("var(")) return part;
|
|
733
|
+
if (part.includes(";")) return `"${part}"`;
|
|
734
|
+
return /^[A-Za-z][A-Za-z0-9]*$/.test(part) ? part : `"${part}"`;
|
|
735
|
+
}).join(", ");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** `1px solid #eee` in one order: width, style, color. */
|
|
739
|
+
export function normalizeBorderValue(value) {
|
|
740
|
+
const raw = String(value ?? "").trim();
|
|
741
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
742
|
+
const parts = splitValueParts(raw);
|
|
743
|
+
let width = null;
|
|
744
|
+
let style = null;
|
|
745
|
+
let color = null;
|
|
746
|
+
for (const part of parts) {
|
|
747
|
+
const lower = part.toLowerCase();
|
|
748
|
+
if (["solid", "dashed", "dotted", "none"].includes(lower)) { style = lower; continue; }
|
|
749
|
+
try {
|
|
750
|
+
color = normalizeColor(part);
|
|
751
|
+
continue;
|
|
752
|
+
} catch {
|
|
753
|
+
// not a color
|
|
754
|
+
}
|
|
755
|
+
width = normalizeLength(part);
|
|
756
|
+
}
|
|
757
|
+
if (width === null && style === null && color === null) {
|
|
758
|
+
throw new TableError("style/bad-border", `Cannot read the border ${raw}.`, { hint: "1px solid #e5e7eb" });
|
|
759
|
+
}
|
|
760
|
+
return [width ?? "1px", style ?? "solid", color ?? "#000000"].join(" ");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Shadows: one or more, each `x y blur [spread] color`, comma separated. */
|
|
764
|
+
export function normalizeShadowValue(value) {
|
|
765
|
+
const raw = String(value ?? "").trim();
|
|
766
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
767
|
+
if (raw.toLowerCase() === "none") return "none";
|
|
768
|
+
const layers = splitTopLevel(raw, ",").map((layer) => {
|
|
769
|
+
const parts = splitValueParts(layer.trim());
|
|
770
|
+
const numbers = [];
|
|
771
|
+
let color = "#000000";
|
|
772
|
+
for (const part of parts) {
|
|
773
|
+
const lower = part.toLowerCase();
|
|
774
|
+
if (lower === "inset") {
|
|
775
|
+
throw new TableError("style/bad-shadow", "Inner shadows are not in the format.", { hint: "Use an outer shadow." });
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
color = normalizeColor(part);
|
|
779
|
+
continue;
|
|
780
|
+
} catch {
|
|
781
|
+
// not a color
|
|
782
|
+
}
|
|
783
|
+
numbers.push(normalizeLength(part));
|
|
784
|
+
}
|
|
785
|
+
if (numbers.length < 2) {
|
|
786
|
+
throw new TableError("style/bad-shadow", `Cannot read the shadow ${raw}.`, { hint: "0 6px 16px #0000001f" });
|
|
787
|
+
}
|
|
788
|
+
while (numbers.length < 3) numbers.push("0");
|
|
789
|
+
return [...numbers.slice(0, 4), color].join(" ");
|
|
790
|
+
});
|
|
791
|
+
return layers.join(", ");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The keywords a background size may be, and the hint shared by its refusals. */
|
|
795
|
+
const BACKGROUND_SIZE_KEYWORDS = new Set(["cover", "contain", "auto"]);
|
|
796
|
+
const BACKGROUND_SIZE_HINT = "cover | contain | auto | 26px 26px";
|
|
797
|
+
|
|
798
|
+
/** The lengths a size may pair; a box keyword such as `fit-content` is not one. */
|
|
799
|
+
const NOT_A_SIZE_KEYWORD = new Set(["fit-content", "max-content", "min-content", "inherit"]);
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* A background size: one keyword, or one or two lengths.
|
|
803
|
+
*
|
|
804
|
+
* `cover`, `contain` and `auto` each describe the whole picture, so they never
|
|
805
|
+
* take a partner — `cover 26px` is two intents, and guessing one of them is how
|
|
806
|
+
* a validator starts disagreeing with the file it just wrote.
|
|
807
|
+
*
|
|
808
|
+
* The pair exists for a tile. A dot grid is one dot every 26px, and the only
|
|
809
|
+
* spelling the format had for that was a data-URI SVG whose intrinsic size
|
|
810
|
+
* carried the number; `background-size: 26px 26px` over a radial gradient says
|
|
811
|
+
* the same thing in a value a person can read and change.
|
|
812
|
+
*
|
|
813
|
+
* A trailing `auto` is dropped: `26px auto` and `26px` paint the same picture,
|
|
814
|
+
* and the format keeps one spelling per intent.
|
|
815
|
+
*/
|
|
816
|
+
export function normalizeBackgroundSize(value) {
|
|
817
|
+
const raw = String(value ?? "").trim();
|
|
818
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
819
|
+
const parts = splitValueParts(raw);
|
|
820
|
+
if (parts.length === 0 || parts.length > 2) {
|
|
821
|
+
throw new TableError("style/bad-size", `background-size:${raw} — a size is one or two values.`, {
|
|
822
|
+
hint: BACKGROUND_SIZE_HINT,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const lower = parts.map((part) => part.toLowerCase());
|
|
826
|
+
if (lower.length === 1) {
|
|
827
|
+
if (BACKGROUND_SIZE_KEYWORDS.has(lower[0])) return lower[0];
|
|
828
|
+
const single = normalizeLength(parts[0]);
|
|
829
|
+
if (NOT_A_SIZE_KEYWORD.has(single) || single.startsWith("-")) {
|
|
830
|
+
throw new TableError("style/bad-size", `${single} is not a background size.`, { hint: BACKGROUND_SIZE_HINT });
|
|
831
|
+
}
|
|
832
|
+
return single;
|
|
833
|
+
}
|
|
834
|
+
if (lower.some((part) => part === "cover" || part === "contain")) {
|
|
835
|
+
throw new TableError(
|
|
836
|
+
"style/bad-size",
|
|
837
|
+
`background-size:${raw} — cover and contain describe the whole picture, not half of a pair.`,
|
|
838
|
+
{ hint: BACKGROUND_SIZE_HINT },
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
const pair = lower.map((part, index) => {
|
|
842
|
+
if (part === "auto") return "auto";
|
|
843
|
+
const length = normalizeLength(parts[index]);
|
|
844
|
+
if (NOT_A_SIZE_KEYWORD.has(length) || length.startsWith("-")) {
|
|
845
|
+
throw new TableError("style/bad-size", `${length} is not a background size.`, { hint: BACKGROUND_SIZE_HINT });
|
|
846
|
+
}
|
|
847
|
+
return length;
|
|
848
|
+
});
|
|
849
|
+
if (pair[0] === "auto" && pair[1] === "auto") return "auto";
|
|
850
|
+
if (pair[1] === "auto") return pair[0];
|
|
851
|
+
return pair.join(" ");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** The hint every backdrop-filter refusal carries. */
|
|
855
|
+
const BACKDROP_FILTER_HINT = "blur(20px) | blur(20px) saturate(1.4)";
|
|
856
|
+
|
|
857
|
+
/** A length a blur may take: a number with a unit, and no percentage. */
|
|
858
|
+
const BLUR_LENGTH = /^-?\d+(?:\.\d+)?(?:px|em|rem|vh|vw|pt)?$/;
|
|
859
|
+
|
|
860
|
+
function backdropFilterError(raw, detail) {
|
|
861
|
+
return new TableError("style/bad-backdrop", `backdrop-filter:${raw} — ${detail}`, { hint: BACKDROP_FILTER_HINT });
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* A backdrop filter: `blur()` and, optionally, `saturate()`.
|
|
866
|
+
*
|
|
867
|
+
* Two functions, not a filter chain. The glass this format needs is a blur of
|
|
868
|
+
* what is behind the surface plus a saturation lift; `drop-shadow`,
|
|
869
|
+
* `brightness` and the rest are a pipeline, and a pipeline with no vocabulary is
|
|
870
|
+
* a string the validator cannot read, the canvas cannot edit, and the contrast
|
|
871
|
+
* checker cannot say anything true about.
|
|
872
|
+
*
|
|
873
|
+
* The written order is not kept: blur-then-saturate is the canonical spelling,
|
|
874
|
+
* because the two commute for this purpose and one spelling per intent is the
|
|
875
|
+
* rule the rest of the table follows.
|
|
876
|
+
*/
|
|
877
|
+
export function normalizeBackdropFilter(value) {
|
|
878
|
+
const raw = String(value ?? "").trim();
|
|
879
|
+
if (VAR_OR_TOKEN.test(raw)) return raw;
|
|
880
|
+
if (raw.toLowerCase() === "none") return "none";
|
|
881
|
+
const found = new Map();
|
|
882
|
+
const pattern = /([a-zA-Z-]+)\s*\(\s*([^()]*?)\s*\)/g;
|
|
883
|
+
let last = 0;
|
|
884
|
+
let match;
|
|
885
|
+
while ((match = pattern.exec(raw)) !== null) {
|
|
886
|
+
if (raw.slice(last, match.index).trim() !== "") {
|
|
887
|
+
throw backdropFilterError(raw, "only filters, separated by spaces, may appear.");
|
|
888
|
+
}
|
|
889
|
+
const name = match[1].toLowerCase();
|
|
890
|
+
const argument = match[2].trim();
|
|
891
|
+
if (name !== "blur" && name !== "saturate") {
|
|
892
|
+
throw backdropFilterError(raw, `${name}() is not a filter the format reads.`);
|
|
893
|
+
}
|
|
894
|
+
if (found.has(name)) throw backdropFilterError(raw, `${name}() appears twice.`);
|
|
895
|
+
if (name === "blur") {
|
|
896
|
+
const length = normalizeLength(argument);
|
|
897
|
+
if (!BLUR_LENGTH.test(length)) throw backdropFilterError(raw, `${argument} is not a length a blur can take.`);
|
|
898
|
+
if (length.startsWith("-")) throw backdropFilterError(raw, "a blur radius cannot be negative.");
|
|
899
|
+
found.set(name, `blur(${length})`);
|
|
900
|
+
} else {
|
|
901
|
+
const number = Number(argument);
|
|
902
|
+
if (!Number.isFinite(number) || number < 0) {
|
|
903
|
+
throw backdropFilterError(raw, "saturate() takes a number, such as 1.4.");
|
|
904
|
+
}
|
|
905
|
+
found.set(name, `saturate(${formatNumber(round3(number))})`);
|
|
906
|
+
}
|
|
907
|
+
last = pattern.lastIndex;
|
|
908
|
+
}
|
|
909
|
+
if (last === 0 || raw.slice(last).trim() !== "") {
|
|
910
|
+
throw backdropFilterError(raw, "only blur() and saturate() may appear.");
|
|
911
|
+
}
|
|
912
|
+
return ["blur", "saturate"].filter((name) => found.has(name)).map((name) => found.get(name)).join(" ");
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** `rotate(<deg>)`, the one function `transform` reads. */
|
|
916
|
+
const ROTATE_DEGREES = /^rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)$/i;
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* A transform: `rotate(<deg>)` or `none`.
|
|
920
|
+
*
|
|
921
|
+
* Rotation is the only transform a static design can describe without changing
|
|
922
|
+
* what the design is: a box turns about its own centre, its layout and its
|
|
923
|
+
* children's layout do not move, and a renderer draws it with one CSS
|
|
924
|
+
* declaration. `translate()` duplicates `left`/`top` and `scale()` duplicates
|
|
925
|
+
* `width`/`height`; a second spelling of a size is the drift this table exists
|
|
926
|
+
* to prevent, so both are refused by name.
|
|
927
|
+
*
|
|
928
|
+
* The angle is a literal: `var()` is refused because the canvas, the geometry
|
|
929
|
+
* checks and the export all have to read the number to draw a box around it.
|
|
930
|
+
* The unit is `deg` — `turn` and `rad` would be two more spellings of the same
|
|
931
|
+
* angle.
|
|
932
|
+
*/
|
|
933
|
+
export function normalizeTransform(value) {
|
|
934
|
+
const raw = String(value ?? "").trim();
|
|
935
|
+
if (raw.toLowerCase() === "none") return "none";
|
|
936
|
+
const match = ROTATE_DEGREES.exec(raw);
|
|
937
|
+
if (!match) {
|
|
938
|
+
throw new TableError("style/bad-transform", `transform:${raw} — only rotate(<deg>) is in the format.`, {
|
|
939
|
+
hint: "rotate(-1.5deg) | none",
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const degrees = round3(Number(match[1]));
|
|
943
|
+
return `rotate(${formatNumber(degrees === 0 ? 0 : degrees)}deg)`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Split on a separator that is not nested inside brackets or quotes. */
|
|
947
|
+
export function splitTopLevel(value, separator) {
|
|
948
|
+
const raw = String(value ?? "");
|
|
949
|
+
const out = [];
|
|
950
|
+
let depth = 0;
|
|
951
|
+
let quote = null;
|
|
952
|
+
let current = "";
|
|
953
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
954
|
+
const ch = raw[i];
|
|
955
|
+
if (quote) {
|
|
956
|
+
current += ch;
|
|
957
|
+
if (ch === quote) quote = null;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
|
|
961
|
+
if (ch === "(") depth += 1;
|
|
962
|
+
if (ch === ")") depth = Math.max(0, depth - 1);
|
|
963
|
+
if (ch === separator && depth === 0) {
|
|
964
|
+
out.push(current);
|
|
965
|
+
current = "";
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
current += ch;
|
|
969
|
+
}
|
|
970
|
+
out.push(current);
|
|
971
|
+
return out;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// -- declaration list --------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Normalize a whole `style` attribute value.
|
|
978
|
+
*
|
|
979
|
+
* @param {string} styleText the raw attribute value
|
|
980
|
+
* @param {(name:string)=>string} parse injected `parseDeclarations` to avoid a cycle
|
|
981
|
+
* @returns {{ text: string, normalized: Array, dropped: Array }}
|
|
982
|
+
*/
|
|
983
|
+
export function normalizeStyleText(styleText, parseDeclarations) {
|
|
984
|
+
const declarations = parseDeclarations(String(styleText ?? ""));
|
|
985
|
+
const normalized = [];
|
|
986
|
+
const dropped = [];
|
|
987
|
+
const kept = [];
|
|
988
|
+
|
|
989
|
+
for (const decl of declarations) {
|
|
990
|
+
let property = decl.property;
|
|
991
|
+
let value = decl.value;
|
|
992
|
+
if (property === "") {
|
|
993
|
+
dropped.push({
|
|
994
|
+
prop: decl.rawProperty, value: decl.rawValue,
|
|
995
|
+
reason: "There is no property name before the colon.",
|
|
996
|
+
});
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (property.startsWith("--")) {
|
|
1000
|
+
// A custom property is always allowed. It is how a design carries its own
|
|
1001
|
+
// local values, and refusing it would push authors toward literals.
|
|
1002
|
+
kept.push({ property, value });
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
const alias = CSS_ALIASES[property];
|
|
1006
|
+
if (alias) {
|
|
1007
|
+
normalized.push({ prop: alias, from: `${property}: ${value}`, to: `${alias}: ${value}`, reason: "aliased" });
|
|
1008
|
+
property = alias;
|
|
1009
|
+
}
|
|
1010
|
+
const rejected = REJECTED_PROPERTIES[property];
|
|
1011
|
+
if (rejected) {
|
|
1012
|
+
dropped.push({ prop: property, value, reason: rejected.reason, replacement: rejected.replacement });
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (!CSS_ALLOWED[property]) {
|
|
1016
|
+
dropped.push({
|
|
1017
|
+
prop: property, value,
|
|
1018
|
+
reason: "Not in the format table.",
|
|
1019
|
+
replacement: null,
|
|
1020
|
+
});
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
let next;
|
|
1024
|
+
try {
|
|
1025
|
+
next = normalizeValue(property, value);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
dropped.push({ prop: property, value, reason: error.message, replacement: error.hint ?? null });
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (next.changed) {
|
|
1031
|
+
normalized.push({ prop: property, from: `${property}: ${value}`, to: `${property}: ${next.value}` });
|
|
1032
|
+
}
|
|
1033
|
+
kept.push({ property, value: next.value });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const ordered = kept
|
|
1037
|
+
.map((entry, i) => ({ ...entry, i }))
|
|
1038
|
+
.sort((a, b) => compareProperties(a.property, b.property) || a.i - b.i)
|
|
1039
|
+
.map(({ property, value }) => `${property}:${value}`);
|
|
1040
|
+
|
|
1041
|
+
return { text: ordered.join(";"), normalized, dropped };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Every property whose value grammar a model most often gets wrong. */
|
|
1045
|
+
export const COMMON_MISTAKES = Object.entries(REJECTED_PROPERTIES)
|
|
1046
|
+
.map(([property, info]) => ({ property, replacement: info.replacement, reason: info.reason }))
|
|
1047
|
+
.sort((a, b) => a.property.localeCompare(b.property));
|