feat: publish Regex Tools 0.1.0
This commit is contained in:
229
src/components/ExplanationTree.tsx
Normal file
229
src/components/ExplanationTree.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useMemo } from "react";
|
||||
import type { NormalizedRegexNode } from "../regex/model/syntax";
|
||||
import { handleTreeNavigation } from "./tree-navigation";
|
||||
|
||||
const MAXIMUM_RENDERED_EXPLANATION_NODES = 2_000;
|
||||
|
||||
export interface ExplanationTreeProps {
|
||||
readonly root?: NormalizedRegexNode;
|
||||
readonly selectedId?: string;
|
||||
readonly flags?: readonly string[];
|
||||
readonly onSelect: (node: NormalizedRegexNode) => void;
|
||||
}
|
||||
|
||||
function countNodes(root: NormalizedRegexNode): number {
|
||||
let count = 0;
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (!node) continue;
|
||||
count += 1;
|
||||
for (const child of node.children) pending.push(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function boundedRoot(
|
||||
root: NormalizedRegexNode,
|
||||
maximum: number,
|
||||
): NormalizedRegexNode {
|
||||
let remaining = maximum;
|
||||
const takeNode = (
|
||||
node: NormalizedRegexNode,
|
||||
): NormalizedRegexNode | undefined => {
|
||||
if (remaining <= 0) return undefined;
|
||||
remaining -= 1;
|
||||
const children: NormalizedRegexNode[] = [];
|
||||
for (const child of node.children) {
|
||||
const visible = takeNode(child);
|
||||
if (!visible) break;
|
||||
children.push(visible);
|
||||
}
|
||||
return { ...node, children };
|
||||
};
|
||||
return takeNode(root) ?? root;
|
||||
}
|
||||
|
||||
function containsNode(root: NormalizedRegexNode, id: string): boolean {
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (!node) continue;
|
||||
if (node.id === id) return true;
|
||||
for (const child of node.children) pending.push(child);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function lengthLabel(node: NormalizedRegexNode): string {
|
||||
const minimum = node.properties.minimumLength;
|
||||
const maximum = node.properties.maximumLength;
|
||||
if (minimum === undefined && maximum === undefined) return "length unknown";
|
||||
return `${minimum ?? "?"}…${maximum === null ? "∞" : (maximum ?? "?")}`;
|
||||
}
|
||||
|
||||
function consumesInputLabel(
|
||||
value: NormalizedRegexNode["properties"]["consumesInput"],
|
||||
): string {
|
||||
return value === true ? "yes" : value === false ? "no" : value;
|
||||
}
|
||||
|
||||
function nullableLabel(
|
||||
value: NormalizedRegexNode["properties"]["nullable"],
|
||||
): string {
|
||||
return value === true ? "yes" : value === false ? "no" : value;
|
||||
}
|
||||
|
||||
function ExplanationNode({
|
||||
node,
|
||||
selectedId,
|
||||
onSelect,
|
||||
depth,
|
||||
focusableId,
|
||||
flags,
|
||||
}: {
|
||||
readonly node: NormalizedRegexNode;
|
||||
readonly selectedId?: string;
|
||||
readonly onSelect: (node: NormalizedRegexNode) => void;
|
||||
readonly depth: number;
|
||||
readonly focusableId: string;
|
||||
readonly flags: readonly string[];
|
||||
}) {
|
||||
const compatibility =
|
||||
node.support.notes.length > 0
|
||||
? node.support.notes.slice(0, 2).join("; ").slice(0, 240)
|
||||
: "no parser warning";
|
||||
return (
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-level={depth}
|
||||
aria-selected={node.id === selectedId}
|
||||
tabIndex={node.id === focusableId ? 0 : -1}
|
||||
className={`tree-row ${node.id === selectedId ? "is-selected" : ""}`}
|
||||
onClick={() => onSelect(node)}
|
||||
onKeyDown={handleTreeNavigation}
|
||||
>
|
||||
<span className="tree-token">{node.raw || "∅"}</span>
|
||||
<span className="tree-explanation">{node.explanation}</span>
|
||||
<span className="tree-meta">
|
||||
{node.capture
|
||||
? `group ${node.capture.number}${node.capture.name ? ` · ${node.capture.name}` : ""}`
|
||||
: node.quantifier
|
||||
? `${node.quantifier.minimum}…${node.quantifier.maximum ?? "∞"} · ${node.quantifier.mode}`
|
||||
: node.properties.zeroWidth
|
||||
? "zero-width"
|
||||
: lengthLabel(node)}
|
||||
</span>
|
||||
<span className="tree-details">
|
||||
<span>
|
||||
range: {node.range.startUtf16}…{node.range.endUtf16} UTF-16
|
||||
</span>
|
||||
<span>
|
||||
consumes: {consumesInputLabel(node.properties.consumesInput)}
|
||||
</span>
|
||||
<span>empty: {nullableLabel(node.properties.nullable)}</span>
|
||||
<span>length: {lengthLabel(node)}</span>
|
||||
<span>
|
||||
active flags:{" "}
|
||||
{(node.activeFlags ?? flags).length > 0
|
||||
? (node.activeFlags ?? flags).join("")
|
||||
: "none"}
|
||||
</span>
|
||||
<span>
|
||||
flavour: {node.support.flavour} · {node.support.status}
|
||||
</span>
|
||||
<span>compatibility: {compatibility}</span>
|
||||
<span>risk: not assessed in this milestone</span>
|
||||
<span>
|
||||
parser: {node.provenance.provider} {node.provenance.providerVersion}{" "}
|
||||
· {node.provenance.source}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{node.children.length > 0 ? (
|
||||
<ul role="group">
|
||||
{node.children.map((child) => (
|
||||
<ExplanationNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
focusableId={focusableId}
|
||||
flags={flags}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExplanationTree({
|
||||
root,
|
||||
selectedId,
|
||||
flags = [],
|
||||
onSelect,
|
||||
}: ExplanationTreeProps) {
|
||||
const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
|
||||
const visibleRoot = useMemo(
|
||||
() =>
|
||||
root ? boundedRoot(root, MAXIMUM_RENDERED_EXPLANATION_NODES) : undefined,
|
||||
[root],
|
||||
);
|
||||
const renderedNodes = Math.min(
|
||||
totalNodes,
|
||||
MAXIMUM_RENDERED_EXPLANATION_NODES,
|
||||
);
|
||||
const selectedNodeVisible =
|
||||
selectedId === undefined ||
|
||||
visibleRoot === undefined ||
|
||||
containsNode(visibleRoot, selectedId);
|
||||
const focusableId =
|
||||
selectedId && selectedNodeVisible ? selectedId : (visibleRoot?.id ?? "");
|
||||
|
||||
return (
|
||||
<section className="panel tree-panel" aria-labelledby="explanation-heading">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Pattern structure</p>
|
||||
<h2 id="explanation-heading">Explanation tree</h2>
|
||||
</div>
|
||||
<span className="provenance-badge">
|
||||
Deterministic · syntax provider
|
||||
</span>
|
||||
</header>
|
||||
{totalNodes > renderedNodes ? (
|
||||
<p
|
||||
className="render-limit-banner"
|
||||
role="status"
|
||||
data-testid="explanation-render-limit"
|
||||
>
|
||||
<strong>Tree preview limited.</strong> Showing the first{" "}
|
||||
{renderedNodes.toLocaleString()} of {totalNodes.toLocaleString()}{" "}
|
||||
normalized syntax nodes. The complete normalized syntax tree remains
|
||||
available to the application.
|
||||
{!selectedNodeVisible
|
||||
? " The selected pattern node is outside this bounded tree preview."
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
{visibleRoot ? (
|
||||
<ul className="tree" role="tree" aria-label="Pattern explanation">
|
||||
<ExplanationNode
|
||||
node={visibleRoot}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
depth={1}
|
||||
focusableId={focusableId}
|
||||
flags={flags}
|
||||
/>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="empty-state">The syntax worker is preparing the tree.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user