diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md
index f1139c6d..b61ddc98 100644
--- a/apps/docs/src/content/inspectors/forms.md
+++ b/apps/docs/src/content/inspectors/forms.md
@@ -165,7 +165,7 @@ You can also open a form from its component in the [Components tab](./components
## Agent tools
-`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`.
+`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`. A literal dot in a control key is written `\.` and a literal backslash `\\`, so the control keyed `a.b` has the path `a\.b`. The panel and the tool output show the same escaped form.
### Read tools
diff --git a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts
new file mode 100644
index 00000000..aca2972c
--- /dev/null
+++ b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts
@@ -0,0 +1,370 @@
+// @vitest-environment jsdom
+import '@angular/compiler';
+import { Component, signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { FormField, form } from '@angular/forms/signals';
+import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+import { afterEach, describe, expect, it } from 'vitest';
+import { collectForms, controlPathOf, diffForms, findForms, nodeAt } from '../forms.ts';
+import { runFormAction, type ActionContext } from '../forms-actions.ts';
+import { fieldPath } from '../forms-read.ts';
+import { formSourceIn } from '../rpc/forms-source.ts';
+import { inferShape, requiredNow, schemaInputs, schemaRequired } from '../forms-webmcp.ts';
+
+try {
+ TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+} catch {
+ // already initialized in this worker
+}
+
+afterEach(() => TestBed.resetTestingModule());
+
+const ngApi = () => (globalThis as any).ng;
+
+function contextFor(host: HTMLElement): ActionContext {
+ const all = () => host.querySelectorAll('*');
+ const found = findForms(ngApi(), all());
+ const forms = new Map(found.forms.map((f, i) => [`form-${i + 1}`, f]));
+ return { ng: ngApi(), forms, elements: found.elements, all };
+}
+
+function collect(host: HTMLElement) {
+ return collectForms(findForms(ngApi(), host.querySelectorAll('*')));
+}
+
+class Dotted {
+ form = new FormGroup({
+ 'a.b': new FormControl(''),
+ a: new FormGroup({ b: new FormControl('inner') }),
+ 'my.group': new FormGroup({ 'x.y': new FormControl(''), z: new FormControl('') }),
+ 'api.token': new FormControl(''),
+ plain: new FormControl(''),
+ });
+}
+Component({
+ selector: 'dotted-form',
+ imports: [ReactiveFormsModule],
+ template: `
+
+ `,
+})(Dotted);
+
+class SignalDotted {
+ model = signal({ 'a.b': '', a: { b: 'inner' }, plain: '' });
+ form = form(this.model);
+}
+Component({
+ selector: 'signal-dotted-form',
+ imports: [FormField],
+ template: ` `,
+})(SignalDotted);
+
+class Slashed {
+ form = new FormGroup({ 'a\\b': new FormControl(''), 'x.y': new FormControl('') });
+}
+Component({
+ selector: 'slashed-form',
+ imports: [ReactiveFormsModule],
+ template: ``,
+})(Slashed);
+
+class SignalSlashed {
+ model = signal({ 'a\\b': '' });
+ form = form(this.model);
+}
+Component({
+ selector: 'signal-slashed-form',
+ imports: [FormField],
+ template: ` `,
+})(SignalSlashed);
+
+async function render(type: new () => T) {
+ const fixture = TestBed.createComponent(type);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ return fixture;
+}
+
+function paths(node: any, out: string[] = []): string[] {
+ out.push(node.path);
+ for (const child of node.children ?? []) paths(child, out);
+ return out;
+}
+
+describe('control keys that contain a dot', () => {
+ it('collects escaped paths and leaves plain ones unchanged', async () => {
+ const fixture = await render(Dotted);
+ const [collected] = collect(fixture.nativeElement);
+ expect(paths(collected.root)).toEqual([
+ '',
+ 'a\\.b',
+ 'a',
+ 'a.b',
+ 'my\\.group',
+ 'my\\.group.x\\.y',
+ 'my\\.group.z',
+ 'api\\.token',
+ 'plain',
+ ]);
+ });
+
+ it('maps a control back to its escaped path and the path back to the control', async () => {
+ const fixture = await render(Dotted);
+ const found = findForms(ngApi(), fixture.nativeElement.querySelectorAll('*'));
+ const root = fixture.componentInstance.form;
+ expect(controlPathOf(root as any, root.controls['my.group'].controls['x.y'] as any)).toBe(
+ 'my\\.group.x\\.y',
+ );
+ expect(nodeAt(found.forms[0], 'a\\.b')).toBe(root.controls['a.b']);
+ expect(nodeAt(found.forms[0], 'a.b')).toBe(root.controls.a.controls.b);
+ });
+
+ it('sets, fills and focuses the dotted control, not the one it used to be read as', async () => {
+ const fixture = await render(Dotted);
+ document.body.appendChild(fixture.nativeElement);
+ const ctx = contextFor(fixture.nativeElement);
+ const group = fixture.componentInstance.form;
+
+ const set = await runFormAction(ctx, {
+ action: 'set-value',
+ formId: 'form-1',
+ path: 'a\\.b',
+ value: 'dotted',
+ mode: 'user',
+ });
+ expect(set.ok).toBe(true);
+ expect(group.controls['a.b'].value).toBe('dotted');
+ expect(group.controls.a.controls.b.value).toBe('inner');
+
+ const fill = await runFormAction(ctx, {
+ action: 'fill',
+ formId: 'form-1',
+ values: { 'my\\.group.x\\.y': 'deep', 'a.b': 'nested' },
+ });
+ expect(fill.ok).toBe(true);
+ expect(group.controls['my.group'].controls['x.y'].value).toBe('deep');
+ expect(group.controls.a.controls.b.value).toBe('nested');
+
+ const whole = await runFormAction(ctx, {
+ action: 'set-value',
+ formId: 'form-1',
+ path: 'my\\.group',
+ value: { 'x.y': 'whole', z: 'zed' },
+ });
+ expect(whole.ok).toBe(true);
+ expect(group.controls['my.group'].controls['x.y'].value).toBe('whole');
+
+ const focus = await runFormAction(ctx, { action: 'focus', formId: 'form-1', path: 'a\\.b' });
+ expect(focus.ok).toBe(true);
+ expect(document.activeElement?.id).toBe('ab');
+ fixture.nativeElement.remove();
+ });
+
+ it('refuses a secret key by its own name and names it escaped', async () => {
+ const fixture = await render(Dotted);
+ const ctx = contextFor(fixture.nativeElement);
+ const result = await runFormAction(ctx, {
+ action: 'set-value',
+ formId: 'form-1',
+ path: 'api\\.token',
+ value: 'x',
+ });
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('is redacted');
+ expect(fixture.componentInstance.form.controls['api.token'].value).toBe('');
+
+ const nested = await runFormAction(ctx, {
+ action: 'set-value',
+ formId: 'form-1',
+ path: 'my\\.group',
+ value: { 'x.y': 'q', z: 'r', 'api.token': 'nope' },
+ });
+ expect(nested.ok).toBe(false);
+ expect(nested.error).toContain('secret field "api\\.token"');
+ });
+
+ it('reports invalid paths and stored-global expressions in the escaped form', async () => {
+ const fixture = await render(Dotted);
+ const ctx = contextFor(fixture.nativeElement);
+ const inner = fixture.componentInstance.form.controls['my.group'].controls['x.y'];
+ inner.setErrors({ bad: true });
+ const stored = await runFormAction(ctx, {
+ action: 'store-as-global',
+ formId: 'form-1',
+ path: 'my\\.group.x\\.y',
+ });
+ expect(stored.invalid).toContain('my\\.group.x\\.y');
+ expect(stored.expression).toBe("$form.get(['my.group','x.y'])");
+ expect((window as any).$control).toBe(inner);
+ const plain = await runFormAction(ctx, {
+ action: 'store-as-global',
+ formId: 'form-1',
+ path: 'plain',
+ });
+ expect(plain.expression).toBe("$form.get('plain')");
+ });
+
+ it('diffs events at the escaped path', async () => {
+ const fixture = await render(Dotted);
+ const before = collect(fixture.nativeElement);
+ fixture.componentInstance.form.controls['my.group'].controls['x.y'].setValue('changed');
+ const events = diffForms(before, collect(fixture.nativeElement));
+ expect(events.map((e) => e.path)).toContain('my\\.group.x\\.y');
+ });
+
+ it('works on Signal Forms keys', async () => {
+ const fixture = await render(SignalDotted);
+ document.body.appendChild(fixture.nativeElement);
+ const ctx = contextFor(fixture.nativeElement);
+ const [collected] = collect(fixture.nativeElement);
+ expect(paths(collected.root)).toEqual(['', 'a\\.b', 'a', 'a.b', 'plain']);
+ const result = await runFormAction(ctx, {
+ action: 'set-value',
+ formId: 'form-1',
+ path: 'a\\.b',
+ value: 'sig',
+ });
+ expect(result.ok).toBe(true);
+ expect(fixture.componentInstance.model()['a.b']).toBe('sig');
+ expect(fixture.componentInstance.model().a.b).toBe('inner');
+ const focus = await runFormAction(ctx, { action: 'focus', formId: 'form-1', path: 'a\\.b' });
+ expect(focus.ok).toBe(true);
+ const stored = await runFormAction(ctx, {
+ action: 'store-as-global',
+ formId: 'form-1',
+ path: 'a\\.b',
+ });
+ expect(stored.expression).toBe("$form['a.b']");
+ fixture.nativeElement.remove();
+ });
+
+ it('builds a Signal Forms field path with an escaped key', async () => {
+ const fixture = await render(SignalDotted);
+ const found = findForms(ngApi(), fixture.nativeElement.querySelectorAll('*'));
+ const field = (found.forms[0].root as any).fieldTree['a.b']();
+ expect(fieldPath(field)).toBe('a\\.b');
+ });
+});
+
+describe('WebMCP and source paths with dotted keys', () => {
+ it('escapes dotted keys in inferred, required and input paths', () => {
+ expect(inferShape({ 'a.b': null, ok: { 'c.d': undefined } }).blocking).toEqual([
+ { path: 'a\\.b', reason: 'null' },
+ { path: 'ok.c\\.d', reason: 'undefined' },
+ ]);
+ const schema = {
+ type: 'object',
+ properties: {
+ 'a.b': { type: 'string' },
+ n: { type: 'object', properties: { 'c.d': { type: 'number' } }, required: ['c.d'] },
+ },
+ required: ['a.b'],
+ };
+ expect(schemaInputs(schema)).toEqual(['a\\.b: string', 'n.c\\.d: number']);
+ expect(schemaRequired(schema)).toEqual(['a\\.b', 'n.c\\.d']);
+ });
+
+ it('keeps requiredNow in step with schemaRequired for dotted keys', () => {
+ const node = (value: unknown, key: string): any => ({
+ value: () => value,
+ required: () => true,
+ keyInParent: () => key,
+ structure: {
+ materializedChildren: () =>
+ value && typeof value === 'object'
+ ? Object.entries(value).map(([k, v]) => node(v, k))
+ : [],
+ },
+ });
+ expect([...requiredNow(node({ 'a.b': 'x' }, '')).keys()]).toEqual(['a\\.b']);
+ });
+
+ it('looks up the source rule by the unescaped key, not its last dotted piece', () => {
+ const file = `
+export class Account {
+ account = new FormGroup({
+ b: new FormControl('', Validators.required),
+ });
+}
+`;
+ expect(formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\.b')?.rules).toEqual([]);
+ expect(formSourceIn(file, 'a.ts', 'Account', 'account', 'b')?.rules).toHaveLength(1);
+ });
+
+ it('matches single and double quoted dotted keys in the source', () => {
+ const file = `
+export class Account {
+ account = new FormGroup({
+ 'a.b': new FormControl('', Validators.required),
+ "c.d": new FormControl('', Validators.required),
+ b: new FormControl(''),
+ });
+}
+`;
+ expect(
+ formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\.b')?.rules.map((r) => r.line),
+ ).toEqual([4]);
+ expect(
+ formSourceIn(file, 'a.ts', 'Account', 'account', 'c\\.d')?.rules.map((r) => r.line),
+ ).toEqual([5]);
+ });
+});
+
+describe('source lookup of keys with a backslash', () => {
+ it('matches a key whose source literal escapes the backslash', () => {
+ const file = [
+ 'export class Account {',
+ ' account = new FormGroup({',
+ " 'a\\\\b': new FormControl('', Validators.required),",
+ " b: new FormControl(''),",
+ ' });',
+ '}',
+ ].join('\n');
+ expect(
+ formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\\\b')?.rules.map((r) => r.line),
+ ).toEqual([3]);
+ });
+});
+
+describe('stored-global expressions with backslashes in keys', () => {
+ it('writes a backslash as an escaped character in the reactive expression', async () => {
+ const fixture = await render(Slashed);
+ const ctx = contextFor(fixture.nativeElement);
+ const slash = await runFormAction(ctx, {
+ action: 'store-as-global',
+ formId: 'form-1',
+ path: 'a\\\\b',
+ });
+ expect(slash.expression).toBe(String.raw`$form.get('a\\b')`);
+ const mixed = await runFormAction(ctx, {
+ action: 'store-as-global',
+ formId: 'form-1',
+ path: 'x\\.y',
+ });
+ expect(mixed.expression).toBe("$form.get(['x.y'])");
+ });
+
+ it('uses bracket notation with an escaped backslash for Signal Forms', async () => {
+ const fixture = await render(SignalSlashed);
+ const ctx = contextFor(fixture.nativeElement);
+ const result = await runFormAction(ctx, {
+ action: 'store-as-global',
+ formId: 'form-1',
+ path: 'a\\\\b',
+ });
+ expect(result.expression).toBe(String.raw`$form['a\\b']`);
+ });
+});
diff --git a/packages/devtools/src/__tests__/forms-path.test.ts b/packages/devtools/src/__tests__/forms-path.test.ts
new file mode 100644
index 00000000..7307dfc6
--- /dev/null
+++ b/packages/devtools/src/__tests__/forms-path.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { childPath, joinPath, splitPath } from '../forms-path.ts';
+
+describe('form control paths', () => {
+ it('keeps plain keys exactly as they were joined before', () => {
+ expect(joinPath(['address', 'city'])).toBe('address.city');
+ expect(joinPath(['items', '0', 'name'])).toBe('items.0.name');
+ expect(joinPath(['name'])).toBe('name');
+ expect(joinPath([])).toBe('');
+ expect(splitPath('address.city')).toEqual(['address', 'city']);
+ expect(splitPath('items.0.name')).toEqual(['items', '0', 'name']);
+ expect(splitPath('')).toEqual([]);
+ });
+
+ it('writes a literal dot as \\. and a literal backslash as \\\\', () => {
+ expect(joinPath(['a.b'])).toBe('a\\.b');
+ expect(joinPath(['a\\b'])).toBe('a\\\\b');
+ expect(joinPath(['a.b', 'c'])).toBe('a\\.b.c');
+ expect(joinPath(['a', 'b.c'])).toBe('a.b\\.c');
+ expect(joinPath(['\\.'])).toBe('\\\\\\.');
+ });
+
+ it('reads an escaped path back into its segments', () => {
+ expect(splitPath('a\\.b')).toEqual(['a.b']);
+ expect(splitPath('a\\.b.c')).toEqual(['a.b', 'c']);
+ expect(splitPath('a\\\\.b')).toEqual(['a\\', 'b']);
+ expect(splitPath('a\\\\\\.b')).toEqual(['a\\.b']);
+ });
+
+ it('round trips dots, backslashes, empty and numeric segments', () => {
+ const cases = [
+ ['a.b'],
+ ['a.b', 'c.d'],
+ ['x', 'a\\b', 'y'],
+ ['\\'],
+ ['.'],
+ ['..'],
+ ['a', ''],
+ ['', 'a'],
+ ['a', '', 'b'],
+ ['0', '1.5', '2'],
+ ['\\.', '.\\'],
+ ['trailing\\'],
+ ];
+ for (const segments of cases) expect(splitPath(joinPath(segments))).toEqual(segments);
+ });
+
+ it('appends one key to an existing path', () => {
+ expect(childPath('', 'name')).toBe('name');
+ expect(childPath('', 'a.b')).toBe('a\\.b');
+ expect(childPath('g', 'a.b')).toBe('g.a\\.b');
+ expect(childPath('g\\.h', 'c')).toBe('g\\.h.c');
+ });
+});
diff --git a/packages/devtools/src/forms-actions.ts b/packages/devtools/src/forms-actions.ts
index d8d0159a..5ccf568a 100644
--- a/packages/devtools/src/forms-actions.ts
+++ b/packages/devtools/src/forms-actions.ts
@@ -15,6 +15,7 @@ import {
unmaskHint,
type RedactReason,
} from './forms-privacy.ts';
+import { childPath, splitPath } from './forms-path.ts';
import { fieldPath, submitSetup } from './forms-read.ts';
import { clip } from './text.ts';
@@ -109,7 +110,7 @@ export function secretInside(
if (!value || typeof value !== 'object' || value instanceof Date || seen.has(value)) return null;
seen.add(value);
for (const [key, child] of Object.entries(value as AnyRecord)) {
- const path = prefix ? `${prefix}.${key}` : key;
+ const path = childPath(prefix, key);
if (!/^\d+$/.test(key) && isRedactedKey(key)) return path;
const nested = secretInside(child, path, seen);
if (nested) return nested;
@@ -134,7 +135,7 @@ export function keepSecrets(
}
function keysOf(path: string): string[] {
- return path ? path.split('.') : [];
+ return splitPath(path);
}
export function valueAt(value: unknown, path: string): unknown {
@@ -156,11 +157,14 @@ function hasPath(value: unknown, path: string): boolean {
}
export function setAt(value: unknown, path: string, next: unknown): unknown {
- const keys = keysOf(path);
+ return setIn(value, keysOf(path), next);
+}
+
+function setIn(value: unknown, keys: string[], next: unknown): unknown {
if (!keys.length) return next;
if (!value || typeof value !== 'object') return value;
const copy: AnyRecord = Array.isArray(value) ? [...value] : { ...(value as AnyRecord) };
- copy[keys[0]] = setAt(copy[keys[0]], keys.slice(1).join('.'), next);
+ copy[keys[0]] = setIn(copy[keys[0]], keys.slice(1), next);
return copy;
}
@@ -302,12 +306,12 @@ function secretOf(
path: string,
element: Element | null,
): { key: string; reason: RedactReason } | null {
- const keys = path.split('.');
+ const keys = splitPath(path);
for (const [i, key] of keys.entries()) {
const reason = redactReason(key);
if (reason) return { key, reason: i < keys.length - 1 ? 'parent' : reason };
}
- const key = keys.at(-1)!;
+ const key = keys.at(-1) ?? '';
const reason = element && redactReason(key, element);
return reason ? { key, reason } : null;
}
@@ -386,7 +390,7 @@ export function guardedFields(
return;
}
for (const [key, child] of childrenOf(found, current)) {
- visit(child, currentPath ? `${currentPath}.${key}` : key);
+ visit(child, childPath(currentPath, key));
}
};
visit(node, path);
@@ -502,7 +506,7 @@ function writeValue(
const current = rawValue(found, node);
const secret = secretInside(value);
if (secret) {
- const key = secret.split('.').pop()!;
+ const key = splitPath(secret).pop()!;
const reason = redactReason(key) ?? 'key';
return `contains the secret field "${secret}" (${REDACT_LABELS[reason]}). Pangular Inspector never writes secret fields; to write it, ${unmaskHint(reason, key)}`;
}
@@ -573,7 +577,7 @@ function invalidPaths(found: FoundForm): string[] {
if (ownErrors) out.push(path);
for (const child of children) {
const key = String(read(() => child['keyInParent'](), ''));
- visit(child, path ? `${path}.${key}` : key, depth + 1);
+ visit(child, childPath(path, key), depth + 1);
}
return;
}
@@ -584,7 +588,7 @@ function invalidPaths(found: FoundForm): string[] {
const entries: [string, AnyRecord][] = Array.isArray(controls)
? controls.map((c, i) => [String(i), c])
: Object.entries(controls);
- for (const [key, child] of entries) visit(child, path ? `${path}.${key}` : key, depth + 1);
+ for (const [key, child] of entries) visit(child, childPath(path, key), depth + 1);
};
visit(found.root, '', 0);
return out;
@@ -619,13 +623,17 @@ function directiveWith(ctx: ActionContext, element: Element | undefined, method:
function expressionFor(found: FoundForm, path: string): string {
if (!path) return '$form';
+ const keys = splitPath(path);
+ const quote = (key: string) => `'${key.replace(/[\\']/g, '\\$&')}'`;
if (found.kind === 'signal') {
- return `$form${path
- .split('.')
- .map((key) => (/^\d+$/.test(key) ? `[${key}]` : `.${key}`))
+ return `$form${keys
+ .map((key) =>
+ /^\d+$/.test(key) ? `[${key}]` : /[.\\]/.test(key) ? `[${quote(key)}]` : `.${key}`,
+ )
.join('')}`;
}
- return `$form.get('${path.replace(/'/g, "\\'")}')`;
+ if (keys.some((key) => key.includes('.'))) return `$form.get([${keys.map(quote).join(',')}])`;
+ return `$form.get(${quote(keys.join('.'))})`;
}
function locate(ctx: ActionContext, selector: string): FormActionResult {
diff --git a/packages/devtools/src/forms-path.ts b/packages/devtools/src/forms-path.ts
new file mode 100644
index 00000000..5452a12d
--- /dev/null
+++ b/packages/devtools/src/forms-path.ts
@@ -0,0 +1,27 @@
+function escapeKey(key: string): string {
+ return key.replace(/[\\.]/g, '\\$&');
+}
+
+export function joinPath(segments: readonly string[]): string {
+ return segments.map(escapeKey).join('.');
+}
+
+export function childPath(path: string, key: string): string {
+ return path ? `${path}.${escapeKey(key)}` : escapeKey(key);
+}
+
+export function splitPath(path: string): string[] {
+ if (!path) return [];
+ const segments: string[] = [];
+ let current = '';
+ for (let i = 0; i < path.length; i++) {
+ const char = path[i];
+ if (char === '\\' && i + 1 < path.length) current += path[++i];
+ else if (char === '.') {
+ segments.push(current);
+ current = '';
+ } else current += char;
+ }
+ segments.push(current);
+ return segments;
+}
diff --git a/packages/devtools/src/forms-read.ts b/packages/devtools/src/forms-read.ts
index c02e17bd..dfc65f95 100644
--- a/packages/devtools/src/forms-read.ts
+++ b/packages/devtools/src/forms-read.ts
@@ -1,3 +1,5 @@
+import { joinPath } from './forms-path.ts';
+
type AnyRecord = Record;
export type ErrorSource =
@@ -107,7 +109,7 @@ export function fieldPath(node: AnyRecord): string {
keys.unshift(String(read(() => current!['keyInParent'](), '?')));
current = parent;
}
- return keys.join('.');
+ return joinPath(keys);
}
function originLevel(node: AnyRecord, error: unknown, raw: string): AnyRecord {
diff --git a/packages/devtools/src/forms-webmcp.ts b/packages/devtools/src/forms-webmcp.ts
index cd68cd91..78d985b5 100644
--- a/packages/devtools/src/forms-webmcp.ts
+++ b/packages/devtools/src/forms-webmcp.ts
@@ -1,5 +1,6 @@
import { redactFormText } from './forms.ts';
import { redactMessage } from './forms-privacy.ts';
+import { childPath } from './forms-path.ts';
import { tokenName, type DebugNg } from './injector-tree.ts';
type AnyRecord = Record;
@@ -83,9 +84,7 @@ function read(fn: () => T, fallback: T): T {
}
}
-function join(path: string, key: string): string {
- return path ? `${path}.${key}` : key;
-}
+const join = childPath;
export function inferShape(value: unknown, path = '', depth = 0): Shape {
if (typeof value === 'string') return { schema: { type: 'string' }, blocking: [] };
diff --git a/packages/devtools/src/forms.ts b/packages/devtools/src/forms.ts
index 3e47696a..337fdcdb 100644
--- a/packages/devtools/src/forms.ts
+++ b/packages/devtools/src/forms.ts
@@ -1,5 +1,6 @@
import { domFacts, submitDom, type DomFacts, type SubmitDom } from './forms-dom.ts';
import { clip } from './text.ts';
+import { childPath, joinPath, splitPath } from './forms-path.ts';
import type { WebMcpTool } from './forms-webmcp.ts';
import {
REDACTED,
@@ -548,14 +549,7 @@ export function serializeControl(
node.children = entries
.slice(0, MAX_CHILDREN)
.map(([childKey, child]) =>
- serializeControl(
- child,
- elements,
- childKey,
- path ? `${path}.${childKey}` : childKey,
- depth + 1,
- secret,
- ),
+ serializeControl(child, elements, childKey, childPath(path, childKey), depth + 1, secret),
);
if (entries.length > MAX_CHILDREN) node.truncated = entries.length - MAX_CHILDREN;
return node;
@@ -583,7 +577,7 @@ function issuePath(issue: AnyRecord): string | undefined {
const keys = parts.map((part) =>
part && typeof part === 'object' ? String((part as AnyRecord)['key']) : String(part),
);
- return keys.length ? keys.join('.') : undefined;
+ return keys.length ? joinPath(keys) : undefined;
}
function signalError(
@@ -613,7 +607,7 @@ export function errorSummaryOf(root: AnyRecord): FormErrorSummary[] {
return errors.slice(0, 50).map((error) => {
const target = read(() => error['fieldTree']() as AnyRecord, null);
const path = target ? fieldPath(target) : '';
- const secret = path.split('.').some((key) => isRedactedKey(key));
+ const secret = splitPath(path).some((key) => isRedactedKey(key));
const { kind, message } = signalError(error, 'control', secret);
return { path, kind, message };
});
@@ -759,11 +753,11 @@ export function serializeField(
(childKey) => created.has(childKey) || (value as AnyRecord)[childKey] !== undefined,
);
node.children = fieldKeys.slice(0, MAX_CHILDREN).map((childKey) => {
- const childPath = path ? `${path}.${childKey}` : childKey;
+ const nextPath = childPath(path, childKey);
const child = created.get(childKey);
return child
- ? serializeField(child, elements, childKey, childPath, depth + 1, secret)
- : unmaterializedField((value as AnyRecord)[childKey], childKey, childPath, secret);
+ ? serializeField(child, elements, childKey, nextPath, depth + 1, secret)
+ : unmaterializedField((value as AnyRecord)[childKey], childKey, nextPath, secret);
});
if (fieldKeys.length > MAX_CHILDREN) node.truncated = fieldKeys.length - MAX_CHILDREN;
return node;
@@ -1086,7 +1080,8 @@ function byUid(node: FormFieldNode, out = new Map()) {
}
function parentPath(path: string): string {
- return path.includes('.') ? path.slice(0, path.lastIndexOf('.')) : '';
+ const keys = splitPath(path);
+ return keys.length > 1 ? joinPath(keys.slice(0, -1)) : '';
}
export function diffForms(
@@ -1198,7 +1193,7 @@ export function controlPathOf(root: AnyRecord, target: AnyRecord): string {
keys.unshift(key);
current = parent;
}
- return keys.join('.');
+ return joinPath(keys);
}
export interface ControlEventOptions {
@@ -1219,7 +1214,7 @@ export function controlEventOf(
const base = { formId, path, timestamp: now };
const of = (key: string) => read(() => (source ?? event)[key], event[key]);
if ('value' in event) {
- const keys = [rootKey, ...(path ? path.split('.') : [])];
+ const keys = [rootKey, ...splitPath(path)];
const key = keys[keys.length - 1];
const secret = keys.slice(0, -1).some((k) => isRedactedKey(k));
const value = isAbstractControl(source)
@@ -1290,7 +1285,7 @@ export function nodeAt(
path: string,
create = false,
): AnyRecord | null {
- const keys = path ? path.split('.') : [];
+ const keys = splitPath(path);
if (found.kind === 'signal' && create) {
let tree: unknown = read(() => found.root['fieldTree'], null);
for (const key of keys) {
@@ -1316,7 +1311,7 @@ export function findFieldElement(
known: WeakMap = new WeakMap(),
): Element | null {
let node: AnyRecord | null = found.root;
- for (const key of path ? path.split('.') : []) {
+ for (const key of splitPath(path)) {
node = node && childAt(node, found.kind, key);
}
if (!node) return null;
diff --git a/packages/devtools/src/rpc/forms-source.ts b/packages/devtools/src/rpc/forms-source.ts
index c485a5ae..e38fafeb 100644
--- a/packages/devtools/src/rpc/forms-source.ts
+++ b/packages/devtools/src/rpc/forms-source.ts
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
+import { splitPath } from '../forms-path.ts';
import { lineCounter, skipString, sourceRoots, stripComments, walkFiles } from './source-scan.ts';
export interface SourceLine {
@@ -104,7 +105,10 @@ function fieldRules(
at: (index: number) => SourceLine,
out: SourceLine[],
) {
- const field = new RegExp(`(\\.${escape(key)}\\b|\\b${escape(key)}\\s*:)`);
+ const literal = key.replace(/\\/g, '\\\\');
+ const field = new RegExp(
+ `(\\.${escape(key)}\\b|\\b${escape(key)}\\s*:|(['"])${escape(literal)}\\2\\s*:)`,
+ );
let offset = 0;
for (const text of scope.split('\n')) {
if (out.length >= 10) return;
@@ -141,8 +145,7 @@ export function formSourceIn(
? new RegExp(`\\b${escape(property)}\\s*(?::[^=;]+)?=`).exec(scope)
: null;
result.form = at(body.start + (declared ? declared.index : 0));
- const key = path
- .split('.')
+ const key = splitPath(path)
.filter((k) => !/^\d+$/.test(k))
.pop();
if (key) fieldRules(scope, body.start, key, at, result.rules);