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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "refactor: modularize Schema, ObserverMap, and AttributeMap in fast-html",
"packageName": "@microsoft/fast-html",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
54 changes: 41 additions & 13 deletions packages/fast-html/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ Built during template parsing, one `Schema` instance per `<f-template>`. It reco

- Describes the shape of each root property referenced in the template.
- Tracks repeat context chains (parent/child array relationships).
- Is stored statically per element name so it can be shared across instances.
- Uses an instance-level `schemaMap` for its own property schemas.
- Registers itself in the module-level `schemaRegistry` (keyed by custom element name) for cross-element `$ref` resolution.

### `ObserverMap` — automatic observable setup

Expand Down Expand Up @@ -164,8 +165,9 @@ packages/fast-html/
│ ├── element.ts # Element utilities
│ ├── template.ts # TemplateElement (<f-template>), lifecycle orchestration, options
│ ├── template-parser.ts # TemplateParser — converts declarative HTML to ViewTemplate strings/values
│ ├── schema.ts # Schema class — JSON schema builder
│ ├── observer-map.ts # ObserverMap class — auto observable/proxy setup
│ ├── schema.ts # Schema class — JSON schema builder + schemaRegistry
│ ├── observer-map.ts # ObserverMap class + config types (ObserverMapConfig, ObserverMapPathEntry, etc.)
│ ├── attribute-map.ts # AttributeMap class + config types (AttributeMapConfig, AttributeMapOption)
│ ├── utilities.ts # Parsing engine, binding resolvers, proxy system
│ └── syntax.ts # Syntax delimiter constants
├── rules/ # ast-grep YAML rules for converting html`` → declarative HTML
Expand All @@ -176,6 +178,19 @@ packages/fast-html/
└── fixtures/ # One directory per feature, each with spec + index.html + main.ts
```

### Module dependency direction

Each module owns its configuration types and can be used independently:

```
template.ts ──imports──▶ observer-map.ts (ObserverMapConfig, ObserverMapOption)
template.ts ──imports──▶ attribute-map.ts (AttributeMapConfig, AttributeMapOption)
template.ts ──imports──▶ schema.ts (Schema)
observer-map.ts ──imports──▶ schema.ts (Schema types)
attribute-map.ts ──imports──▶ schema.ts (Schema types)
utilities.ts ──imports──▶ schema.ts (schemaRegistry for cross-element $ref resolution)
```

---

## Exports and Public API
Expand All @@ -184,28 +199,40 @@ packages/fast-html/
import {
TemplateElement,
TemplateParser,
Schema,
schemaRegistry,
ObserverMap,
AttributeMap,
type ObserverMapConfig,
type ObserverMapPathEntry,
type ObserverMapPathNode,
type AttributeMapConfig,
type JSONSchema,
type CachedPathMap,
} from "@microsoft/fast-html";
```

Three primary exports are intended for application code:
Primary exports intended for application code:

| Export | Purpose |
|---|---|
| `TemplateElement` | Define the `<f-template>` element; configure callbacks and per-element options. |
| `TemplateParser` | Standalone parser that converts declarative HTML into `ViewTemplate` strings/values. Can be used independently of `<f-template>` for programmatic template compilation. |
| `ObserverMap` | Advanced: access the observer-map class directly if building tooling. |
| `Schema` | JSON schema builder that records binding paths discovered during template parsing. Each instance owns its own schema map and registers itself in the `schemaRegistry` for cross-element `$ref` resolution. |
| `schemaRegistry` | Module-level `Map<string, Map<string, JSONSchema>>` that indexes schemas by custom element name. Used for cross-element lookups (e.g. nested component `$ref` resolution). |
| `ObserverMap` | Automatic observable setup using the schema; defines observable properties and installs proxy-based deep change tracking. Configuration types (`ObserverMapConfig`, `ObserverMapPathEntry`, `ObserverMapPathNode`) are co-located in this module. |
| `AttributeMap` | Automatic `@attr` property registration for leaf bindings in the template. Configuration type (`AttributeMapConfig`) is co-located in this module. |

Additionally, the following types are exported for use in `observerMap` configuration:
Additionally, the following types are exported:

