@multiplatform.one/config 7.10.0 → 7.15.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/oxlint.mjs CHANGED
@@ -9,7 +9,10 @@
9
9
  * mpo-conventions/no-hex-literals — ban #rgb/#rrggbb(/aa) and rgb()/hsl()
10
10
  * string literals. Palette modules (themes/base, themes/accent, and
11
11
  * files whose name contains "palette") are exempt: a 12-step ramp is
12
- * the deliverable.
12
+ * the deliverable. So is a file with a `// hex-escape: <reason>`
13
+ * comment, a surface no theme token reaches; lint.mjs fails the comment
14
+ * unless the spec's `## Colour-literal escapes` table has that file and
15
+ * reason (MPO-326).
13
16
  * mpo-conventions/no-raw-typography — AN-8: app text never rides raw
14
17
  * Tamagui*-prefixed typography escape hatches (TamaguiText, TamaguiHeading,
15
18
  * TamaguiH1…, TamaguiParagraph, TamaguiSizableText, TamaguiAnchor) outside
@@ -23,9 +26,15 @@
23
26
  * on those same imports. Silent behind sizeRecipeEscape() and inside a
24
27
  * file listed as DRAWING. An undeclared `// mpo-drawing` pragma is a
25
28
  * lint.mjs failure, not a skip here.
