Files
houseplan-card/scripts/bundle-manifest.mjs
T
Codex d21fba11d6 feat(card): the LED strip tool in the Devices editor (#780)
Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with
clean clicks only (pan, pinch, a second finger, cancel and the synthetic
click after navigation add nothing), snaps to the grid and to physical wall
faces / zero-wall axes, stops at the first face of masonry, partitions,
columns and windows (doors, gates and passages are cut by geometry), Shift
gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a
double click or a click on the first point (≥3 vertices) closes; fewer than
two distinct points write nothing. A finished strip opens the device picker
(lights first, taken markers explained, «New device…» binds in that
dialog's own write, «Later» keeps unbound geometry).

A selected strip shows its vertex handles (a drag re-checks both neighbours
and the whole path) and a new Devices tray branch: device settings,
bind/change, unbind, show as icon, delete. The device dialog gets the
representation section: show as strip (restores a hidden shape at once or
draws one for this marker, behind the dialog's own save/discard guard),
show as icon, unbind/delete for a hidden shape. Every geometry or
representation change is one optimistic write and one LED command of the
device history; Undo/Redo restores only its own strip record and refuses
when a newer change sits on it. A rebinding renames the link in the marker
save, a deleted marker leaves an unbound strip, a bound marker may not move
to another space. Plan/Background show strips as a passive translucent mark.

The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement
geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only
on the tool, an editable strip in the shown space or a strip device's
dialog. The initial graph gets the loader and delegation only
(src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling.
Mutant anchors follow the moved code (marker dialog guard, static LED layer).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00

544 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { dirname, posix, relative, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const ISO_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ISO_RETRY_ASSET__';
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
const LED_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_RETRY_ASSET__';
const LED_FIELD_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_FIELD_RETRY_ASSET__';
const LED_EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_EDITOR_RETRY_ASSET__';
/**
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
* namespace × language. Their loader modules are the recognition marker for
* the manifest graph and the target of each second-attempt retry token.
*/
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools', 'led']
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
namespace,
language,
module: `/src/i18n/${namespace}/${namespace}-${language}.ts`,
token: `__HOUSEPLAN_${namespace.toUpperCase()}_${language.toUpperCase()}_RETRY_ASSET__`,
})));
const isNamespaceLocaleModule = (id) => NAMESPACE_LOCALE_CHUNKS
.some((chunk) => id.endsWith(chunk.module));
export const CARD_ENTRY_FILE = 'houseplan-card.js';
export const PANEL_ENTRY_FILE = 'houseplan-panel.js';
const ENTRY_CONTRACTS = [
{
fileName: CARD_ENTRY_FILE,
facade: 'src/houseplan-card.ts',
element: 'houseplan-card',
cardFallback: true,
},
{
fileName: PANEL_ENTRY_FILE,
facade: 'src/houseplan-panel.ts',
element: 'houseplan-panel',
cardFallback: false,
},
];
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const normalizedId = (value) => String(value || '').replaceAll('\\', '/');
const hasExactFacade = (chunk, facade) => {
const id = normalizedId(chunk.facadeModuleId);
return id === facade || id.endsWith(`/${facade}`);
};
const exactEntryChunk = (bundle, contract) => {
const matches = Object.values(bundle).filter((item) => item.type === 'chunk'
&& item.isEntry
&& normalizedId(item.fileName) === contract.fileName);
if (matches.length !== 1) {
throw new Error(
`${contract.fileName} entry count is ${matches.length}, expected exactly 1`,
);
}
if (!hasExactFacade(matches[0], contract.facade)) {
throw new Error(
`${contract.fileName} facade is ${normalizedId(matches[0].facadeModuleId) || 'missing'},`
+ ` expected ${contract.facade}`,
);
}
return matches[0];
};
const reachable = (entry, byPath, edge) => {
const seen = new Set();
const visit = (path) => {
if (seen.has(path)) return;
const file = byPath.get(path);
if (!file) throw new Error(`bundle manifest references missing asset: ${path}`);
seen.add(path);
for (const child of file[edge] || []) visit(child);
};
visit(entry);
return seen;
};
export function buildBundleManifest(bundle, fingerprint) {
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
const entries = chunks.filter((item) => item.isEntry);
if (entries.length !== ENTRY_CONTRACTS.length) {
throw new Error(`bundle entry count is ${entries.length}, expected ${ENTRY_CONTRACTS.length}`);
}
const cardEntry = exactEntryChunk(bundle, ENTRY_CONTRACTS[0]);
const panelEntry = exactEntryChunk(bundle, ENTRY_CONTRACTS[1]);
const files = chunks
.map((chunk) => {
const contents = Buffer.from(chunk.code, 'utf8');
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts') || id.endsWith('/src/i18n/fr.ts'))
? 'locale'
: modules.some(isNamespaceLocaleModule)
? 'namespace-locale'
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
? 'onboarding'
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
? 'editor'
: modules.some((id) => id.endsWith('/src/iso-scene-render.ts'))
? 'isometric'
: modules.some((id) => id.endsWith('/src/pdf/pdf-export.ts'))
? 'pdf'
// #661: the moon's math, art and template — loaded only at night.
: modules.some((id) => id.endsWith('/src/moon-runtime.ts'))
? 'moon'
// #780: LED strip geometry, light field and hit paths — loaded
// only when a shown space has an active strip.
: modules.some((id) => id.endsWith('/src/led-strip-runtime.ts'))
? 'led'
// #780: the linear field, loaded by the LED runtime only when a
// strip is on in a Glow room with a light scene.
: modules.some((id) => id.endsWith('/src/led-strip-field.ts'))
? 'led-field'
// #780: the Devices-editor LED tool, loaded by the editor runtime.
: modules.some((id) => id.endsWith('/src/led-strip-editor.ts'))
? 'led-editor'
// #474: designer furniture artwork — its own lazy chunk, shared by
// the editor (static import) and the View runtime (dynamic).
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
? 'furniture-art'
: undefined;
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
rawBytes: contents.length,
gzipBytes: gzipSync(contents, { level: 9 }).length,
isEntry: chunk.isEntry,
imports: [...chunk.imports].sort(),
dynamicImports: [...chunk.dynamicImports].sort(),
_role: role,
};
})
.sort((left, right) => left.path.localeCompare(right.path));
const entry = normalizedId(cardEntry.fileName);
const panelEntryPath = normalizedId(panelEntry.fileName);
const byPath = new Map(files.map((file) => [file.path, file]));
const initial = reachable(entry, byPath, 'imports');
const initialPanel = reachable(panelEntryPath, byPath, 'imports');
const initialPanelOnly = new Set(
[...initialPanel].filter((path) => !initial.has(path)),
);
const dynamicRoots = [...initial]
.flatMap((path) => byPath.get(path)?.dynamicImports || []);
const lazy = new Set();
for (const root of dynamicRoots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
}
const localeRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'locale'
|| /(?:^|\/)(?:de|fr)-[^/]+\.js$/.test(path));
const onboardingRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'onboarding'
|| path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => (byPath.get(path)?._role === 'editor'
|| /(?:^|\/)editor(?:-[^/]+)?\.js$/.test(path))
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
const isometricRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'isometric'
|| path.includes('iso-scene-render-'));
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf'
|| path.includes('pdf-export-'));
const moonRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'moon'
|| path.includes('moon-runtime-'));
// #780: the field chunk is a dynamic import of the LED chunk, not of the
// initial graph, so it is found by its module, like namespace dictionaries.
const ledRoots = [
...dynamicRoots.filter((path) => byPath.get(path)?._role === 'led'
|| path.includes('led-strip-runtime-')),
...files.filter((file) => file._role === 'led-field').map((file) => file.path),
];
const ledEditorRoots = files.filter((file) => file._role === 'led-editor').map((file) => file.path);
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
// the initial graph, so they are found by their module, not as a root.
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
.map((file) => file.path);
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) graph.add(path);
}
return graph;
};
const lazyEditor = graphFrom(editorRoots);
const lazyOnboarding = graphFrom(onboardingRoots);
const lazyLocale = graphFrom(localeRoots);
const lazyIsometric = graphFrom(isometricRoots);
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const lazyPdf = graphFrom(pdfRoots);
const lazyMoon = graphFrom(moonRoots);
const lazyLed = graphFrom(ledRoots);
const lazyLedEditor = graphFrom(ledEditorRoots);
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
schema: 1,
fingerprint,
entry,
panelEntry: panelEntryPath,
initialViewFiles: [...initial].sort(),
initialViewGzipBytes: sum(initial),
initialPanelFiles: [...initialPanel].sort(),
initialPanelGzipBytes: sum(initialPanel),
initialPanelOnlyFiles: [...initialPanelOnly].sort(),
initialPanelOnlyGzipBytes: sum(initialPanelOnly),
lazyFiles: [...lazy].sort(),
lazyGzipBytes: sum(lazy),
lazyEditorFiles: [...lazyEditor].sort(),
lazyEditorGzipBytes: sum(lazyEditor),
lazyOnboardingFiles: [...lazyOnboarding].sort(),
lazyOnboardingGzipBytes: sum(lazyOnboarding),
lazyLocaleFiles: [...lazyLocale].sort(),
lazyLocaleGzipBytes: sum(lazyLocale),
lazyIsometricFiles: [...lazyIsometric].sort(),
lazyIsometricGzipBytes: sum(lazyIsometric),
lazyFurnitureArtFiles: [...lazyFurnitureArt].sort(),
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
lazyPdfFiles: [...lazyPdf].sort(),
lazyPdfGzipBytes: sum(lazyPdf),
lazyMoonFiles: [...lazyMoon].sort(),
lazyMoonGzipBytes: sum(lazyMoon),
lazyLedFiles: [...lazyLed].sort(),
lazyLedGzipBytes: sum(lazyLed),
lazyLedEditorFiles: [...lazyLedEditor].sort(),
lazyLedEditorGzipBytes: sum(lazyLedEditor),
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
files: files.map(({ _role, ...file }) => file),
};
}
export function bundleManifestPlugin(fingerprint) {
return {
name: 'houseplan-bundle-manifest',
generateBundle(_options, bundle) {
const manifest = buildBundleManifest(bundle, fingerprint);
this.emitFile({
type: 'asset',
fileName: 'houseplan-assets.json',
source: `${JSON.stringify(manifest, null, 2)}\n`,
});
},
};
}
/** Embed one immutable build id in both sides of the lazy handshake. */
export function buildFingerprintPlugin(fingerprint) {
return {
name: 'houseplan-build-fingerprint',
transform(code, id) {
if (!id.replaceAll('\\', '/').includes('/src/')
|| !code.includes(BUILD_FINGERPRINT_TOKEN)) return null;
return {
code: code.replaceAll(BUILD_FINGERPRINT_TOKEN, fingerprint),
map: null,
};
},
};
}
/**
* Point the second import attempt at the exact content-hashed editor chunk.
* Rollup owns that filename, so the URL is injected only after chunk naming;
* the first import remains a normal Rollup edge for graph accounting.
*/
export function editorRuntimeRetryUrlPlugin() {
return {
name: 'houseplan-editor-retry-url',
generateBundle(_options, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
const editor = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
const isometric = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/iso-scene-render.ts')));
const german = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
const french = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/fr.ts')));
const furnitureArt = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts')));
const pdf = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/pdf/pdf-export.ts')));
const moon = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/moon-runtime.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!isometric) throw new Error('isometric runtime chunk was not emitted');
if (!german) throw new Error('German locale chunk was not emitted');
if (!french) throw new Error('French locale chunk was not emitted');
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
if (!moon) throw new Error('moon runtime chunk was not emitted');
const led = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-runtime.ts')));
if (!led) throw new Error('LED strip runtime chunk was not emitted');
const ledField = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-field.ts')));
if (!ledField) throw new Error('LED strip field chunk was not emitted');
const ledEditor = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-editor.ts')));
if (!ledEditor) throw new Error('LED strip editor chunk was not emitted');
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
if (!chunk) {
throw new Error(`${entry.namespace} ${entry.language} locale chunk was not emitted`);
}
return { ...entry, chunk, replacements: 0 };
});
let furnitureArtReplacements = 0;
let editorReplacements = 0;
let onboardingReplacements = 0;
let isometricReplacements = 0;
let germanReplacements = 0;
let frenchReplacements = 0;
let pdfReplacements = 0;
let moonReplacements = 0;
let ledReplacements = 0;
let ledFieldReplacements = 0;
let ledEditorReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
editorReplacements += chunk.code.split(EDITOR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(EDITOR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(ONBOARDING_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), onboarding.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(ISO_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), isometric.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
isometricReplacements += chunk.code.split(ISO_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ISO_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(DE_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), german.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
germanReplacements += chunk.code.split(DE_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(DE_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(FR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), french.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
frenchReplacements += chunk.code.split(FR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(FR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(FURNITURE_ART_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), furnitureArt.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
furnitureArtReplacements += chunk.code.split(FURNITURE_ART_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(FURNITURE_ART_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(PDF_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), pdf.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(MOON_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), moon.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
moonReplacements += chunk.code.split(MOON_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(MOON_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(LED_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), led.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(LED_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(LED_EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), ledEditor.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
ledEditorReplacements += chunk.code.split(LED_EDITOR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(LED_EDITOR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(LED_FIELD_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), ledField.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
ledFieldReplacements += chunk.code.split(LED_FIELD_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(LED_FIELD_RETRY_ASSET_TOKEN, asset);
}
for (const entry of namespaceLocales) {
if (!chunk.code.includes(entry.token)) continue;
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
entry.replacements += chunk.code.split(entry.token).length - 1;
chunk.code = chunk.code.replaceAll(entry.token, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1
|| ledFieldReplacements !== 1 || ledEditorReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}/${ledEditorReplacements}, expected 1/1/1/1/1/1/1/1/1/1/1`);
}
// #627: the same strict rule for every namespace × language token —
// exactly one second-attempt URL each, never zero and never two.
const namespaceMismatch = namespaceLocales.filter((entry) => entry.replacements !== 1);
if (namespaceMismatch.length) {
throw new Error('namespace locale retry URL placeholder counts are '
+ namespaceMismatch.map((entry) => `${entry.namespace}-${entry.language}=${entry.replacements}`).join(', ')
+ ', expected exactly 1 each');
}
},
};
}
/**
* Rewrite both stable entry facades so a stale cached entry fails loudly
* instead of killing the card or panel silently (#353 K3, #486). Rollup emits
* the card facade as a STATIC re-export of a content-hashed implementation
* chunk, while the panel entry statically imports that same shared chunk.
* After an update a proxy-cached entry can point at a chunk the manifest-gated
* server no longer serves, and a static import failure would abort the whole
* module before any code runs.
*
* The rewrite keeps the happy path intact via top-level await: an importer's
* `await import(entry)` does not resolve until the inner import settles, so
* `customElements.define` of the real card still happens-before the importer
* continues — demo, smokes and golden run unchanged. Only the failure branch
* is new: it defines a minimal fallback element with a human message.
*
* Must run BEFORE bundleManifestPlugin so the manifest hashes the final code.
*/
export function entryFallbackPlugin() {
const fallbackDefinition = (contract) => `if(!customElements.get("${contract.element}")){`
+ 'const l=String(navigator.language||"en").toLowerCase();'
+ 'const m=l.startsWith("ru")'
+ '?"House Plan обновился — перезагрузите страницу (Ctrl+F5)."'
+ ':l.startsWith("de")'
+ '?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5)."'
+ ':l.startsWith("fr")'
+ '?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5)."'
+ ':"House Plan was updated — please reload the page (Ctrl+F5).";'
+ `customElements.define("${contract.element}",class extends HTMLElement{`
+ (contract.cardFallback ? 'setConfig(){}getCardSize(){return 1}' : '')
+ 'connectedCallback(){'
+ 'this.style.cssText="display:block;box-sizing:border-box;padding:16px;'
+ (contract.cardFallback
? 'border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);'
: 'min-height:100%;')
+ 'background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);'
+ 'font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";'
+ 'this.textContent=m}})}';
return {
name: 'houseplan-entry-fallback',
generateBundle(_options, bundle) {
const cardContract = ENTRY_CONTRACTS[0];
const cardEntry = exactEntryChunk(bundle, cardContract);
const cardPattern = /export\{[^}]*\}from(["'])(\.\/houseplan-assets\/[^"']+\.js)\1;?/g;
const cardMatches = [...cardEntry.code.matchAll(cardPattern)];
if (cardMatches.length !== 1) {
throw new Error(
`${cardContract.fileName} facade re-export count is ${cardMatches.length}, expected 1`,
);
}
const cardAsset = cardMatches[0][2];
cardEntry.code = cardEntry.code.replace(
cardPattern,
`try{await import("${cardAsset}")}`
+ `catch(e){${fallbackDefinition(cardContract)}`
+ 'console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}',
);
// Rollup folds the small panel shell into its stable entry and points its
// side-effect import directly at the shared card implementation. Keep that
// edge (#535). Routing it through the stable card facade instead used to
// look harmless — the chunk is the same URL either way, so no second copy
// arises — but the facade is the ONE address in the distribution with no
// version in it: a dashboard reaches the same file through the Lovelace
// resource's `?v=`, while a relative specifier cannot inherit that query.
// Entries are served without Cache-Control (only ETag/Last-Modified), so
// a browser may keep its copy for hours; a stale panel entry then pulled
// a stale chunk — both cached — and silently ran a previous card against
// the current backend, with nothing but the version banner to show for it.
// The hashed name changes with the content, so now either the matching
// implementation arrives or the panel's own fallback says so out loud.
const panelContract = ENTRY_CONTRACTS[1];
const panelEntry = exactEntryChunk(bundle, panelContract);
const panelPattern = new RegExp(
`import(["'])${cardAsset.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1;?`,
'g',
);
const panelMatches = [...panelEntry.code.matchAll(panelPattern)];
if (panelMatches.length !== 1) {
throw new Error(
`${panelContract.fileName} card import count is ${panelMatches.length}, expected 1`,
);
}
panelEntry.code = panelEntry.code.replace(
panelPattern,
`try{await import("${cardAsset}")}`
+ `catch(e){${fallbackDefinition(panelContract)}`
+ 'console.error("[houseplan] stale houseplan-panel.js: the card implementation '
+ 'chunk is unavailable",e)}',
);
panelEntry.imports = [cardAsset.replace(/^\.\//, '')];
},
};
}
/** Remove only files named by the previous generated manifest. */
export function cleanBundleOutputPlugin(outputRoot = 'dist') {
return {
name: 'houseplan-clean-bundle-output',
buildStart() {
const root = resolve(outputRoot);
const manifestPath = resolve(root, 'houseplan-assets.json');
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
for (const file of manifest?.files || []) {
const path = resolve(root, String(file.path || ''));
const rel = relative(root, path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`previous bundle path escapes output root: ${file.path}`);
}
rmSync(path, { force: true });
}
rmSync(manifestPath, { force: true });
},
};
}