Files
pbakaus_impeccable/tests/template-extensions.test.mjs
T
5d719a279a Fix Live accept for Elixir templates in lib/ (#374)
* Fix Live accept for Elixir templates in lib/

Wrap and accept search the repo for impeccable variant markers. That
search skipped .ex files and the lib/ tree, so Phoenix LiveView markup
inside ~H""" blocks never matched and browser Accept returned
"Session markers not found".

Extend the same EXTENSIONS and searchDirs in live-accept.mjs and
live-wrap.mjs. Add a regression test that accepts from
lib/my_app_web/components/layouts.ex.

* Live: give the source search one owner for template extensions

The #374 fix had to patch the same hardcoded EXTENSIONS array in two
files because live-wrap.mjs and live-accept.mjs each carried their own
copy of the project source walk. The copies had already drifted: same
extension list twice, same searchDirs twice, and one realpathSync
guarded by try/catch while the other was not.

Meanwhile hook-lib.mjs had solved this properly for the design hook in
#316/#347 with a configurable `detector.extensions` and suffix matching
that handles .blade.php and .html.erb. Live never read it, so a project
that taught the hook about .heex still got 'Session markers not found'
on Accept.

- lib/template-extensions.mjs is the single owner. It holds Live's
  built-in markup list, the suffix matcher, and the detector.extensions
  config reader. hook-lib.mjs now imports its normalize/merge/match
  helpers from here instead of duplicating them, and re-exports
  matchConfiguredExtension for its existing callers.
- Live resolves built-ins PLUS detector.extensions, so teaching the hook
  about a server template teaches wrap and accept at the same time.
- live/source-search.mjs holds the walk both scripts share. Callers pass
  the one thing that actually differs (skipDirs, fileFilter). Unifying
  gives live-wrap the guarded realpathSync, so a dangling symlink in the
  tree no longer throws out of the whole wrap, and makes it skip
  .impeccable artifacts the way accept already did.
- Extensions are matched on filename suffix rather than path.extname, so
  root.html.heex and show.html.erb resolve.
- Drop .exs. Those are Elixir scripts (mix.exs, config/*.exs), never
  markup, and including them only lets a wrap query match build config.
- Fill the Elixir gap in the manual-edit paths, which kept their own
  allowlists and would have left Live half-working for Phoenix:
  live-commit-manual-edits.mjs and live-manual-edit-evidence.mjs.

Verified the round trip by hand against a Phoenix layout: wrap injects
markers into a ~H""" block in lib/**/*.ex, accept carbonizes the chosen
variant back out.

AI assistance: written with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Nils Kanevad <heliumbrain@users.noreply.github.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 10:49:36 -07:00

151 lines
5.9 KiB
JavaScript

import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
LIVE_TEMPLATE_EXTENSIONS,
clearTemplateExtensionCache,
matchConfiguredExtension,
matchesTemplateExtension,
resolveLiveTemplateExtensions,
} from '../skill/scripts/lib/template-extensions.mjs';
import { matchConfiguredExtension as fromHookLib } from '../skill/scripts/hook-lib.mjs';
describe('template-extensions — built-in list', () => {
it('covers the frontend defaults plus Elixir markup', () => {
for (const ext of ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.ex', '.heex', '.eex']) {
assert.ok(LIVE_TEMPLATE_EXTENSIONS.includes(ext), `expected ${ext}`);
}
});
it('leaves .exs out — those are Elixir scripts, not templates', () => {
assert.ok(!LIVE_TEMPLATE_EXTENSIONS.includes('.exs'));
assert.ok(!matchesTemplateExtension('mix.exs', LIVE_TEMPLATE_EXTENSIONS));
assert.ok(!matchesTemplateExtension('config/runtime.exs', LIVE_TEMPLATE_EXTENSIONS));
});
});
describe('template-extensions — matchesTemplateExtension', () => {
it('matches on filename suffix, not path.extname', () => {
// extname('show.html.erb') is '.erb', so an extname check would miss this.
assert.ok(matchesTemplateExtension('show.html.erb', ['.html.erb']));
assert.ok(matchesTemplateExtension('lib/app_web/root.html.heex', LIVE_TEMPLATE_EXTENSIONS));
});
it('matches plain extensions and is case-insensitive', () => {
assert.ok(matchesTemplateExtension('Layouts.EX', LIVE_TEMPLATE_EXTENSIONS));
assert.ok(matchesTemplateExtension('index.HTML', LIVE_TEMPLATE_EXTENSIONS));
});
it('rejects a file whose whole name is the extension', () => {
assert.ok(!matchesTemplateExtension('.heex', LIVE_TEMPLATE_EXTENSIONS));
});
it('rejects non-markup files', () => {
assert.ok(!matchesTemplateExtension('main.css', LIVE_TEMPLATE_EXTENSIONS));
assert.ok(!matchesTemplateExtension('README.md', LIVE_TEMPLATE_EXTENSIONS));
});
});
describe('template-extensions — hook-lib parity', () => {
it('hook-lib re-exports the same matchConfiguredExtension', () => {
assert.equal(fromHookLib, matchConfiguredExtension);
});
it('still prefers the longest configured suffix', () => {
const match = matchConfiguredExtension('show.blade.php', ['.php', '.blade.php']);
assert.equal(match.ext, '.blade.php');
assert.equal(match.engine, 'html');
});
it('honours an explicit text engine', () => {
const match = matchConfiguredExtension('mail.txt.erb', [{ ext: '.txt.erb', engine: 'text' }]);
assert.equal(match.engine, 'text');
});
});
describe('template-extensions — resolveLiveTemplateExtensions', () => {
let tmp;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-tmpl-ext-'));
clearTemplateExtensionCache();
});
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
it('returns the built-ins when there is no config', () => {
assert.deepEqual(resolveLiveTemplateExtensions(tmp), [...LIVE_TEMPLATE_EXTENSIONS]);
});
it('folds in detector.extensions so Live inherits what the hook was taught', () => {
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(
join(tmp, '.impeccable', 'config.json'),
JSON.stringify({ detector: { extensions: ['.blade.php', { ext: 'twig' }] } }),
);
const exts = resolveLiveTemplateExtensions(tmp);
assert.ok(exts.includes('.blade.php'));
assert.ok(exts.includes('.twig'), 'a bare string should be normalized to a dotted ext');
});
it('reads config.local.json as well', () => {
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(
join(tmp, '.impeccable', 'config.local.json'),
JSON.stringify({ detector: { extensions: ['.slim'] } }),
);
assert.ok(resolveLiveTemplateExtensions(tmp).includes('.slim'));
});
it('never duplicates a built-in', () => {
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(
join(tmp, '.impeccable', 'config.json'),
JSON.stringify({ detector: { extensions: ['.heex'] } }),
);
const exts = resolveLiveTemplateExtensions(tmp);
assert.equal(exts.filter((e) => e === '.heex').length, 1);
});
it('survives malformed config without throwing', () => {
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(join(tmp, '.impeccable', 'config.json'), '{ not json');
assert.deepEqual(resolveLiveTemplateExtensions(tmp), [...LIVE_TEMPLATE_EXTENSIONS]);
});
it('ignores a detector.extensions that is not an array', () => {
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(
join(tmp, '.impeccable', 'config.json'),
JSON.stringify({ detector: { extensions: '.blade.php' } }),
);
assert.deepEqual(resolveLiveTemplateExtensions(tmp), [...LIVE_TEMPLATE_EXTENSIONS]);
});
});
describe('template-extensions — memoization', () => {
let tmp;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'impeccable-tmpl-cache-'));
clearTemplateExtensionCache();
});
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
it('serves repeat calls from cache', () => {
const first = resolveLiveTemplateExtensions(tmp);
assert.equal(resolveLiveTemplateExtensions(tmp), first, 'same array instance');
});
it('re-reads config after the cache is cleared', () => {
assert.ok(!resolveLiveTemplateExtensions(tmp).includes('.blade.php'));
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(
join(tmp, '.impeccable', 'config.json'),
JSON.stringify({ detector: { extensions: ['.blade.php'] } }),
);
clearTemplateExtensionCache();
assert.ok(resolveLiveTemplateExtensions(tmp).includes('.blade.php'));
});
});