29
+ * mpo-conventions/no-child-margin — `00` L9a: the parent owns the gap. Ban a
30
+ * non-zero margin* prop on any element nested inside another element's
31
+ * JSX. The outermost element of a tree is left alone (a file cannot see
32
+ * whether its root is a screen's), and element names the spec's
33
+ * `## Slot margins` SLOT-MARGIN row lists are the declared slots (MPO-23).
26
34
  */
27
35
 
28
36
  import { isDrawingFile, readDrawingRegistry } from "./drawing-registry.mjs";
37
+ import { readSlotMarginRegistry } from "./spec-registry.mjs";
29
38
 
30
39
  /** Hex + functional CSS colour literals. AST-visited only (not a text scan). */
31
40
  export const COLOR_LITERAL =
@@ -35,6 +44,41 @@ export const COLOR_LITERAL =
35
44
  export const PALETTE_FILE =
36
45
  /(?:^|\/)themes\/(base|accent)\.(tsx?|jsx?)$|(?:^|\/)[^/]*palette[^/]*\.(tsx?|jsx?)$/i;
37
46
 
47
+ /**
48
+ * A `hex-escape: <reason>` line or block comment. A JSDoc `*` line or a
49
+ * backticked mention is prose about the marker, not the marker.
50
+ */
51
+ const HEX_ESCAPE_COMMENT = /(?<!`)(?:\/\/|\/\*)\s*hex-escape:\s*(.*?)\s*(?:\*\/.*)?$/;
52
+
53
+ /**
54
+ * Every `hex-escape:` comment in a source file, with the reason it writes.
55
+ * @param {string} src
56
+ * @returns {{ line: number, reason: string }[]}
57
+ */
58
+ export function findHexEscapes(src) {
59
+ if (!src.includes("hex-escape:")) return [];
60
+ /** @type {{ line: number, reason: string }[]} */
61
+ const sites = [];
62
+ src.split("\n").forEach((text, index) => {
63
+ if (text.trim().startsWith("*")) return;
64
+ const match = text.match(HEX_ESCAPE_COMMENT);
65
+ if (match) sites.push({ line: index + 1, reason: match[1].trim() });
66
+ });
67
+ return sites;
68
+ }
69
+
70
+ /**
71
+ * @param {unknown} context
72
+ * @returns {string}
73
+ */
74
+ function sourceText(context) {
75
+ const ctx =
76
+ /** @type {{ sourceCode?: { text?: string }, getSourceCode?: () => { text?: string } }} */ (
77
+ context
78
+ );
79
+ return ctx?.sourceCode?.text ?? ctx?.getSourceCode?.()?.text ?? "";
80
+ }
81
+
38
82
  const RAW_TYPOGRAPHY_HATCH = /^Tamagui(?:Text|SizableText|Paragraph|Heading|H[1-6]|Anchor)$/;
39
83
  const HOUSE_PACKAGE = /^@multiplatform\.one\//;
40
84
 
@@ -105,7 +149,40 @@ export const GEOMETRY_PROPS = Object.freeze([
105
149
  "fontSize",
106
150
  ]);
107
151
 
152
+ /**
153
+ * Every spelling of an outer margin: the long props @tamagui/helpers registers
154
+ * and the Tamagui shorthands for them. `00` L9a overrules the LAYOUT_PROPS
155
+ * allowance for sibling margins above: the place among siblings is the
156
+ * parent's `gap`.
157
+ */
158
+ export const MARGIN_PROPS = Object.freeze([
159
+ "margin",
160
+ "marginTop",
161
+ "marginRight",
162
+ "marginBottom",
163
+ "marginLeft",
164
+ "marginHorizontal",
165
+ "marginVertical",
166
+ "marginStart",
167
+ "marginEnd",
168
+ "marginBlock",
169
+ "marginBlockStart",
170
+ "marginBlockEnd",
171
+ "marginInline",
172
+ "marginInlineStart",
173
+ "marginInlineEnd",
174
+ "m",
175
+ "mt",
176
+ "mr",
177
+ "mb",
178
+ "ml",
179
+ "mx",
180
+ "my",
181
+ ]);
182
+
108
183
  const CHROME_PROP_SET = new Set(CHROME_PROPS);
184
+ const MARGIN_PROP_SET = new Set(MARGIN_PROPS);
185
+ const ZERO_MARGIN = /^(?:-?0(?:\.0+)?(?:px)?|\$0)$/;
109
186
  const GEOMETRY_PROP_SET = new Set(GEOMETRY_PROPS);
110
187
 
111
188
  const RULE_OPTIONS_SCHEMA = [
@@ -119,6 +196,48 @@ const RULE_OPTIONS_SCHEMA = [
119
196
  },
120
197
  ];
121
198
 
199
+ const SLOT_MARGIN_OPTIONS_SCHEMA = [
200
+ {
201
+ type: "object",
202
+ additionalProperties: false,
203
+ properties: {
204
+ slotMargins: { type: "array", items: { type: "string" } },
205
+ },
206
+ },
207
+ ];
208
+
209
+ /**
210
+ * @param {unknown} context
211
+ * @returns {Set<string>}
212
+ */
213
+ function resolveSlotMargins(context) {
214
+ const opt = context?.options?.[0] ?? {};
215
+ if (Array.isArray(opt.slotMargins)) return new Set(opt.slotMargins);
216
+ try {
217
+ return new Set(readSlotMarginRegistry().members);
218
+ } catch {
219
+ return new Set();
220
+ }
221
+ }
222
+
223
+ /**
224
+ * `0` is `00` L8 E1, legal anywhere: a zero-reset is the absence of an outer
225
+ * margin, not one (Tamagui's own headings ship `margin: 0`).
226
+ * @param {unknown} value
227
+ */
228
+ function isZeroMargin(value) {
229
+ const node = unwrapJsxValue(value);
230
+ if (!node || typeof node !== "object") return false;
231
+ const expr =
232
+ /** @type {{ type?: string, value?: unknown, operator?: string, argument?: unknown }} */ (node);
233
+ if (expr.type === "Literal") {
234
+ if (typeof expr.value === "number") return expr.value === 0;
235
+ if (typeof expr.value === "string") return ZERO_MARGIN.test(expr.value.trim());
236
+ }
237
+ if (expr.type === "UnaryExpression" && expr.operator === "-") return isZeroMargin(expr.argument);
238
+ return false;
239
+ }
240
+
122
241
  /**
123
242
  * @param {unknown} context
124
243
  * @returns {{ drawings: string[], chromeAllow: Set<string> }}
@@ -330,6 +449,9 @@ export const plugin = {
330
449
  if (isPaletteModule(filename)) {
331
450
  return {};
332
451
  }
452
+ if (findHexEscapes(sourceText(context)).some((site) => site.reason)) {
453
+ return {};
454
+ }
333
455
 
334
456
  function reportIfColor(node, value) {
335
457
  const matched = matchColorLiteral(value);
@@ -445,6 +567,51 @@ export const plugin = {
445
567
  return visitors;
446
568
  },
447
569
  },
570
+ "no-child-margin": {
571
+ meta: {
572
+ type: "problem",
573
+ docs: {
574
+ description:
575
+ "00 L9a: the parent owns the gap. Ban a non-zero margin* prop on any element nested inside another element's JSX, unless the spec declares the element a slot.",
576
+ },
577
+ schema: SLOT_MARGIN_OPTIONS_SCHEMA,
578
+ messages: {
579
+ childMargin:
580
+ "{{prop}} on {{name}} is an outer margin on a child (00 L9a). Set `gap` on the parent, express the slot as flex, or declare {{name}} in the spec's ## Slot margins table.",
581
+ },
582
+ },
583
+ create(context) {
584
+ const slotMargins = resolveSlotMargins(context);
585
+ let depth = 0;
586
+ const enter = () => {
587
+ depth += 1;
588
+ };
589
+ const exit = () => {
590
+ depth -= 1;
591
+ };
592
+ return {
593
+ JSXElement: enter,
594
+ "JSXElement:exit": exit,
595
+ JSXFragment: enter,
596
+ "JSXFragment:exit": exit,
597
+ JSXOpeningElement(node) {
598
+ if (depth < 2) return;
599
+ const name = jsxName(node.name);
600
+ if (elementAllowKeys(name).some((key) => slotMargins.has(key))) return;
601
+ for (const attr of node.attributes ?? []) {
602
+ const prop = jsxAttrName(attr);
603
+ if (!MARGIN_PROP_SET.has(prop)) continue;
604
+ if (isZeroMargin(attr.value)) continue;
605
+ context.report({
606
+ node: attr,
607
+ messageId: "childMargin",
608
+ data: { prop, name: name || "element" },
609
+ });
610
+ }
611
+ },
612
+ };
613
+ },
614
+ },
448
615
  },
449
616
  };
450
617
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/config",
3
- "version": "7.10.0",
3
+ "version": "7.15.0",
4
4
  "description": "Shared build and test configuration presets for multiplatform.one",
5
5
  "keywords": [
6
6
  "config",
@@ -15,14 +15,14 @@
15
15
  ],
16
16
  "homepage": "https://multiplatform.one",
17
17
  "bugs": {
18
- "url": "https://gitlab.com/bitspur/multiplatform.one/multiplatform.one/issues",
18
+ "url": "https://projects.corp.bitspur.com/tracker/MPO",
19
19
  "email": "support@risserlabs.com"
20
20
  },
21
21
  "license": "Apache-2.0",
22
22
  "author": "BitSpur <support@risserlabs.com> (https://risserlabs.com)",
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
25
+ "url": "git+https://git.corp.bitspur.com/multiplatform.one/mpo.git",
26
26
  "directory": "public/config"
27
27
  },
28
28
  "source": "src/index.ts",
@@ -34,9 +34,11 @@
34
34
  "README.md",
35
35
  "oxlint.mjs",
36
36
  "oxlint.d.ts",
37
+ "oxlint.json",
37
38
  "lint.mjs",
38
39
  "lint.d.ts",
39
40
  "drawing-registry.mjs",
41
+ "spec-registry.mjs",
40
42
  "!tsconfig/dist",
41
43
  "!tsconfig/types",
42
44
  "types",
@@ -84,7 +86,9 @@
84
86
  "./tsconfig/app.json": "./tsconfig/app.json",
85
87
  "./react-native-config": "./src/react-native.config.cjs",
86
88
  "./oxlint": "./oxlint.mjs",
89
+ "./oxlint.json": "./oxlint.json",
87
90
  "./drawing-registry": "./drawing-registry.mjs",
91
+ "./spec-registry": "./spec-registry.mjs",
88
92
  "./lint": {
89
93
  "types": "./lint.d.ts",
90
94
  "default": "./lint.mjs",
@@ -95,14 +99,14 @@
95
99
  "access": "public"
96
100
  },
97
101
  "dependencies": {
102
+ "@multiplatform.one/utils": "7.15.0",
98
103
  "@tamagui/vite-plugin": "2.7.6",
99
104
  "@vitejs/plugin-react": "^6.0.1",
100
105
  "dotenv": "^17.4.2",
101
106
  "vite": "^8.0.10",
102
107
  "vite-plugin-external": "^6.2.2",
103
108
  "vite-plugin-i18next-loader": "^3.1.3",
104
- "vitest": "^4.1.5",
105
- "@multiplatform.one/utils": "7.10.0"
109
+ "vitest": "^4.1.5"
106
110
  },
107
111
  "devDependencies": {
108
112
  "tsdown": "^0.21.10",
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Slot-margin, size-recipe-escape and colour-literal-escape registries — parsed from the living
3
+ * rulebook, the same way drawing-registry.mjs and
4
+ * scripts/radius-identity-registry.mjs read theirs (MPO-23).
5
+ *
6
+ * `00` L8: an escape is declared by name in a committed registry that is
7
+ * generated from the spec, not from the code, so writing more violations can
8
+ * never widen the allowlist. Both tables live in
9
+ * `docs/theme-propagation-spec.md`:
10
+ *
11
+ * ## Slot margins `| Class | Normative meaning |`, one SLOT-MARGIN
12
+ * row whose **bolded** `Members:` are the element
13
+ * names that may carry a margin (L9a).
14
+ * ## Size-recipe escapes `| File | Reason |`, one row per escape site; the
15
+ * reason is the exact text the code writes (L8 E8).
16
+ * ## Colour-literal escapes `| File | Reason |`, one row per file whose
17
+ * `hex-escape:` comment exempts it from
18
+ * no-hex-literals; same verbatim reason (MPO-326).
19
+ *
20
+ * A consumer tree with no spec, or a spec without the section, yields
21
+ * `present: false` rather than throwing, so shc can pick the rules up before
22
+ * it has copied the tables.
23
+ *
24
+ * Usage:
25
+ * import { readSlotMarginRegistry, readSizeRecipeEscapeRegistry, readHexEscapeRegistry } from "./spec-registry.mjs";
26
+ * node spec-registry.mjs [--json]
27
+ */
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { pathToFileURL } from "node:url";
30
+ import { defaultSpecPath } from "./drawing-registry.mjs";
31
+
32
+ export const SLOT_MARGIN_HEADING = "## Slot margins";
33
+ export const SLOT_MARGIN_CLASS = "SLOT-MARGIN";
34
+ export const SIZE_RECIPE_ESCAPE_HEADING = "## Size-recipe escapes";
35
+ export const HEX_ESCAPE_HEADING = "## Colour-literal escapes";
36
+
37
+ /** `` `a — b` `` → `a — b`. */
38
+ function unwrapCode(cell) {
39
+ const match = cell.match(/^`(.*)`$/);
40
+ return match ? match[1] : cell;
41
+ }
42
+
43
+ /**
44
+ * Body rows of the first markdown table under `heading`, header and separator
45
+ * dropped. `null` when the heading is absent.
46
+ * @param {string} markdown
47
+ * @param {string} heading e.g. "## Slot margins"
48
+ * @returns {string[][] | null}
49
+ */
50
+ export function readSpecTable(markdown, heading) {
51
+ const lines = markdown.split("\n");
52
+ const start = lines.findIndex((line) => line.trim() === heading);
53
+ if (start === -1) return null;
54
+ /** @type {string[][]} */
55
+ const rows = [];
56
+ let seenTable = false;
57
+ let seenHeader = false;
58
+ for (let i = start + 1; i < lines.length; i++) {
59
+ const line = lines[i].trim();
60
+ if (/^#{1,2}\s/.test(line)) break;
61
+ if (!line.startsWith("|")) {
62
+ if (seenTable) break;
63
+ continue;
64
+ }
65
+ seenTable = true;
66
+ const cells = line
67
+ .split("|")
68
+ .slice(1, -1)
69
+ .map((cell) => cell.trim());
70
+ if (/^:?-+:?$/.test(cells[0] ?? "")) continue;
71
+ if (!seenHeader) {
72
+ seenHeader = true;
73
+ continue;
74
+ }
75
+ rows.push(cells);
76
+ }
77
+ return rows;
78
+ }
79
+
80
+ /** `Members: **Card.Footer**, **Card.Header**.` → ["Card.Footer", "Card.Header"]. */
81
+ function parseMembers(text) {
82
+ const match = text.match(/Members:\s*(.*)$/i);
83
+ if (!match) return [];
84
+ return [...match[1].matchAll(/\*\*([^*]+)\*\*/g)].map((m) => m[1].trim()).filter(Boolean);
85
+ }
86
+
87
+ /**
88
+ * @param {string} specPath
89
+ * @param {string} heading
90
+ */
91
+ function readTable(specPath, heading) {
92
+ if (!existsSync(specPath)) return null;
93
+ return readSpecTable(readFileSync(specPath, "utf8"), heading);
94
+ }
95
+
96
+ /**
97
+ * Element names that may carry a margin because the spec declares them an
98
+ * internal slot (`00` L9a: `Card.Footer` `marginTop: auto` is the precedent).
99
+ *
100
+ * @param {string} [specPath]
101
+ * @returns {{ specPath: string, present: boolean, members: string[] }}
102
+ */
103
+ export function readSlotMarginRegistry(specPath = defaultSpecPath()) {
104
+ const rows = readTable(specPath, SLOT_MARGIN_HEADING);
105
+ if (!rows) return { specPath, present: false, members: [] };
106
+ const row = rows.find((cells) => cells[0] === SLOT_MARGIN_CLASS);
107
+ if (!row) {
108
+ throw new Error(`${specPath}: "${SLOT_MARGIN_HEADING}" has no ${SLOT_MARGIN_CLASS} row`);
109
+ }
110
+ return { specPath, present: true, members: parseMembers(row[1] ?? "") };
111
+ }
112
+
113
+ /**
114
+ * `| File | Reason |` rows under `heading`: one `{ file, reason }` per row,
115
+ * placeholder `_(none)_` rows dropped.
116
+ *
117
+ * @param {string} specPath
118
+ * @param {string} heading
119
+ * @returns {{ specPath: string, present: boolean, escapes: { file: string, reason: string }[] }}
120
+ */
121
+ function readFileReasonRegistry(specPath, heading) {
122
+ const rows = readTable(specPath, heading);
123
+ if (!rows) return { specPath, present: false, escapes: [] };
124
+ /** @type {{ file: string, reason: string }[]} */
125
+ const escapes = [];
126
+ for (const cells of rows) {
127
+ const file = unwrapCode(cells[0] ?? "");
128
+ const reason = unwrapCode(cells[1] ?? "");
129
+ if (!file || /^[_*]?\(none/.test(file)) continue;
130
+ if (!reason) {
131
+ throw new Error(`${specPath}: "${heading}" row for ${file} has no reason`);
132
+ }
133
+ escapes.push({ file, reason });
134
+ }
135
+ return { specPath, present: true, escapes };
136
+ }
137
+
138
+ /**
139
+ * Declared size-recipe escape sites: one `{ file, reason }` per row. `file` is
140
+ * a path from the tree root; `reason` is compared verbatim with the reason the
141
+ * code writes, so rewording an escape is a spec edit too.
142
+ *
143
+ * @param {string} [specPath]
144
+ * @returns {{ specPath: string, present: boolean, escapes: { file: string, reason: string }[] }}
145
+ */
146
+ export function readSizeRecipeEscapeRegistry(specPath = defaultSpecPath()) {
147
+ return readFileReasonRegistry(specPath, SIZE_RECIPE_ESCAPE_HEADING);
148
+ }
149
+
150
+ /**
151
+ * Files exempt from `mpo-conventions/no-hex-literals`: one `{ file, reason }`
152
+ * per row, matched verbatim against the file's `hex-escape:` comment. These
153
+ * are surfaces no theme token reaches (GTK widget CSS outside the provider, a
154
+ * content script on third-party pages, the browser badge API, native build
155
+ * config).
156
+ *
157
+ * @param {string} [specPath]
158
+ * @returns {{ specPath: string, present: boolean, escapes: { file: string, reason: string }[] }}
159
+ */
160
+ export function readHexEscapeRegistry(specPath = defaultSpecPath()) {
161
+ return readFileReasonRegistry(specPath, HEX_ESCAPE_HEADING);
162
+ }
163
+
164
+ const invokedDirectly =
165
+ typeof process.argv[1] === "string" && import.meta.url === pathToFileURL(process.argv[1]).href;
166
+
167
+ if (invokedDirectly) {
168
+ const slots = readSlotMarginRegistry();
169
+ const escapes = readSizeRecipeEscapeRegistry();
170
+ const hexEscapes = readHexEscapeRegistry();
171
+ if (process.argv.includes("--json")) {
172
+ console.log(JSON.stringify({ slots, escapes, hexEscapes }, null, 2));
173
+ } else {
174
+ console.log(`spec registries from ${slots.specPath}`);
175
+ console.log(
176
+ ` SLOT-MARGIN (present=${slots.present}): ${slots.members.join(", ") || "(none)"}`,
177
+ );
178
+ console.log(` size-recipe escapes (present=${escapes.present}):`);
179
+ for (const { file, reason } of escapes.escapes) console.log(` ${file} — ${reason}`);
180
+ console.log(` colour-literal escapes (present=${hexEscapes.present}):`);
181
+ for (const { file, reason } of hexEscapes.escapes) console.log(` ${file} — ${reason}`);
182
+ }
183
+ }