Skip to content

Commit a58425a

Browse files
authored
Merge pull request #8010 from plotly/cam/8003/handle-regex-in-types
fix: Handle regex values when generating types
2 parents a1f2c54 + 8a3d1ea commit a58425a

7 files changed

Lines changed: 180 additions & 54 deletions

File tree

‎draftlogs/8010_fix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Handle regex enum values when generating schema types [[#8010](https://github.com/plotly/plotly.js/pull/8010)]

‎lib/index.d.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export as namespace Plotly;
2727
// ---------------------------------------------------------------------------
2828

2929
export type {
30+
AxisName,
31+
CartesianSubplotId,
3032
Color,
3133
ColorScale,
3234
Datum,
@@ -36,7 +38,9 @@ export type {
3638
MarkerSymbol,
3739
TypedArray,
3840
XAnchor,
39-
YAnchor
41+
XAxisName,
42+
YAnchor,
43+
YAxisName
4044
} from '../src/types/lib/common';
4145

4246
// ---------------------------------------------------------------------------
@@ -50,15 +54,12 @@ export type * from '../src/types/generated/schema';
5054
// ---------------------------------------------------------------------------
5155

5256
export type {
53-
AxisName,
5457
ButtonClickEvent,
5558
Icon,
5659
ModeBarButton,
5760
ModeBarButtonAny,
5861
ModeBarDefaultButtons,
59-
Template,
60-
XAxisName,
61-
YAxisName
62+
Template
6263
} from '../src/types/core/layout';
6364

6465
// ---------------------------------------------------------------------------

‎src/types/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ src/types/
114114
│ ├── data.internal.d.ts # CalcData, FullData
115115
│ ├── events.d.ts # PlotMouseEvent, PlotlyHTMLElement, etc.
116116
│ ├── graph-div.internal.d.ts # GraphDiv, GraphContext
117-
│ ├── layout.d.ts # AxisName, ModeBar behavioral types, Template
117+
│ ├── layout.d.ts # ModeBar behavioral types, Template
118118
│ └── layout.internal.d.ts # FullLayout, LayoutSize, SubplotInfo
119119
│
120120
├── lib/ # primitives + the schema-extraction machinery
121-
│ ├── common.d.ts # Color, Datum, TypedArray, MarkerSymbol, ...
121+
│ ├── common.d.ts # Color, Datum, TypedArray, AxisName, ...
122122
│ └── attributes.d.ts # AttributeMap, AttrInfo (compile-time validation)
123123
│
124124
└── generated/ # machine-generated types

‎src/types/core/layout.d.ts‎

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,10 @@ import type { Data, Layout, TraceType } from '../generated/schema';
1212
import type { PlotlyHTMLElement } from './events';
1313

1414
// ---------------------------------------------------------------------------
15-
// Axis name types (template literal utilities — not in schema)
15+
// Axis name types
1616
// ---------------------------------------------------------------------------
1717

18-
/**
19-
* Numeric axis suffix plus the optional ` domain` qualifier. The suffix is
20-
* empty for the first axis (`x` / `y`) and `2` through `99` otherwise.
21-
*/
22-
type xYAxisNames = `${
23-
| ''
24-
| `${2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`
25-
| `${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`}${'' | ' domain'}`;
26-
27-
/** Any valid x-axis reference: `'x'`, `'x2'`, …, `'x99'`, optionally `' domain'`. */
28-
export type XAxisName = `x${xYAxisNames}`;
29-
/** Any valid y-axis reference: `'y'`, `'y2'`, …, `'y99'`, optionally `' domain'`. */
30-
export type YAxisName = `y${xYAxisNames}`;
31-
/** Any valid axis reference (x or y, numbered or not, domain-qualified or not). */
32-
export type AxisName = XAxisName | YAxisName;
18+
export type { AxisName, CartesianSubplotId, XAxisName, YAxisName } from '../lib/common';
3319

3420
// ---------------------------------------------------------------------------
3521
// ModeBar / Icon (behavioral types — not in schema)

‎src/types/generated/schema.d.ts‎

Lines changed: 27 additions & 18 deletions
Large diffs are not rendered by default.

‎src/types/lib/common.d.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,52 @@ export type XAnchor = 'auto' | 'left' | 'center' | 'right';
6464
/** Vertical anchor position for components (legend, annotation, etc.). */
6565
export type YAnchor = 'auto' | 'top' | 'middle' | 'bottom';
6666

67+
// ---------------------------------------------------------------------------
68+
// Axis and subplot identifiers
69+
//
70+
// The schema states these as regexes, which no TypeScript type can express
71+
// exactly. The template literal types below enumerate the accepted strings
72+
// instead, so they are bounded where the schema is not. See the digit-tier
73+
// note on `AxisNumber`.
74+
//
75+
// tasks/generate_schema_types.mjs maps each schema regex onto one of these
76+
// types through its REGEX_VALUE_TYPES table.
77+
// ---------------------------------------------------------------------------
78+
79+
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
80+
type NonZeroDigit = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
81+
82+
/**
83+
* Numeric axis suffix. Empty for the first axis (`x` / `y`), then `2` through
84+
* `999`. There is no `1` suffix — the first axis is unnumbered.
85+
*
86+
* The schema regex accepts any number of digits. This type stops at three
87+
* because a template literal union has to be finite, so charts with 1000 or
88+
* more axes of one letter cannot be typed.
89+
*/
90+
type AxisNumber = '' | `${Exclude<NonZeroDigit, 1>}` | `${NonZeroDigit}${Digit}` | `${NonZeroDigit}${Digit}${Digit}`;
91+
92+
/**
93+
* Two-digit variant of `AxisNumber`, capped at `99`.
94+
*
95+
* Used only where the suffix appears twice in one identifier. Three digits
96+
* squared exceeds the TypeScript union size limit.
97+
*/
98+
type ShortAxisNumber = '' | `${Exclude<NonZeroDigit, 1>}` | `${NonZeroDigit}${Digit}`;
99+
100+
/** Any valid x-axis reference: `'x'`, `'x2'`, …, optionally `' domain'`. */
101+
export type XAxisName = `x${AxisNumber}${'' | ' domain'}`;
102+
/** Any valid y-axis reference: `'y'`, `'y2'`, …, optionally `' domain'`. */
103+
export type YAxisName = `y${AxisNumber}${'' | ' domain'}`;
104+
/** Any valid axis reference (x or y, numbered or not, domain-qualified or not). */
105+
export type AxisName = XAxisName | YAxisName;
106+
107+
/**
108+
* A cartesian subplot id pairing an x and a y axis, such as `'xy'` or
109+
* `'x3y2'`. Unlike `XAxisName`, no `' domain'` qualifier is permitted.
110+
*/
111+
export type CartesianSubplotId = `x${ShortAxisNumber}y${ShortAxisNumber}`;
112+
67113
// ---------------------------------------------------------------------------
68114
// Error bars
69115
// ---------------------------------------------------------------------------

‎tasks/generate_schema_types.mjs‎

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,53 @@ const LAYOUT_ARRAY_NAMES = new Map([
190190

191191
const serializeValue = tsLiteral;
192192

193+
/**
194+
* Schema `values` entries wrapped in `/.../` are regexes, not literals. The
195+
* enumerated `validateFunction` in src/lib/coerce.js unwraps and tests them.
196+
* Each one maps to a template literal type in src/types/lib/common.d.ts that
197+
* accepts the same strings.
198+
*
199+
* A regex stands in for a value set that the static schema cannot list,
200+
* because the real set depends on how many axes a figure declares.
201+
*/
202+
const REGEX_VALUE_TYPES = new Map([
203+
['/^x([2-9]|[1-9][0-9]+)?( domain)?$/', 'XAxisName'],
204+
['/^y([2-9]|[1-9][0-9]+)?( domain)?$/', 'YAxisName'],
205+
['/^x([2-9]|[1-9][0-9]+)?y([2-9]|[1-9][0-9]+)?$/', 'CartesianSubplotId']
206+
]);
207+
208+
/** Test whether a schema value uses the `/.../` regex convention. */
209+
function isRegexValue(v) {
210+
return typeof v === 'string' && v.length > 1 && v.startsWith('/') && v.endsWith('/');
211+
}
212+
213+
/**
214+
* Serialize one `values` entry from a string or enumerated attribute.
215+
*
216+
* Plain values become string literals. Regex values become the named type
217+
* that accepts the same strings.
218+
*
219+
* @param {string|number|boolean} v - The schema value
220+
* @param {string} attrPath - Dotted attribute path, used in the error message
221+
* @returns {string}
222+
* @throws If `v` is a regex with no entry in `REGEX_VALUE_TYPES`. Emitting the
223+
* regex source as a string literal would produce a type that only accepts
224+
* the pattern text, so failing here is deliberate.
225+
*/
226+
function enumValueToTS(v, attrPath) {
227+
if (!isRegexValue(v)) return serializeValue(v);
228+
229+
const typeName = REGEX_VALUE_TYPES.get(v);
230+
if (!typeName) {
231+
throw new Error(
232+
`Unmapped regex value '${v}' at '${attrPath}'. Add an entry to REGEX_VALUE_TYPES ` +
233+
`pointing at a template literal type in src/types/lib/common.d.ts that accepts ` +
234+
`the same strings.`
235+
);
236+
}
237+
return typeName;
238+
}
239+
193240
/**
194241
* Try to match a values array to a known common type.
195242
* Returns the type name string or null.
@@ -211,32 +258,42 @@ function matchCommonType(values) {
211258
* When all elements share the same valType we can emit a precise tuple
212259
* or array type instead of `unknown[]`.
213260
*/
214-
function infoArrayToTS(attr) {
261+
function infoArrayToTS(attr, attrPath) {
215262
if (!attr.items) return 'any[]';
216263

217264
// items can be an array of item descriptors or a single descriptor
218265
// (single descriptor = freeLength homogeneous array)
219266
if (!Array.isArray(attr.items)) {
220-
const elemType = simpleValType(attr.items.valType);
221-
return `${elemType}[]`;
267+
return `${asArrayElement(simpleValType(attr.items, attrPath))}[]`;
222268
}
223269

224-
const elemTypes = attr.items.map((item) => simpleValType(item.valType));
270+
const elemTypes = attr.items.map((item) => simpleValType(item, attrPath));
225271

226272
if (attr.freeLength) {
227273
// Variable-length — use array of the union of element types
228274
const unique = [...new Set(elemTypes)];
229275
const union = unique.length === 1 ? unique[0] : unique.join(' | ');
230-
return `${union}[]`;
276+
return `${asArrayElement(union)}[]`;
231277
}
232278

233279
// Fixed-length — emit tuple
234280
return `[${elemTypes.join(', ')}]`;
235281
}
236282

237-
/** Map a valType string to a simple TS type (no arrayOk handling). */
238-
function simpleValType(valType) {
239-
switch (valType) {
283+
/** Parenthesize a union so that appending `[]` binds to the whole union. */
284+
function asArrayElement(type) {
285+
return type.includes('|') ? `(${type})` : type;
286+
}
287+
288+
/**
289+
* Map an info_array item descriptor to a simple TS type (no arrayOk handling).
290+
*
291+
* @param {object} item - The item descriptor, carrying at least `valType`
292+
* @param {string} attrPath - Dotted path of the owning attribute
293+
* @returns {string}
294+
*/
295+
function simpleValType(item, attrPath) {
296+
switch (item.valType) {
240297
case 'number':
241298
case 'integer':
242299
return 'number';
@@ -247,6 +304,9 @@ function simpleValType(valType) {
247304
return 'boolean';
248305
case 'color':
249306
return 'Color';
307+
case 'enumerated':
308+
if (!Array.isArray(item.values)) return 'any';
309+
return item.values.map((v) => enumValueToTS(v, attrPath)).join(' | ');
250310
default:
251311
return 'any';
252312
}
@@ -344,7 +404,7 @@ function valTypeToTS(attr, attrPath) {
344404
base = common;
345405
break;
346406
}
347-
base = attr.values.map(serializeValue).join(' | ');
407+
base = attr.values.map((v) => enumValueToTS(v, attrPath)).join(' | ');
348408
} else {
349409
base = 'string';
350410
}
@@ -381,7 +441,7 @@ function valTypeToTS(attr, attrPath) {
381441
base = common;
382442
break;
383443
}
384-
base = attr.values.map(serializeValue).join(' | ');
444+
base = attr.values.map((v) => enumValueToTS(v, attrPath)).join(' | ');
385445
} else {
386446
base = 'any';
387447
}
@@ -408,7 +468,7 @@ function valTypeToTS(attr, attrPath) {
408468
}
409469

410470
case 'info_array':
411-
return infoArrayToTS(attr);
471+
return infoArrayToTS(attr, attrPath);
412472

413473
case 'any':
414474
return 'any';
@@ -1057,7 +1117,16 @@ export function generateSchemaTypes(schema, outputPath) {
10571117
command: 'npm run schema'
10581118
}),
10591119
'',
1060-
"import type { Color, ColorScale, Datum, MarkerSymbol, TypedArray } from '../lib/common';",
1120+
'import type {',
1121+
' CartesianSubplotId,',
1122+
' Color,',
1123+
' ColorScale,',
1124+
' Datum,',
1125+
' MarkerSymbol,',
1126+
' TypedArray,',
1127+
' XAxisName,',
1128+
' YAxisName',
1129+
"} from '../lib/common';",
10611130
''
10621131
];
10631132

@@ -1242,7 +1311,21 @@ export function generateSchemaTypes(schema, outputPath) {
12421311
}
12431312
}
12441313

1245-
fs.writeFileSync(outputPath, toFileText(chunks));
1314+
const output = toFileText(chunks);
1315+
1316+
// Backstop for the `/.../` regex convention. `enumValueToTS` already fails
1317+
// on an unmapped regex and names the attribute, but it only sees `values`
1318+
// entries. This catches a regex that reaches the output by another route.
1319+
const leakedRegexes = output.match(/'\/\^[^']*'/g);
1320+
if (leakedRegexes) {
1321+
throw new Error(
1322+
`Generated types contain regex string literals: ${[...new Set(leakedRegexes)].join(', ')}. ` +
1323+
`A schema regex reached the output as a literal type, which only accepts the pattern ` +
1324+
`text itself. Map it in REGEX_VALUE_TYPES.`
1325+
);
1326+
}
1327+
1328+
fs.writeFileSync(outputPath, output);
12461329

12471330
const sharedCount = sharedList.length;
12481331
const layoutCount = subplotGroups.size + arrayItems.size + 1; // +1 for Layout itself

0 commit comments

Comments
 (0)