| Type | Purpose |
|---|---|
| `ObserverMapConfig` | Configuration object for the `observerMap` option; accepts optional `properties` key. |
| `ObserverMapPathEntry` | `boolean \| ObserverMapPathNode` — a node in the observation path tree. |
| `ObserverMapPathNode` | Object node with optional `$observe` and child property overrides. |
| Type | Source Module | Purpose |
|---|---|---|
| `ObserverMapConfig` | `observer-map.ts` | Configuration object for the `observerMap` option; accepts optional `properties` key. |
| `ObserverMapPathEntry` | `observer-map.ts` | `boolean \| ObserverMapPathNode` — a node in the observation path tree. |
| `ObserverMapPathNode` | `observer-map.ts` | Object node with optional `$observe` and child property overrides. |
| `AttributeMapConfig` | `attribute-map.ts` | Configuration object for the `attributeMap` option; accepts `attribute-name-strategy`. |
| `JSONSchema` | `schema.ts` | JSON Schema interface used by `Schema` for property structure. |
| `CachedPathMap` | `schema.ts` | `Map<string, Map<string, JSONSchema>>` — the shape of the schema registry. |

---

Expand Down Expand Up @@ -377,13 +404,14 @@ innerHTML token

## Schema and Observer Map

The `Schema` class accumulates all binding paths discovered during parsing into a static JSON Schema map indexed by `customElementName → rootPropertyName → JSONSchema`.
The `Schema` class accumulates all binding paths discovered during parsing into an instance-level JSON Schema map (`schemaMap`) indexed by `rootPropertyName → JSONSchema`. Each `Schema` instance also registers itself in the module-level `schemaRegistry` (keyed by custom element name) for cross-element `$ref` resolution.

```mermaid
flowchart LR
A["Template binding\nuser.details.age"] --> B[bindingResolver]
B --> C[schema.addPath\ntype:'access'\npath:'user.details.age'\nrootProperty:'user']
C --> D["Schema.jsonSchemaMap\n{'my-el' => {'user' => JSONSchema}}"]
C --> D["schema.schemaMap\n{'user' => JSONSchema}"]
C --> D2["schemaRegistry\n{'my-el' => schemaMap}"]
D --> E[ObserverMap.defineProperties]
E --> E1["applyConfigToSchema\nstamps $observe: false on excluded schema nodes"]
E1 --> F[Observable.defineProperty on prototype\nfor 'user']
Expand Down
36 changes: 36 additions & 0 deletions packages/fast-html/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,39 @@
});
}
```

## Modularize Schema, ObserverMap, and AttributeMap

### Import changes

Configuration types have moved from `template.ts` to their owning modules. If you import types directly from internal paths, update your imports:

| Before | After |
|---|---|
| `import type { ObserverMapConfig } from "./template.js"` | `import type { ObserverMapConfig } from "./observer-map.js"` |
| `import type { AttributeMapConfig } from "./template.js"` | `import type { AttributeMapConfig } from "./attribute-map.js"` |

Imports from the package barrel (`@microsoft/fast-html`) are unaffected.

### Schema changes

`Schema.jsonSchemaMap` (static property) has been replaced by:
- An instance-level `schemaMap` on each `Schema` instance (private)
- A module-level `schemaRegistry` export for cross-element lookups

| Before | After |
|---|---|
| `Schema.jsonSchemaMap.get('my-element')` | `import { schemaRegistry } from "@microsoft/fast-html"; schemaRegistry.get('my-element')` |

### New public exports

The following are now part of the public API:

| Export | Purpose |
|---|---|
| `Schema` | JSON schema builder class |
| `schemaRegistry` | Module-level registry for cross-element schema lookups |
| `AttributeMap` | Automatic `@attr` property registration |
| `AttributeMapOption` | Constant for the `"all"` option value |
| `JSONSchema` | JSON Schema type interface |
| `CachedPathMap` | Schema registry map type |
26 changes: 13 additions & 13 deletions packages/fast-html/SCHEMA_OBSERVER_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The `Schema` class is responsible for building JSON Schema definitions that map
```typescript
constructor(name: string)
```
Creates a new schema instance for a specific custom element name and initializes an entry in the static `jsonSchemaMap`.
Creates a new schema instance for a specific custom element name and initializes an instance-level `schemaMap`. The instance also registers itself in the module-level `schemaRegistry` for cross-element `$ref` resolution.

