mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +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
+10
-43
@@ -42,6 +42,16 @@ import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { extractPlatform, loadContext } from './context.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
// `detector.extensions` (issue #316) is shared with Live's source search, which
|
||||
// needs the same answer for `.heex` / `.blade.php` when it hunts for session
|
||||
// markers. lib/template-extensions.mjs owns the shape; re-exported here because
|
||||
// hook-lib has been the import site for matchConfiguredExtension since #347.
|
||||
import {
|
||||
matchConfiguredExtension,
|
||||
mergeExtensions,
|
||||
} from './lib/template-extensions.mjs';
|
||||
|
||||
export { matchConfiguredExtension };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// Extra scanned extensions from `detector.extensions` config. Entries are
|
||||
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
|
||||
// for server-side templates) or bare strings as shorthand. Extensions are
|
||||
// matched against the end of the filename, not path.extname, so double
|
||||
// extensions like `.blade.php` and `.html.erb` work (issue #316).
|
||||
function normalizeExtensionEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const raw = typeof entry === 'string' ? entry : entry?.ext;
|
||||
if (typeof raw !== 'string') continue;
|
||||
let ext = raw.trim().toLowerCase();
|
||||
if (!ext) continue;
|
||||
if (!ext.startsWith('.')) ext = `.${ext}`;
|
||||
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
|
||||
out.push({ ext, engine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeExtensions(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
|
||||
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function matchConfiguredExtension(filePath, extensions) {
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return null;
|
||||
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
|
||||
// entry regardless of config order.
|
||||
let best = null;
|
||||
for (const entry of normalizeExtensionEntries(extensions)) {
|
||||
if (name.length > entry.ext.length && name.endsWith(entry.ext)
|
||||
&& (!best || entry.ext.length > best.ext.length)) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function applyConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* One owner for "which file extensions hold UI markup".
|
||||
*
|
||||
* Before this module the answer was spelled out separately in hook-lib.mjs
|
||||
* (`detector.extensions` config, issue #316) and in live-wrap.mjs /
|
||||
* live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both).
|
||||
* The lists drifted: the hook learned configurable server-template extensions
|
||||
* while Live kept its six frontend defaults, so a Phoenix project got design
|
||||
* findings on `.heex` files but `Session markers not found` on Accept (#374).
|
||||
*
|
||||
* Extensions are matched against the END OF THE FILENAME, not `path.extname`,
|
||||
* so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Built-in markup extensions for Live's wrap/accept source search.
|
||||
*
|
||||
* Elixir's `.ex` is here because Phoenix function components put `~H"""`
|
||||
* templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone
|
||||
* templates. `.exs` is deliberately absent: those are Elixir *scripts*
|
||||
* (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them
|
||||
* only gives the wrap query a chance to match build config by accident.
|
||||
*/
|
||||
export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([
|
||||
'.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro',
|
||||
'.ex', '.heex', '.eex',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalize `detector.extensions` entries to `{ ext, engine }`.
|
||||
*
|
||||
* Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' —
|
||||
* the common case for server-side templates) or bare strings as shorthand.
|
||||
*/
|
||||
export function normalizeExtensionEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const raw = typeof entry === 'string' ? entry : entry?.ext;
|
||||
if (typeof raw !== 'string') continue;
|
||||
let ext = raw.trim().toLowerCase();
|
||||
if (!ext) continue;
|
||||
if (!ext.startsWith('.')) ext = `.${ext}`;
|
||||
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
|
||||
out.push({ ext, engine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeExtensions(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
|
||||
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function matchConfiguredExtension(filePath, extensions) {
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return null;
|
||||
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
|
||||
// entry regardless of config order.
|
||||
let best = null;
|
||||
for (const entry of normalizeExtensionEntries(extensions)) {
|
||||
if (name.length > entry.ext.length && name.endsWith(entry.ext)
|
||||
&& (!best || entry.ext.length > best.ext.length)) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this filename end in one of `extensions`?
|
||||
*
|
||||
* Suffix matching rather than `path.extname` equality, so a configured
|
||||
* `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The
|
||||
* `name.length > ext.length` guard keeps a file literally named `.heex` from
|
||||
* counting as a template.
|
||||
*/
|
||||
export function matchesTemplateExtension(filePath, extensions) {
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return false;
|
||||
for (const ext of extensions) {
|
||||
if (name.length > ext.length && name.endsWith(ext)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in Live extensions plus any the project configured for the detector.
|
||||
*
|
||||
* Reading `detector.extensions` here is the point: a user who taught the design
|
||||
* hook about `.blade.php` should not have to teach Live separately. Config
|
||||
* parsing is intentionally minimal (own the shape, not the whole hook config)
|
||||
* so this module stays importable from the Live CLI without pulling in
|
||||
* hook-lib.mjs.
|
||||
*/
|
||||
export function resolveLiveTemplateExtensions(cwd = process.cwd()) {
|
||||
const cached = extensionCache.get(cwd);
|
||||
if (cached) return cached;
|
||||
const resolved = readLiveTemplateExtensions(cwd);
|
||||
extensionCache.set(cwd, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// live-wrap calls the resolver once per candidate query per pass (up to eight
|
||||
// times in one CLI run), and every call would otherwise re-read and re-parse
|
||||
// both config files. Keyed by cwd; a single CLI process never rewrites its own
|
||||
// config mid-run.
|
||||
const extensionCache = new Map();
|
||||
|
||||
/** Test seam: drop the memoized config so a fixture can rewrite config.json. */
|
||||
export function clearTemplateExtensionCache() {
|
||||
extensionCache.clear();
|
||||
}
|
||||
|
||||
function readLiveTemplateExtensions(cwd) {
|
||||
const configured = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = safeReadJson(path.join(cwd, '.impeccable', name));
|
||||
const detector = raw?.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
configured.push(...normalizeExtensionEntries(detector.extensions));
|
||||
}
|
||||
}
|
||||
const seen = new Set(LIVE_TEMPLATE_EXTENSIONS);
|
||||
const out = [...LIVE_TEMPLATE_EXTENSIONS];
|
||||
for (const { ext } of configured) {
|
||||
if (seen.has(ext)) continue;
|
||||
seen.add(ext);
|
||||
out.push(ext);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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') };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([
|
||||
'.astro',
|
||||
'.cjs',
|
||||
'.css',
|
||||
'.eex',
|
||||
'.ex',
|
||||
'.heex',
|
||||
'.htm',
|
||||
'.html',
|
||||
'.js',
|
||||
|
||||
@@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
|
||||
|
||||
const EVIDENCE_VERSION = 1;
|
||||
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts',
|
||||
// Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex
|
||||
// templates, so copy edits land in all three.
|
||||
'.ex', '.heex', '.eex',
|
||||
]);
|
||||
const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data'];
|
||||
const STRONG_LITERAL_MATCH_LIMIT = 8;
|
||||
const WEAK_LITERAL_MATCH_LIMIT = 4;
|
||||
|
||||
+13
-50
@@ -14,15 +14,15 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import { findSourceFile } from './live/source-search.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
export async function wrapCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) {
|
||||
/**
|
||||
* Search project files for the query string (class name, ID, etc.)
|
||||
* Returns the first matching file path, or null.
|
||||
*
|
||||
* Only `node_modules`, `.git`, and `.impeccable` are skipped outright.
|
||||
* dist/build/out are left to the isGeneratedFile guard so the
|
||||
* `includeGenerated` second pass can still find the element there and report
|
||||
* `generatedMatch`.
|
||||
*/
|
||||
function findFileWithQuery(query, cwd, genOpts = {}) {
|
||||
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, query, seen, 0, genOpts);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function searchDir(dir, query, seen, depth, genOpts) {
|
||||
if (depth > 5) return null; // don't go too deep
|
||||
const realDir = fs.realpathSync(dir);
|
||||
if (seen.has(realDir)) return null;
|
||||
seen.add(realDir);
|
||||
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { return null; }
|
||||
|
||||
// Check files first
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (!EXTENSIONS.includes(ext)) continue;
|
||||
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (content.includes(query)) return filePath;
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
|
||||
// Then recurse into directories. Always skip node_modules and .git (never
|
||||
// project content). dist/build/out are left to the isGeneratedFile guard so
|
||||
// the includeGenerated second-pass can still find the element there and
|
||||
// report `generatedMatch`.
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
||||
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
return findSourceFile({
|
||||
query,
|
||||
cwd,
|
||||
extensions: resolveLiveTemplateExtensions(cwd),
|
||||
fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* The project-source walk shared by live-wrap.mjs and live-accept.mjs.
|
||||
*
|
||||
* Both scripts need the same thing: find the one project file containing a
|
||||
* string (wrap looks for the element's class/id/text, accept looks for the
|
||||
* session's `impeccable-variants-start` marker). They had two near-identical
|
||||
* copies of the walk, and the copies drifted — same `EXTENSIONS` array declared
|
||||
* twice, same `searchDirs` array declared twice, one `realpathSync` guarded by
|
||||
* try/catch and the other not. That drift is what #374 had to patch in two
|
||||
* places at once.
|
||||
*
|
||||
* Callers differ only in how they reject a candidate, so that is the one thing
|
||||
* this module takes as options (`skipDirs`, `fileFilter`).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs';
|
||||
import { matchesTemplateExtension } from '../lib/template-extensions.mjs';
|
||||
|
||||
/**
|
||||
* Privileged roots, searched in order, before the catch-all `.` walk.
|
||||
*
|
||||
* `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is
|
||||
* an ordering preference rather than a reachability fix: `.` already recurses
|
||||
* into `lib`, so the real #374 bug was the extension list, not this array.
|
||||
*/
|
||||
export const SOURCE_SEARCH_DIRS = Object.freeze([
|
||||
'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Directories that are never project source.
|
||||
*
|
||||
* `.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 accept
|
||||
* searches for. The walk reaches `.` for any project whose source is not under
|
||||
* one of the privileged roots 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.
|
||||
*/
|
||||
export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]);
|
||||
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
/**
|
||||
* Walk the project for the first template file whose contents include `query`.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.query substring to find in file contents
|
||||
* @param {string} opts.cwd project root
|
||||
* @param {string[]} opts.extensions filename suffixes that count as templates
|
||||
* @param {Iterable<string>} [opts.skipDirs] directory names never to descend into
|
||||
* @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate
|
||||
* @returns {string|null} absolute path of the first match
|
||||
*/
|
||||
export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) {
|
||||
const skip = new Set(skipDirs);
|
||||
const seen = new Set();
|
||||
for (const dir of SOURCE_SEARCH_DIRS) {
|
||||
const absDir = path.join(cwd, dir);
|
||||
if (!fs.existsSync(absDir)) continue;
|
||||
const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walk(dir, query, extensions, skip, fileFilter, seen, depth) {
|
||||
if (depth > MAX_DEPTH) return null;
|
||||
// A broken symlink anywhere in the tree used to throw straight out of
|
||||
// live-wrap's copy of this walk, killing the whole wrap.
|
||||
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; }
|
||||
|
||||
// Files before directories: a match in the current directory beats one
|
||||
// nested deeper.
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!matchesTemplateExtension(entry.name, extensions)) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (fileFilter && !fileFilter(filePath)) continue;
|
||||
try {
|
||||
if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath;
|
||||
} catch { /* unreadable, skip */ }
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (skip.has(entry.name)) continue;
|
||||
const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user