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>
107 lines
4.6 KiB
JavaScript
107 lines
4.6 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import { NEVER_SOURCE_DIRS, SOURCE_SEARCH_DIRS, findSourceFile } from '../skill/scripts/live/source-search.mjs';
|
|
import { LIVE_TEMPLATE_EXTENSIONS } from '../skill/scripts/lib/template-extensions.mjs';
|
|
|
|
const EXTS = LIVE_TEMPLATE_EXTENSIONS;
|
|
|
|
describe('live source-search — search roots', () => {
|
|
it('puts lib/ ahead of the catch-all . walk', () => {
|
|
assert.ok(SOURCE_SEARCH_DIRS.includes('lib'));
|
|
assert.ok(SOURCE_SEARCH_DIRS.indexOf('lib') < SOURCE_SEARCH_DIRS.indexOf('.'));
|
|
});
|
|
|
|
it('never treats impeccable state as project source', () => {
|
|
assert.ok(NEVER_SOURCE_DIRS.includes('.impeccable'));
|
|
assert.ok(NEVER_SOURCE_DIRS.includes('node_modules'));
|
|
assert.ok(NEVER_SOURCE_DIRS.includes('.git'));
|
|
});
|
|
});
|
|
|
|
describe('live source-search — findSourceFile', () => {
|
|
let tmp;
|
|
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-src-search-')); });
|
|
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
const write = (rel, body) => {
|
|
const abs = join(tmp, rel);
|
|
mkdirSync(join(abs, '..'), { recursive: true });
|
|
writeFileSync(abs, body);
|
|
return abs;
|
|
};
|
|
|
|
it('finds a Phoenix ~H template inside lib/', () => {
|
|
const abs = write('lib/my_app_web/components/layouts.ex', 'def nav(a) do\n ~H"""\n <nav class="topbar">x</nav>\n """\nend\n');
|
|
assert.equal(findSourceFile({ query: 'topbar', cwd: tmp, extensions: EXTS }), abs);
|
|
});
|
|
|
|
it('finds a .html.heex template', () => {
|
|
const abs = write('lib/my_app_web/controllers/page_html/home.html.heex', '<section class="hero">x</section>\n');
|
|
assert.equal(findSourceFile({ query: 'hero', cwd: tmp, extensions: EXTS }), abs);
|
|
});
|
|
|
|
it('skips .impeccable artifacts that carry the same marker', () => {
|
|
// The bug this guards: staged revision artifacts hold the marker too, and
|
|
// dot-directories sort before letters in the `.` walk.
|
|
write('.impeccable/live/artifacts/abc-r1.html', '<!-- impeccable-variants-start abc -->\n');
|
|
const real = write('views/home.html', '<!-- impeccable-variants-start abc -->\n');
|
|
assert.equal(findSourceFile({ query: 'impeccable-variants-start abc', cwd: tmp, extensions: EXTS }), real);
|
|
});
|
|
|
|
it('never descends into node_modules', () => {
|
|
write('node_modules/pkg/dist/index.html', '<div class="needle">x</div>\n');
|
|
assert.equal(findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS }), null);
|
|
});
|
|
|
|
it('honours an extra skipDirs entry', () => {
|
|
write('build/index.html', '<div class="needle">x</div>\n');
|
|
assert.equal(
|
|
findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS, skipDirs: [...NEVER_SOURCE_DIRS, 'build'] }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('honours fileFilter rejections', () => {
|
|
write('src/index.html', '<div class="needle">x</div>\n');
|
|
assert.equal(
|
|
findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS, fileFilter: () => false }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('ignores files whose extension is not a template', () => {
|
|
write('src/notes.md', 'needle\n');
|
|
assert.equal(findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS }), null);
|
|
});
|
|
|
|
it('prefers a privileged root over the catch-all walk', () => {
|
|
const preferred = write('src/page.html', '<div class="needle">a</div>\n');
|
|
write('misc/page.html', '<div class="needle">b</div>\n');
|
|
assert.equal(findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS }), preferred);
|
|
});
|
|
|
|
it('survives a broken symlink instead of throwing', () => {
|
|
// live-wrap's copy of this walk called realpathSync unguarded, so one dangling
|
|
// link anywhere in the tree took down the whole wrap.
|
|
mkdirSync(join(tmp, 'src'), { recursive: true });
|
|
symlinkSync(join(tmp, 'does-not-exist'), join(tmp, 'src', 'dangling'));
|
|
const abs = write('src/page.html', '<div class="needle">x</div>\n');
|
|
assert.equal(findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS }), abs);
|
|
});
|
|
|
|
it('does not loop on a self-referential symlink', () => {
|
|
mkdirSync(join(tmp, 'src', 'inner'), { recursive: true });
|
|
symlinkSync(join(tmp, 'src'), join(tmp, 'src', 'inner', 'loop'));
|
|
assert.equal(findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS }), null);
|
|
});
|
|
|
|
it('returns null when nothing matches', () => {
|
|
write('src/page.html', '<div class="other">x</div>\n');
|
|
assert.equal(findSourceFile({ query: 'needle', cwd: tmp, extensions: EXTS }), null);
|
|
});
|
|
});
|