Closes #2531. ## Summary The full `nunjucks` templating engine (~226KB) plus `ha-nunjucks` (~46KB) — roughly **272KB, ~13% of the eager entry chunk** — was statically imported and downloaded by every card on initial load, even though templates only apply when a config value contains a `{{ … }}` / `{% … %}` delimiter. Most cards use no templates and never need the engine. This defers the engine behind a dynamic `import('ha-nunjucks/dist')` so it ships in a separate, on-demand chunk instead of the eager `card-*.js`. ## Approach The render path (`TemplateRenderer.renderRecursively`) is **kept synchronous** — it is called from many synchronous hot paths (condition/trigger evaluators, picture-elements rendering, actions, folder matchers), and making it async would be a large, high-risk refactor of the evaluation core. Instead: - **New `src/card-controller/templates/engine.ts`** — a module-level singleton lazy loader (`loadTemplateEngine()` / `getTemplateEngine()`) shared across all `TemplateRenderer` instances, plus a `containsTemplate()` delimiter helper. - **Delimiter gating** — strings without a delimiter never touch the engine (the overwhelming majority of renders). - **Pre-warm at config time** — because every template string originates in the config, a new mandatory `TEMPLATE_ENGINE` initialization aspect loads the engine before first render whenever the config contains a delimiter. This **guarantees no raw `{{ … }}` flash**: content/condition rendering is blocked until the engine is present for template-using cards. Cards without templates never load it. ## Result - nunjucks + ha-nunjucks move out of the eager chunk into a separate chunk fetched only when a card actually uses templates. - No change to the synchronous public render API; condition/trigger evaluation core untouched. ## Tests - New `engine.ts` loader coverage (concurrent load, cached reuse, not-loaded fallback). - The 11 existing test files that render real (delimiter-bearing) templates declare their dependency explicitly via `beforeAll(loadTemplateEngine)` — no global/implicit setup hook. - Full suite green (4764 tests), lint and ts-prune clean, per-file 100% coverage maintained for the affected directories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016ykWemdkZrgywvC71pHc6c --- _Generated by [Claude Code](https://claude.ai/code/session_016ykWemdkZrgywvC71pHc6c)_
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import type { renderTemplate } from 'ha-nunjucks/dist';
|
|
|
|
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
|
import type { TriggerData } from '../../condition-trigger/triggers/types';
|
|
import type { HomeAssistant } from '../../ha/types';
|
|
import { isRecord } from '../../utils/basic';
|
|
import type { TemplateACCNamespace, TemplateMediaData } from './types';
|
|
|
|
type RenderTemplate = typeof renderTemplate;
|
|
|
|
interface TemplateContext {
|
|
acc: TemplateACCNamespace;
|
|
|
|
// The HA-native top-level `trigger`, set only when a trigger fired.
|
|
trigger?: TriggerData;
|
|
}
|
|
|
|
interface TemplateRenderOptions {
|
|
conditionState?: ConditionState;
|
|
triggerData?: TriggerData;
|
|
mediaData?: TemplateMediaData;
|
|
}
|
|
|
|
// The template-rendering methods that callers depend on. Callers (e.g.
|
|
// condition/trigger code) are kept independent of CardController via this
|
|
// interface.
|
|
export interface TemplateRenderer {
|
|
// Whether the renderer has finished loading. Synchronous callers that may run
|
|
// before loading completes (condition/trigger evaluation) check this and
|
|
// defer rather than rendering a template against an absent renderer.
|
|
isLoaded(): boolean;
|
|
|
|
renderRecursively(
|
|
hass: HomeAssistant,
|
|
data: unknown,
|
|
options?: TemplateRenderOptions,
|
|
): unknown;
|
|
|
|
renderRecursivelyAsType<T>(
|
|
hass: HomeAssistant,
|
|
data: T,
|
|
options?: TemplateRenderOptions,
|
|
): T;
|
|
}
|
|
|
|
// Renders nunjucks templates for the card. The renderer itself (`ha-nunjucks`,
|
|
// ~272KB) is large and most cards never use a template, so it is imported on
|
|
// demand the first time a template needs rendering (see `loadRenderer`).
|
|
export class TemplateManager implements TemplateRenderer {
|
|
private _renderer: RenderTemplate | null = null;
|
|
|
|
/**
|
|
* Whether any string anywhere in a given piece of data is a template.
|
|
*/
|
|
public static dataContainsTemplate(data: unknown): boolean {
|
|
return TemplateManager._containsTemplate(JSON.stringify(data) ?? '');
|
|
}
|
|
|
|
/**
|
|
* Load the renderer (the first time) and remember it. Repeat calls return
|
|
* immediately once loaded; a failed load is not cached, so a later call
|
|
* retries. Concurrent calls share a single load via the module cache.
|
|
*/
|
|
public async loadRenderer(): Promise<void> {
|
|
if (this._renderer) {
|
|
return;
|
|
}
|
|
const module = await import('ha-nunjucks/dist');
|
|
this._renderer = module.renderTemplate;
|
|
}
|
|
|
|
public isLoaded(): boolean {
|
|
return !!this._renderer;
|
|
}
|
|
|
|
public renderRecursively = (
|
|
hass: HomeAssistant,
|
|
data: unknown,
|
|
options?: TemplateRenderOptions,
|
|
): unknown => {
|
|
return this._renderTemplateRecursively(
|
|
hass,
|
|
data,
|
|
this._generateTemplateContext(options),
|
|
);
|
|
};
|
|
|
|
// Structure-preserving variant of `renderRecursively`: arrays, records, and
|
|
// primitives keep their shape (only string leaves are rendered), so the
|
|
// caller's type is asserted back unchanged. Callers whose template renders to
|
|
// a *different* type than its input (e.g. a string that yields a boolean)
|
|
// must use `renderRecursively` and narrow the `unknown` result at runtime.
|
|
public renderRecursivelyAsType = <T>(
|
|
hass: HomeAssistant,
|
|
data: T,
|
|
options?: TemplateRenderOptions,
|
|
): T => this.renderRecursively(hass, data, options) as T;
|
|
|
|
private _generateTemplateContext(
|
|
options?: TemplateRenderOptions,
|
|
): TemplateContext | undefined {
|
|
if (
|
|
!options?.conditionState?.camera &&
|
|
!options?.conditionState?.view &&
|
|
!options?.conditionState?.config &&
|
|
!options?.triggerData &&
|
|
!options?.mediaData
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const acc: TemplateACCNamespace = {
|
|
...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
|
|
...(options?.conditionState?.view && { view: options.conditionState.view }),
|
|
...(options?.conditionState?.config && { config: options.conditionState.config }),
|
|
...(options?.mediaData && { media: options.mediaData }),
|
|
};
|
|
|
|
return {
|
|
acc,
|
|
...(options?.triggerData && { trigger: options.triggerData }),
|
|
};
|
|
}
|
|
|
|
private _renderTemplateRecursively(
|
|
hass: HomeAssistant,
|
|
data: unknown,
|
|
templateContext?: TemplateContext,
|
|
): unknown {
|
|
if (typeof data === 'string') {
|
|
if (!TemplateManager._containsTemplate(data)) {
|
|
return data;
|
|
}
|
|
|
|
if (!this._renderer) {
|
|
// A defensive guard that should not be reached: the renderer is loaded
|
|
// during mandatory initialization before the view, triggers, or actions
|
|
// render, and the condition/trigger evaluators that can run earlier
|
|
// check `isLoaded()` first and defer rather than calling in here.
|
|
this.loadRenderer().catch(() => {});
|
|
return data;
|
|
}
|
|
|
|
return this._renderer(
|
|
// ha-nunjucks has a more complete model of the Home Assistant object, but
|
|
// does not export it as a type.
|
|
hass as unknown as Parameters<RenderTemplate>[0],
|
|
data,
|
|
templateContext,
|
|
);
|
|
} else if (Array.isArray(data)) {
|
|
return data.map((item) =>
|
|
this._renderTemplateRecursively(hass, item, templateContext),
|
|
);
|
|
} else if (isRecord(data)) {
|
|
const result = {};
|
|
for (const key in data) {
|
|
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext);
|
|
}
|
|
return result;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Whether a string contains a nunjucks template that needs rendering. It does
|
|
* only if it has a matching pair of markers (`{{ … }}` or `{% … %}`); this is
|
|
* the same check `ha-nunjucks` makes, so a string without them renders the
|
|
* same whether or not the renderer has loaded -- which is what lets a card
|
|
* that uses no templates avoid loading the renderer at all.
|
|
*/
|
|
private static _containsTemplate(value: string): boolean {
|
|
return (
|
|
(value.includes('{{') && value.includes('}}')) ||
|
|
(value.includes('{%') && value.includes('%}'))
|
|
);
|
|
}
|
|
}
|