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)_
134 lines
3.3 KiB
TypeScript
134 lines
3.3 KiB
TypeScript
import { sub } from 'date-fns';
|
|
|
|
import type { ConditionState } from '../../../condition-trigger/conditions/types';
|
|
import type {
|
|
DateMatcher,
|
|
Matcher,
|
|
StartDateMatcher,
|
|
TemplateMatcher,
|
|
TitleMatcher,
|
|
} from '../../../config/schema/folders';
|
|
import type {
|
|
BrowseMediaMetadata,
|
|
RichBrowseMedia,
|
|
} from '../../../ha/browse-media/types';
|
|
import type { HomeAssistant } from '../../../ha/types';
|
|
import { regexpExtract } from '../../../utils/regexp-extract';
|
|
import type { TemplateRenderer } from '../../templates';
|
|
import { REGEXP_GROUP_VALUE_KEY } from './types';
|
|
|
|
export class MediaMatcher {
|
|
private _templateRenderer: TemplateRenderer;
|
|
|
|
constructor(templateRenderer: TemplateRenderer) {
|
|
this._templateRenderer = templateRenderer;
|
|
}
|
|
|
|
public match(
|
|
hass: HomeAssistant,
|
|
media: RichBrowseMedia<BrowseMediaMetadata>,
|
|
options?: {
|
|
foldersOnly?: boolean;
|
|
matchers?: Matcher[];
|
|
conditionState?: ConditionState;
|
|
},
|
|
): boolean {
|
|
if (options?.foldersOnly && !media.can_expand) {
|
|
return false;
|
|
}
|
|
|
|
for (const matcher of options?.matchers ?? []) {
|
|
switch (matcher.type) {
|
|
case 'date':
|
|
case 'startdate':
|
|
if (!this._matchStartDate(matcher, media)) {
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case 'template':
|
|
if (!this._matchTemplate(hass, matcher, media, options?.conditionState)) {
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case 'title':
|
|
if (!this._matchTitle(matcher, media)) {
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case 'or':
|
|
if (
|
|
!matcher.matchers.some((subMatcher) =>
|
|
this.match(hass, media, {
|
|
foldersOnly: options?.foldersOnly,
|
|
matchers: [subMatcher],
|
|
conditionState: options?.conditionState,
|
|
}),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private _matchStartDate(
|
|
matcher: DateMatcher | StartDateMatcher,
|
|
media: RichBrowseMedia<BrowseMediaMetadata>,
|
|
): boolean {
|
|
const startDate = media._metadata?.startDate;
|
|
return (
|
|
!!startDate &&
|
|
startDate >=
|
|
sub(new Date(), {
|
|
years: matcher.since.years ?? 0,
|
|
months: matcher.since.months ?? 0,
|
|
days: matcher.since.days ?? 0,
|
|
hours: matcher.since.hours ?? 0,
|
|
minutes: matcher.since.minutes ?? 0,
|
|
})
|
|
);
|
|
}
|
|
|
|
private _matchTemplate(
|
|
hass: HomeAssistant,
|
|
matcher: TemplateMatcher,
|
|
media: RichBrowseMedia<BrowseMediaMetadata>,
|
|
conditionState?: ConditionState,
|
|
): boolean {
|
|
return (
|
|
this._templateRenderer.renderRecursively(hass, matcher.value_template, {
|
|
conditionState,
|
|
mediaData: {
|
|
title: media.title,
|
|
is_folder: media.can_expand,
|
|
},
|
|
}) === true
|
|
);
|
|
}
|
|
|
|
private _matchTitle(
|
|
matcher: TitleMatcher,
|
|
media: RichBrowseMedia<BrowseMediaMetadata>,
|
|
): boolean {
|
|
const valueToMatch = matcher.regexp
|
|
? regexpExtract(matcher.regexp, media.title, { groupName: REGEXP_GROUP_VALUE_KEY })
|
|
: media.title;
|
|
|
|
if (!valueToMatch) {
|
|
return false;
|
|
}
|
|
|
|
if (matcher.title) {
|
|
return valueToMatch === matcher.title;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|