mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* 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>
151 lines
5.9 KiB
JavaScript
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'));
|
|
});
|
|
});
|