#### addPath
```typescript
Expand Down Expand Up @@ -131,7 +131,7 @@ Creates an observer map instance that will configure the provided class prototyp
public defineProperties(): void
```
The main method that:
1. Iterates through all root properties defined in the schema (each custom element in the jsonSchemaMap contains multiple schemas, one for each root property)
1. Iterates through all root properties defined in the schema (each schema instance contains multiple root property schemas)
2. Defines observable properties using FAST Element's `Observable.defineProperty` (an alternative to the `@observable` decorator syntax used in custom element classes)
3. Sets up property change handlers that create proxies for nested objects

Expand Down Expand Up @@ -375,16 +375,18 @@ This creates nested context definitions where the `post` context understands its

## Technical Details

### Static Schema Map
### Schema Registry

The `Schema` class maintains a static `CachedPathMap`:
The `Schema` module exports a module-level `schemaRegistry`:
```typescript
public static jsonSchemaMap: CachedPathMap = new Map();
export const schemaRegistry: CachedPathMap = new Map();
```

This map structure is: `Map<customElementName, Map<rootPropertyName, JSONSchema>>`
Each `Schema` instance owns an instance-level `schemaMap: Map<string, JSONSchema>` and registers itself in `schemaRegistry` on construction.

**Rationale for Static Property**: The static nature of this map is essential for handling nested components inside f-templates. When an object or array is passed to another custom element within an f-template, that nested component needs to observe the entire root property's structure based on the binding paths within that nested component. The static map allows all components to access and contribute to the same schema definitions, ensuring consistent observation behavior across component boundaries.
The registry structure is: `Map<customElementName, Map<rootPropertyName, JSONSchema>>`

**Rationale for Module-level Registry**: The registry allows cross-element `$ref` resolution for nested components inside f-templates. When an object or array is passed to another custom element within an f-template, that nested component needs to observe the entire root property's structure based on the binding paths within that nested component. The registry allows all components to access and contribute to the same schema definitions, ensuring consistent observation behavior across component boundaries.

### Context Tracking

Expand All @@ -408,15 +410,13 @@ The schema system tracks binding contexts using special metadata:

### Schema Inspection

You can inspect generated schemas from any f-template custom element in the browser using the console:
You can inspect generated schemas using the module-level `schemaRegistry` import:

