mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
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>
This commit is contained in:
co-authored by
Nils Kanevad
Paul Bakaus
Claude
parent
e6f3ce6d9a
commit
5d719a279a
@@ -16,8 +16,10 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
|
||||
import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs';
|
||||
import { withSourceLockSync } from './live/source-lock.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
@@ -26,7 +28,6 @@ import {
|
||||
removeSvelteComponentSession,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
const ACCEPT_LOCK_WAIT_MS = 1_000;
|
||||
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
|
||||
// value arriving over HTTP.
|
||||
@@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `.impeccable` is the critical entry, and it is not cosmetic.
|
||||
*
|
||||
* Progressive publication stages each revision as `.impeccable/live/artifacts/
|
||||
* <id>-r<n>.<source-ext>`, and those artifacts carry the very marker this search
|
||||
* looks for. The walk reaches `.` for any project whose source is not under one
|
||||
* of the privileged dirs above (this repo's own site lives in `site/pages/`), and
|
||||
* dot-directories sort before letters, so the artifact was found *before* the
|
||||
* real file. isGeneratedFile then declined the accept, and the agent fell back to
|
||||
* carbonizing several hundred lines of stylesheet by hand.
|
||||
*
|
||||
* Impeccable's own state directory is never project source. Never search it.
|
||||
* Accept also skips `dist` / `build` outright, where wrap descends into them so
|
||||
* its `includeGenerated` second pass can report a `generatedMatch`. Accept has
|
||||
* no such pass: a marker found in build output is only ever a stale copy of the
|
||||
* marker in source.
|
||||
*/
|
||||
const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]);
|
||||
const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build'];
|
||||
|
||||
function findSessionFile(id, cwd) {
|
||||
const marker = 'impeccable-variants-start ' + id;
|
||||
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
|
||||
const seen = new Set();
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
const absDir = path.join(cwd, dir);
|
||||
if (!fs.existsSync(absDir)) continue;
|
||||
const result = searchDir(absDir, marker, seen, 0);
|
||||
if (result) {
|
||||
const content = fs.readFileSync(result, 'utf-8');
|
||||
return { file: result, content, lines: content.split('\n') };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function searchDir(dir, query, seen, depth) {
|
||||
if (depth > 5) return null;
|
||||
let realDir;
|
||||
try { realDir = fs.realpathSync(dir); } catch { return null; }
|
||||
if (seen.has(realDir)) return null;
|
||||
seen.add(realDir);
|
||||
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return null; }
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (content.includes(query)) return filePath;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (SEARCH_SKIP_DIRS.has(entry.name)) continue;
|
||||
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
const result = findSourceFile({
|
||||
query: 'impeccable-variants-start ' + id,
|
||||
cwd,
|
||||
extensions: resolveLiveTemplateExtensions(cwd),
|
||||
skipDirs: SEARCH_SKIP_DIRS,
|
||||
});
|
||||
if (!result) return null;
|
||||
const content = fs.readFileSync(result, 'utf-8');
|
||||
return { file: result, content, lines: content.split('\n') };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user