Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/8010_fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Handle regex enum values when generating schema types [[#8010](https://github.com/plotly/plotly.js/pull/8010)]
11 changes: 6 additions & 5 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export as namespace Plotly;
// ---------------------------------------------------------------------------

export type {
AxisName,
CartesianSubplotId,
Color,
ColorScale,
Datum,
Expand All @@ -36,7 +38,9 @@ export type {
MarkerSymbol,
TypedArray,
XAnchor,
YAnchor
XAxisName,
YAnchor,
YAxisName
} from '../src/types/lib/common';

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

export type {
AxisName,
ButtonClickEvent,
Icon,
ModeBarButton,
ModeBarButtonAny,
ModeBarDefaultButtons,
Template,
XAxisName,
YAxisName
Template
} from '../src/types/core/layout';

// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions src/types/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,11 @@ src/types/
│ ├── data.internal.d.ts # CalcData, FullData
│ ├── events.d.ts # PlotMouseEvent, PlotlyHTMLElement, etc.
│ ├── graph-div.internal.d.ts # GraphDiv, GraphContext
│ ├── layout.d.ts # AxisName, ModeBar behavioral types, Template
│ ├── layout.d.ts # ModeBar behavioral types, Template
│ └── layout.internal.d.ts # FullLayout, LayoutSize, SubplotInfo
├── lib/ # primitives + the schema-extraction machinery
│ ├── common.d.ts # Color, Datum, TypedArray, MarkerSymbol, ...
│ ├── common.d.ts # Color, Datum, TypedArray, AxisName, ...
│ └── attributes.d.ts # AttributeMap, AttrInfo (compile-time validation)
└── generated/ # machine-generated types
Expand Down
18 changes: 2 additions & 16 deletions src/types/core/layout.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,10 @@ import type { Data, Layout, TraceType } from '../generated/schema';
import type { PlotlyHTMLElement } from './events';

// ---------------------------------------------------------------------------
// Axis name types (template literal utilities — not in schema)
// Axis name types
// ---------------------------------------------------------------------------

/**
* Numeric axis suffix plus the optional ` domain` qualifier. The suffix is
* empty for the first axis (`x` / `y`) and `2` through `99` otherwise.
*/
type xYAxisNames = `${
| ''
| `${2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`
| `${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`}${'' | ' domain'}`;

/** Any valid x-axis reference: `'x'`, `'x2'`, …, `'x99'`, optionally `' domain'`. */
export type XAxisName = `x${xYAxisNames}`;
/** Any valid y-axis reference: `'y'`, `'y2'`, …, `'y99'`, optionally `' domain'`. */
export type YAxisName = `y${xYAxisNames}`;
/** Any valid axis reference (x or y, numbered or not, domain-qualified or not). */
export type AxisName = XAxisName | YAxisName;
export type { AxisName, CartesianSubplotId, XAxisName, YAxisName } from '../lib/common';

// ---------------------------------------------------------------------------
// ModeBar / Icon (behavioral types — not in schema)
Expand Down
45 changes: 27 additions & 18 deletions src/types/generated/schema.d.ts

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions src/types/lib/common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,52 @@ export type XAnchor = 'auto' | 'left' | 'center' | 'right';
/** Vertical anchor position for components (legend, annotation, etc.). */
export type YAnchor = 'auto' | 'top' | 'middle' | 'bottom';

// ---------------------------------------------------------------------------
// Axis and subplot identifiers
//
// The schema states these as regexes, which no TypeScript type can express
// exactly. The template literal types below enumerate the accepted strings
// instead, so they are bounded where the schema is not. See the digit-tier
// note on `AxisNumber`.
//
// tasks/generate_schema_types.mjs maps each schema regex onto one of these
// types through its REGEX_VALUE_TYPES table.
// ---------------------------------------------------------------------------

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type NonZeroDigit = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;

/**
* Numeric axis suffix. Empty for the first axis (`x` / `y`), then `2` through
* `999`. There is no `1` suffix — the first axis is unnumbered.
*
* The schema regex accepts any number of digits. This type stops at three
* because a template literal union has to be finite, so charts with 1000 or
* more axes of one letter cannot be typed.
*/
type AxisNumber = '' | `${Exclude<NonZeroDigit, 1>}` | `${NonZeroDigit}${Digit}` | `${NonZeroDigit}${Digit}${Digit}`;

/**
* Two-digit variant of `AxisNumber`, capped at `99`.
*
* Used only where the suffix appears twice in one identifier. Three digits
* squared exceeds the TypeScript union size limit.
*/
type ShortAxisNumber = '' | `${Exclude<NonZeroDigit, 1>}` | `${NonZeroDigit}${Digit}`;

/** Any valid x-axis reference: `'x'`, `'x2'`, …, optionally `' domain'`. */
export type XAxisName = `x${AxisNumber}${'' | ' domain'}`;
/** Any valid y-axis reference: `'y'`, `'y2'`, …, optionally `' domain'`. */
export type YAxisName = `y${AxisNumber}${'' | ' domain'}`;
/** Any valid axis reference (x or y, numbered or not, domain-qualified or not). */
export type AxisName = XAxisName | YAxisName;

/**
* A cartesian subplot id pairing an x and a y axis, such as `'xy'` or
* `'x3y2'`. Unlike `XAxisName`, no `' domain'` qualifier is permitted.
*/
export type CartesianSubplotId = `x${ShortAxisNumber}y${ShortAxisNumber}`;

// ---------------------------------------------------------------------------
// Error bars
// ---------------------------------------------------------------------------
Expand Down
109 changes: 96 additions & 13 deletions tasks/generate_schema_types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,53 @@ const LAYOUT_ARRAY_NAMES = new Map([

const serializeValue = tsLiteral;

/**
* Schema `values` entries wrapped in `/.../` are regexes, not literals. The
* enumerated `validateFunction` in src/lib/coerce.js unwraps and tests them.
* Each one maps to a template literal type in src/types/lib/common.d.ts that
* accepts the same strings.
*
* A regex stands in for a value set that the static schema cannot list,
* because the real set depends on how many axes a figure declares.
*/
const REGEX_VALUE_TYPES = new Map([
['/^x([2-9]|[1-9][0-9]+)?( domain)?$/', 'XAxisName'],
['/^y([2-9]|[1-9][0-9]+)?( domain)?$/', 'YAxisName'],
['/^x([2-9]|[1-9][0-9]+)?y([2-9]|[1-9][0-9]+)?$/', 'CartesianSubplotId']
]);

/** Test whether a schema value uses the `/.../` regex convention. */
function isRegexValue(v) {
return typeof v === 'string' && v.length > 1 && v.startsWith('/') && v.endsWith('/');
}

/**
* Serialize one `values` entry from a string or enumerated attribute.
*
* Plain values become string literals. Regex values become the named type
* that accepts the same strings.
*
* @param {string|number|boolean} v - The schema value
* @param {string} attrPath - Dotted attribute path, used in the error message
* @returns {string}
* @throws If `v` is a regex with no entry in `REGEX_VALUE_TYPES`. Emitting the
* regex source as a string literal would produce a type that only accepts
* the pattern text, so failing here is deliberate.
*/
function enumValueToTS(v, attrPath) {
if (!isRegexValue(v)) return serializeValue(v);

const typeName = REGEX_VALUE_TYPES.get(v);
if (!typeName) {
throw new Error(
`Unmapped regex value '${v}' at '${attrPath}'. Add an entry to REGEX_VALUE_TYPES ` +
`pointing at a template literal type in src/types/lib/common.d.ts that accepts ` +
`the same strings.`
);
}
return typeName;
}

/**
* Try to match a values array to a known common type.
* Returns the type name string or null.
Expand All @@ -211,32 +258,42 @@ function matchCommonType(values) {
* When all elements share the same valType we can emit a precise tuple
* or array type instead of `unknown[]`.
*/
function infoArrayToTS(attr) {
function infoArrayToTS(attr, attrPath) {
if (!attr.items) return 'any[]';

// items can be an array of item descriptors or a single descriptor
// (single descriptor = freeLength homogeneous array)
if (!Array.isArray(attr.items)) {
const elemType = simpleValType(attr.items.valType);
return `${elemType}[]`;
return `${asArrayElement(simpleValType(attr.items, attrPath))}[]`;
}

const elemTypes = attr.items.map((item) => simpleValType(item.valType));
const elemTypes = attr.items.map((item) => simpleValType(item, attrPath));

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

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

/** Map a valType string to a simple TS type (no arrayOk handling). */
function simpleValType(valType) {
switch (valType) {
/** Parenthesize a union so that appending `[]` binds to the whole union. */
function asArrayElement(type) {
return type.includes('|') ? `(${type})` : type;
}

/**
* Map an info_array item descriptor to a simple TS type (no arrayOk handling).
*
* @param {object} item - The item descriptor, carrying at least `valType`
* @param {string} attrPath - Dotted path of the owning attribute
* @returns {string}
*/
function simpleValType(item, attrPath) {
switch (item.valType) {
case 'number':
case 'integer':
return 'number';
Expand All @@ -247,6 +304,9 @@ function simpleValType(valType) {
return 'boolean';
case 'color':
return 'Color';
case 'enumerated':
if (!Array.isArray(item.values)) return 'any';
return item.values.map((v) => enumValueToTS(v, attrPath)).join(' | ');
default:
return 'any';
}
Expand Down Expand Up @@ -344,7 +404,7 @@ function valTypeToTS(attr, attrPath) {
base = common;
break;
}
base = attr.values.map(serializeValue).join(' | ');
base = attr.values.map((v) => enumValueToTS(v, attrPath)).join(' | ');
} else {
base = 'string';
}
Expand Down Expand Up @@ -381,7 +441,7 @@ function valTypeToTS(attr, attrPath) {
base = common;
break;
}
base = attr.values.map(serializeValue).join(' | ');
base = attr.values.map((v) => enumValueToTS(v, attrPath)).join(' | ');
} else {
base = 'any';
}
Expand All @@ -408,7 +468,7 @@ function valTypeToTS(attr, attrPath) {
}

case 'info_array':
return infoArrayToTS(attr);
return infoArrayToTS(attr, attrPath);

case 'any':
return 'any';
Expand Down Expand Up @@ -1057,7 +1117,16 @@ export function generateSchemaTypes(schema, outputPath) {
command: 'npm run schema'
}),
'',
"import type { Color, ColorScale, Datum, MarkerSymbol, TypedArray } from '../lib/common';",
'import type {',
' CartesianSubplotId,',
' Color,',
' ColorScale,',
' Datum,',
' MarkerSymbol,',
' TypedArray,',
' XAxisName,',
' YAxisName',
"} from '../lib/common';",
''
];

Expand Down Expand Up @@ -1242,7 +1311,21 @@ export function generateSchemaTypes(schema, outputPath) {
}
}

fs.writeFileSync(outputPath, toFileText(chunks));
const output = toFileText(chunks);

// Backstop for the `/.../` regex convention. `enumValueToTS` already fails
// on an unmapped regex and names the attribute, but it only sees `values`
// entries. This catches a regex that reaches the output by another route.
const leakedRegexes = output.match(/'\/\^[^']*'/g);
if (leakedRegexes) {
throw new Error(
`Generated types contain regex string literals: ${[...new Set(leakedRegexes)].join(', ')}. ` +
`A schema regex reached the output as a literal type, which only accepts the pattern ` +
`text itself. Map it in REGEX_VALUE_TYPES.`
);
}

fs.writeFileSync(outputPath, output);

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