```typescript
// First, select an f-template element in the browser's developer tools
// Then access the static jsonSchemaMap from the console:
$0.schema.__proto__.constructor.jsonSchemaMap
import { schemaRegistry } from "@microsoft/fast-html";

// To get a specific schema for an element and property:
const elementSchemas = $0.schema.__proto__.constructor.jsonSchemaMap.get('my-element');
// Get all schemas for an element:
const elementSchemas = schemaRegistry.get('my-element');
const userSchema = elementSchemas?.get('users');
console.log(JSON.stringify(userSchema, null, 2));
```
Expand Down
74 changes: 71 additions & 3 deletions packages/fast-html/docs/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,75 @@
```ts

import { FASTElement } from '@microsoft/fast-element';
import type { FASTElementDefinition } from '@microsoft/fast-element';
import { TemplateLifecycleCallbacks } from '@microsoft/fast-element';
import { ViewTemplate } from '@microsoft/fast-element';

// @public
export class AttributeMap {
constructor(classPrototype: any, schema: Schema, definition?: FASTElementDefinition, config?: AttributeMapConfig);
// (undocumented)
defineProperties(): void;
}

// @public
export interface AttributeMapConfig {
"attribute-name-strategy"?: "none" | "camelCase";
}

// @public
export const AttributeMapOption: {
readonly all: "all";
};

// @public
export type AttributeMapOption = (typeof AttributeMapOption)[keyof typeof AttributeMapOption] | AttributeMapConfig;

// @public (undocumented)
export type CachedPathMap = Map<string, Map<string, JSONSchema>>;

// @public
export interface ElementOptions {
// (undocumented)
attributeMap?: AttributeMapOption;
// Warning: (ae-forgotten-export) The symbol "ObserverMapOption" needs to be exported by the entry point index.d.ts
//
// (undocumented)
observerMap?: ObserverMapOption;
}

// @public
export interface ElementOptionsDictionary<ElementOptionsType = ElementOptions> {
// (undocumented)
[key: string]: ElementOptionsType;
}

// @public
export interface HydrationLifecycleCallbacks extends TemplateLifecycleCallbacks {
elementDidHydrate?(source: HTMLElement): void;
elementDidRegister?(name: string): void;
elementWillHydrate?(source: HTMLElement): void;
hydrationComplete?(): void;
hydrationStarted?(): void;
templateWillUpdate?(name: string): void;
}

// Warning: (ae-forgotten-export) The symbol "JSONSchemaCommon" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export interface JSONSchema extends JSONSchemaCommon {
// Warning: (ae-forgotten-export) The symbol "JSONSchemaDefinition" needs to be exported by the entry point index.d.ts
//
// (undocumented)
$defs?: Record<string, JSONSchemaDefinition>;
// (undocumented)
$id: string;
// (undocumented)
$schema: string;
}

// @public
export class ObserverMap {
// Warning: (ae-forgotten-export) The symbol "Schema" needs to be exported by the entry point index.d.ts
constructor(classPrototype: any, schema: Schema, config?: ObserverMapConfig);
// (undocumented)
defineProperties(): void;
Expand Down Expand Up @@ -47,14 +105,24 @@ export interface ResolvedStringsAndValues {
values: Array<any>;
}

// @public
export class Schema {
constructor(name: string);
// Warning: (ae-forgotten-export) The symbol "RegisterPathConfig" needs to be exported by the entry point index.d.ts
addPath(config: RegisterPathConfig): void;
getRootProperties(): IterableIterator<string>;
getSchema(rootPropertyName: string): JSONSchema | null;
}

// @public
export const schemaRegistry: CachedPathMap;

// @public
export class TemplateElement extends FASTElement {
constructor();
// Warning: (ae-forgotten-export) The symbol "HydrationLifecycleCallbacks" needs to be exported by the entry point index.d.ts
static config(callbacks: HydrationLifecycleCallbacks): typeof TemplateElement;
// (undocumented)
connectedCallback(): void;
// Warning: (ae-forgotten-export) The symbol "ElementOptionsDictionary" needs to be exported by the entry point index.d.ts
static elementOptions: ElementOptionsDictionary;
name?: string;
static options(elementOptions?: ElementOptionsDictionary): typeof TemplateElement;
Expand Down
36 changes: 35 additions & 1 deletion packages/fast-html/src/components/attribute-map.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,41 @@
import type { FASTElementDefinition } from "@microsoft/fast-element";
import { AttributeDefinition, Observable } from "@microsoft/fast-element";
import type { Schema } from "./schema.js";
import type { AttributeMapConfig } from "./template.js";

/**
* Values for the attributeMap element option.
*/
export const AttributeMapOption = {
all: "all",
} as const;

/**
* Configuration object for the attributeMap element option.
* Passing an empty object (`{}`) is equivalent to `"all"`.
*/
export interface AttributeMapConfig {
/**
* Strategy for mapping template binding keys to HTML attribute names.
*
* - `"none"` (default): the binding key is used as-is for both the
* property name and the attribute name (e.g. `{{foo-bar}}` →
* property `foo-bar`, attribute `foo-bar`).
* - `"camelCase"`: the binding key is treated as a camelCase property
* name and the attribute name is derived by converting it to
* kebab-case (e.g. `{{fooBar}}` → property `fooBar`, attribute
* `foo-bar`). This matches the build-time `attribute-name-strategy`
* option in `@microsoft/fast-build`.
*/
"attribute-name-strategy"?: "none" | "camelCase";
}

/**
* Type for the attributeMap element option.
* Accepts `"all"` or a configuration object.
*/
export type AttributeMapOption =
| (typeof AttributeMapOption)[keyof typeof AttributeMapOption]
| AttributeMapConfig;

/**
* Converts a camelCase string to kebab-case.
Expand Down
20 changes: 15 additions & 5 deletions packages/fast-html/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
export { AttributeMap } from "./attribute-map.js";
export { ObserverMap } from "./observer-map.js";
export {
AttributeMap,
type AttributeMapConfig,
AttributeMapOption,
type ElementOptions,
type ElementOptionsDictionary,
type HydrationLifecycleCallbacks,
} from "./attribute-map.js";
export {
ObserverMap,
type ObserverMapConfig,
ObserverMapOption,
type ObserverMapPathEntry,
type ObserverMapPathNode,
} from "./observer-map.js";
export {
type CachedPathMap,
type JSONSchema,
Schema,
schemaRegistry,
} from "./schema.js";
export {
type ElementOptions,
type ElementOptionsDictionary,
type HydrationLifecycleCallbacks,
TemplateElement,
} from "./template.js";
export { type ResolvedStringsAndValues, TemplateParser } from "./template-parser.js";
Loading
Loading