Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -111,11 +111,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
19 changes: 2 additions & 17 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 Expand Up @@ -169,4 +155,3 @@ export interface Template {
/** Template layout defaults. */
layout?: Partial<Layout> | undefined;
}

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 @@ -194,6 +194,53 @@ function serializeValue(v) {
return String(v); // numbers, booleans
}

/**
* 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 @@ -215,32 +262,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 @@ -251,6 +308,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 @@ -368,7 +428,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 @@ -405,7 +465,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 @@ -432,7 +492,7 @@ function valTypeToTS(attr, attrPath) {
}

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

case 'any':
return 'any';
Expand Down Expand Up @@ -1080,7 +1140,16 @@ export function generateSchemaTypes(schema, outputPath) {
' * Do not edit by hand — run `npm run schema` to regenerate.',
' */',
'',
"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 @@ -1265,7 +1334,21 @@ export function generateSchemaTypes(schema, outputPath) {
}
}

fs.writeFileSync(outputPath, chunks.join('\n'));
const output = chunks.join('\n');

// 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
Loading