mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
* docs: add PRD for design detector hook integration Plans a PostToolUse hook for Claude Code and Codex that runs the existing design detector after every relevant file write and feeds findings back to the agent as advisory system-reminder context. No implementation in this commit; covers UX, technical design, build pipeline changes, distribution, coverage tradeoffs, and rollout. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: revise hook PRD with best-practices review Folds in the P0/P1/P2 findings from an online best-practices critique against the official Claude Code and Codex hook references plus 10+ 2026 community guides and similar prior-art tools (claw-hooks, claude-code-hooks-mastery). Key changes: - Exec form everywhere (Codex snippet was shell form), with Windows rationale. - Default timeout dropped from 10s to 5s. - Re-entrancy guard (CLAUDE_HOOK_DEPTH) and per-file edit counter. - Session-scoped finding dedup promoted from open question to v1. - Per-language inline-ignore syntax map (HTML/JSX/CSS/JS). - Hard-skip rules for sensitive paths and generated/lock files. - Honest framing about Claude Code lacking per-plugin hook disable. - Honest framing about Bash-written files being invisible in v1. - Codex Windows-not-supported call-out, feature flag note, trust ceremony detail. - Optional NDJSON audit log via IMPECCABLE_HOOK_LOG. - Findings cap lowered 8 → 5 with attention-budget rationale. - Versioned envelope ([impeccable@1]) on rendered template. - Expanded test plan, decision log, and stdin payload appendix. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(hooks): ship the design detector hook for Claude Code and Codex Implements docs/hooks-prd.md: a PostToolUse hook that runs the impeccable design detector after every Edit/Write/MultiEdit on a UI file and pushes findings into the agent's next-turn context as a short system reminder. Silent on clean files. Never blocks an edit. Why this matters: today, design slop (side-tab borders, gradient text, purple/cyan palettes, bounce easing, etc.) only gets caught when a human notices or someone explicitly runs /impeccable audit. The hook closes the loop at the moment slop is written. What ships in v1 - skill/scripts/hook.mjs: PostToolUse entry. Reads stdin, runs the detector in-process (no `npx impeccable` cold start), emits hookSpecificOutput.additionalContext when fresh findings exist. - skill/scripts/hook-lib.mjs: extracted helpers (config, cache, filter, render, audit log, runHook orchestrator). 100% unit-testable. - skill/scripts/hook-session-start.mjs: SessionStart greeting, gated by a project-scannable probe + 30-day throttle. - skill/scripts/hook-admin.mjs: backs /impeccable hooks on/off/status/ignore-rule/ignore-file/reset. Hardening built in - Re-entrancy guard (IMPECCABLE_HOOK_DEPTH) so the hook can never recursively spawn itself. - Hard-skip regexes for sensitive paths (.env, .pem, id_rsa, secrets, credentials, .git) and generated/lock/build output. These fire before the file is even read; cannot be turned off via config. - Path-traversal check on the inbound file_path. - Session-scoped dedup keyed by (session, file, rule, line) so the same finding never lands in context twice. Prevents the ~12.5K wasted tokens per chatty session called out in the PRD. - Per-(session, file) edit counter with a one-shot suppression notice on the 7th edit, silent after. - Fail-open contract: every error path returns exit 0 with no stdout. Optional NDJSON audit log via IMPECCABLE_HOOK_LOG. Three kill switches (precedence high to low): 1. IMPECCABLE_HOOK_DISABLED env var (1/true/yes/on, case-insensitive) 2. .impeccable/hook.json `enabled: false` 3. /impeccable hooks off slash command (writes the JSON) Inline ignores are language-aware. `// impeccable: ignore <rule>` for JS/TS, `<!-- impeccable: ignore <rule> -->` for HTML/Vue/Svelte/Astro, `{/* impeccable: ignore <rule> */}` for JSX/TSX, `/* impeccable: ignore <rule> */` for CSS. `*` matches any rule. Directive applies to the next non-blank line. Same shape as ESLint, Stylelint, Biome. Build pipeline - scripts/lib/transformers/hooks.js: per-provider hooks.json builders, plus the slim .codex-plugin/plugin.json manifest. - providers.js: emitHooks: 'claude' for claude-code, emitHooks: 'codex' for codex and agents. Codex also emits emitCodexPlugin. - factory.js: emits hooks/hooks.json next to the skills tree. - build.js: syncs hooks/ into harness roots and into the slim plugin/ subtree; writes .codex-plugin/plugin.json. Build is idempotent (verified: 98 staged files unchanged across two runs). Claude Code wiring uses exec form (command + args) and the ${CLAUDE_PLUGIN_ROOT} placeholder. Matcher: Edit|Write|MultiEdit. `if:` glob filters to UI extensions before spawning Node. PostToolUse timeout 5s, SessionStart timeout 3s. Codex wiring uses ${PLUGIN_ROOT} (Codex's native placeholder), matcher Edit|Write|apply_patch, no `if:` analog (the script does the extension filter). macOS and Linux only; hooks are disabled on Windows in current Codex builds. The trust ceremony and feature flag are documented in README.md. Routing - /impeccable hooks lives outside the 23-command router table on purpose: it is plumbing, not a design skill. The hidden routing slot is added to SKILL.md alongside pin/unpin so the LLM knows to dispatch it. The 23-command count and all stale-count validators remain happy. Tests - tests/hook.test.mjs: 38 unit tests covering env parsing, config load + defaults + malformed, cache round-trip + GC, ignoreRules/minSeverity/inline ignores (all four languages), globbing with **/*/{a,b}, render template with cap + clamp + 0-line prefix drop, audit log NDJSON, payload event-name parameterization, re-entrancy, kill switches, sensitive-path + generated-path + traversal skips, allowlist filter, config ignoreFiles, edit counter cycle including the 7th-edit notice, MultiEdit and apply_patch payload shapes, detector throw swallow, malformed stdin, missing file race. - tests/hook-build.test.mjs: 18 integration tests covering hook manifest shape (matcher, timeouts, exec form, if: glob, placeholders), Codex differences (${PLUGIN_ROOT}, no if:, no SessionStart), Codex plugin manifest (no inline hooks field to avoid the duplicate-file error), routing across the hooksJsonFor table, and presence of all three committed artifacts plus the bundled detector the runtime relative-import path depends on. Full suite: 175 bun tests + 186 node tests, all green. Docs - README.md: new "Design hook" section explaining default behavior, per-project / global / inline disable paths, the JSON schema knobs, the audit log debug flag, and the slop / a11y coverage split. - HARNESSES.md: flips the `hooks` row for Codex from No -> Yes (Claude was already Yes), adds a per-harness hook-surface table with the manifest location and matcher each provider uses. Open questions from the PRD intentionally deferred to v2: Bash-write blind spot, effort-aware suppression, Stop-hook session summary, per-rule severity, async hook mode. None block v1. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Codex hook scanning: apply_patch paths and co-located stylesheets Parse file targets from Codex apply_patch command bodies, co-scan imported and sibling CSS when UI components are edited, drop the git-sweep PostToolUse group, and align Codex SessionStart manifest and trust docs with the official hooks spec. Co-authored-by: Cursor <cursoragent@cursor.com> * Gitignore hook session cache and drop local test HTML Hook dedup/throttle state in .impeccable/hook.cache.json is per-project runtime data like other .impeccable/ sidecars. Remove an untracked bad-nested-flexbox scratch page from site/public/. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Claude Code hook: drop Edit-only if filter so Write/MultiEdit fire Claude's if permission rule binds to one tool name, so Edit(*.{…}) never spawned the hook on Write or MultiEdit despite the matcher listing them. Extension filtering now lives in hook-lib on both Claude and Codex. Co-authored-by: Cursor <cursoragent@cursor.com> * Surface Cursor design findings via stop-hook followup Replace dropped postToolUse additional_context with afterFileEdit recording and a one-shot stop followup_message so anti-pattern nudges reach the agent. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix design hook packaging and scans * Fix Cursor hook pending bucket fallback * Fix Sass hook scan coverage * Fix Cursor hook review findings * Fix session start dead hook normalization * Fix hook config and relative scan paths * Remove SessionStart design hook * Remove redundant afterFileEdit normalization * Fix Cursor suppression and module style scans * Fix sensitive path hook filter * Fix disabled Cursor stop hook emission * Refresh hook harness artifacts * Fix Cursor hook manifest install * Add hook ignore-value support * Ignore hook runtime files locally * Fix Codex plugin hook packaging * fix: address PR review bot findings Block numeric hook depth counters from re-entering. Avoid following stylesheet imports from traversal-looking hook targets. * fix: gate ignore-value suggestions by supported rules Only render exact ignore-value commands when the same finding can be suppressed by ignoreValues. * Package Codex plugin as hook-only * Remove Codex plugin packaging * Recover hook install probe plumbing * Remove Codex hook packaging follow-up doc * Remove extra hook docs and skill wording changes * Install real design hooks via skills CLI * Add provider hook smoke runner * Fix Cursor hook delivery with preToolUse gate * Simplify Cursor hook install to preToolUse * Clarify confirmed hook exceptions * Persist hook ignores in shared config * Guard font hook exceptions * Fix hook install after main rebase * Fix hook scan target handling * fix: address hook review findings * Address hook review feedback * Stabilize DeepSeek insert live fixture * Fix Cursor hook Python shell write bypass --------- Co-authored-by: Cursor <cursoragent@cursor.com>
584 lines
22 KiB
JavaScript
584 lines
22 KiB
JavaScript
/**
|
|
* CLI helper: insert/remove the live variant mode script tag in the project's
|
|
* main HTML entry point.
|
|
*
|
|
* On first live run, the agent generates `.impeccable/live/config.json`
|
|
* with the project's insertion target (framework-specific). On
|
|
* every subsequent run, this script handles insert/remove deterministically
|
|
* with zero LLM involvement.
|
|
*
|
|
* Usage:
|
|
* node live-inject.mjs --port PORT # Insert the live script tag
|
|
* node live-inject.mjs --remove # Remove the live script tag
|
|
* node live-inject.mjs --check # Check whether live config exists
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
|
|
import {
|
|
applySvelteKitLiveAdapter,
|
|
detectSvelteKitProject,
|
|
removeSvelteKitLiveAdapter,
|
|
} from './live/sveltekit-adapter.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
|
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
|
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
|
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
|
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
|
|
|
export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
|
'.impeccable/hook.cache.json',
|
|
'.impeccable/hook.pending.json',
|
|
'.impeccable/hook.local.json',
|
|
'.impeccable/live/server.json',
|
|
'.impeccable/live/sessions/',
|
|
'.impeccable/live/previews/',
|
|
'.impeccable/live/annotations/',
|
|
'.impeccable/live/cache/',
|
|
'.impeccable/live/manual-edit-apply-transaction.json',
|
|
'.impeccable/live/manual-edit-events.jsonl',
|
|
'.impeccable/live/manual-edit-evidence/',
|
|
'.impeccable/live/pending-manual-edits.json',
|
|
'.impeccable/live/deferred-svelte-component-accepts.json',
|
|
'.impeccable-live.json',
|
|
'.impeccable-live/',
|
|
'node_modules/.impeccable-live/',
|
|
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
|
'src/lib/impeccable/__runtime.js',
|
|
'src/lib/impeccable/[0-9a-f]*/',
|
|
]);
|
|
|
|
/**
|
|
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
|
* matching them would silently inject tracking scripts into third-party
|
|
* code. The user cannot turn these off via config — they are the floor.
|
|
*/
|
|
const HARD_EXCLUDES = [
|
|
'**/node_modules/**',
|
|
'**/.git/**',
|
|
];
|
|
|
|
export async function injectCli() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.includes('--help') || args.includes('-h')) {
|
|
console.log(`Usage: node live-inject.mjs [options]
|
|
|
|
Insert or remove the live mode script tag in the project's HTML entry point.
|
|
Reads configuration from .impeccable/live/config.json.
|
|
|
|
Modes:
|
|
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
|
--remove Remove the script tag (if present)
|
|
--check Print whether .impeccable/live/config.json exists and its content
|
|
|
|
Output (JSON):
|
|
{ ok, file, inserted|removed, config? }`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (args.includes('--check')) {
|
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
|
|
process.exit(0);
|
|
}
|
|
let cfg;
|
|
try {
|
|
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
} catch (err) {
|
|
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
|
|
return;
|
|
}
|
|
try {
|
|
validateConfig(cfg);
|
|
} catch (err) {
|
|
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
|
|
return;
|
|
}
|
|
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
|
|
return;
|
|
}
|
|
|
|
// Load config
|
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
|
|
process.exit(1);
|
|
}
|
|
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
validateConfig(config);
|
|
|
|
const resolvedFiles = resolveFiles(process.cwd(), config);
|
|
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
|
|
|
if (args.includes('--remove')) {
|
|
if (svelteKit) {
|
|
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
|
|
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
|
return;
|
|
}
|
|
const results = resolvedFiles.map((relFile) => {
|
|
const absFile = path.resolve(process.cwd(), relFile);
|
|
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
|
const content = fs.readFileSync(absFile, 'utf-8');
|
|
const detagged = removeTag(content, config.commentSyntax);
|
|
const updated = revertCspMeta(detagged);
|
|
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
|
|
fs.writeFileSync(absFile, updated, 'utf-8');
|
|
return {
|
|
file: relFile,
|
|
removed: detagged !== content,
|
|
cspReverted: updated !== detagged,
|
|
};
|
|
});
|
|
console.log(JSON.stringify({ ok: true, results }));
|
|
return;
|
|
}
|
|
|
|
// Insert mode — need --port
|
|
const portIdx = args.indexOf('--port');
|
|
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;
|
|
if (!Number.isFinite(port)) {
|
|
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
|
|
process.exit(1);
|
|
}
|
|
const gitIgnore = ensureLiveGitIgnores(process.cwd());
|
|
|
|
if (svelteKit) {
|
|
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
|
|
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
|
return;
|
|
}
|
|
|
|
const results = resolvedFiles.map((relFile) => {
|
|
const absFile = path.resolve(process.cwd(), relFile);
|
|
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
|
const content = fs.readFileSync(absFile, 'utf-8');
|
|
const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
|
|
const withTag = insertTag(withoutOld, config, port, relFile);
|
|
if (withTag === withoutOld) {
|
|
return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
|
|
}
|
|
const updated = patchCspMeta(withTag, port);
|
|
fs.writeFileSync(absFile, updated, 'utf-8');
|
|
return {
|
|
file: relFile,
|
|
inserted: true,
|
|
cspPatched: updated !== withTag,
|
|
};
|
|
});
|
|
const anyInserted = results.some((r) => r.inserted);
|
|
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
|
|
if (!anyInserted) process.exit(1);
|
|
}
|
|
|
|
export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
|
const target = resolveIgnoreTarget(cwd);
|
|
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
|
const block = [
|
|
IGNORE_MARKER_OPEN,
|
|
...LIVE_IGNORE_PATTERNS,
|
|
IGNORE_MARKER_CLOSE,
|
|
].join('\n');
|
|
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
|
|
|
|
let updated;
|
|
if (markerRe.test(existing)) {
|
|
updated = existing.replace(markerRe, block);
|
|
} else {
|
|
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
|
|
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
|
|
}
|
|
|
|
if (updated !== existing) {
|
|
fs.mkdirSync(path.dirname(target.path), { recursive: true });
|
|
fs.writeFileSync(target.path, updated, 'utf-8');
|
|
}
|
|
|
|
return {
|
|
file: path.relative(cwd, target.path).split(path.sep).join('/'),
|
|
mode: target.mode,
|
|
changed: updated !== existing,
|
|
patterns: [...LIVE_IGNORE_PATTERNS],
|
|
};
|
|
}
|
|
|
|
function resolveIgnoreTarget(cwd) {
|
|
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
|
if (gitExcludePath) {
|
|
return { path: gitExcludePath, mode: 'git-info-exclude' };
|
|
}
|
|
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
|
|
}
|
|
|
|
function resolveGitInfoExcludePath(cwd) {
|
|
const dotGit = path.join(cwd, '.git');
|
|
if (!fs.existsSync(dotGit)) return null;
|
|
|
|
const stat = fs.statSync(dotGit);
|
|
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
|
|
if (!stat.isFile()) return null;
|
|
|
|
const body = fs.readFileSync(dotGit, 'utf-8').trim();
|
|
const match = body.match(/^gitdir:\s*(.+)$/i);
|
|
if (!match) return null;
|
|
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
|
|
return path.join(gitDir, 'info', 'exclude');
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Expand config.files (which may contain glob patterns) into a literal list
|
|
* of existing file paths relative to rootDir. Literal entries pass through;
|
|
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
|
* are applied as filters. Duplicates are removed. Order is preserved by
|
|
* first appearance.
|
|
*/
|
|
export function resolveFiles(rootDir, config) {
|
|
const patterns = config.files;
|
|
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
|
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
|
const excludeRegexes = allExcludes.map(globToRegex);
|
|
|
|
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
|
const isGlob = (s) => /[*?[]/.test(s);
|
|
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const pat of patterns) {
|
|
if (!isGlob(pat)) {
|
|
// Literal path — include even if it doesn't exist yet; the caller
|
|
// reports file_not_found per-entry. Exclude list doesn't apply to
|
|
// explicit literal entries (user named it on purpose).
|
|
if (!seen.has(pat)) {
|
|
seen.add(pat);
|
|
out.push(pat);
|
|
}
|
|
continue;
|
|
}
|
|
let matches;
|
|
try {
|
|
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const ent of matches) {
|
|
if (!ent.isFile || !ent.isFile()) continue;
|
|
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
|
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
|
if (isExcluded(rel)) continue;
|
|
if (seen.has(rel)) continue;
|
|
seen.add(rel);
|
|
out.push(rel);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Convert a glob pattern to a RegExp. Supports:
|
|
* ** → any number of path segments (including zero)
|
|
* * → any chars except `/`
|
|
* ? → any single char except `/`
|
|
* Paths are normalized to forward slashes before matching.
|
|
*/
|
|
function globToRegex(pattern) {
|
|
let re = '';
|
|
let i = 0;
|
|
while (i < pattern.length) {
|
|
const c = pattern[i];
|
|
if (c === '*') {
|
|
if (pattern[i + 1] === '*') {
|
|
// ** — any number of segments, including zero. Handle the common
|
|
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
|
if (pattern[i + 2] === '/') {
|
|
re += '(?:.*/)?';
|
|
i += 3;
|
|
} else {
|
|
re += '.*';
|
|
i += 2;
|
|
}
|
|
} else {
|
|
re += '[^/]*';
|
|
i += 1;
|
|
}
|
|
} else if (c === '?') {
|
|
re += '[^/]';
|
|
i += 1;
|
|
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
re += '\\' + c;
|
|
i += 1;
|
|
} else {
|
|
re += c;
|
|
i += 1;
|
|
}
|
|
}
|
|
return new RegExp('^' + re + '$');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function validateConfig(cfg) {
|
|
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
|
|
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
|
|
throw new Error('config.files (non-empty string array) required');
|
|
}
|
|
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
|
throw new Error('config.files must contain only non-empty strings');
|
|
}
|
|
if (cfg.exclude !== undefined) {
|
|
if (!Array.isArray(cfg.exclude)) {
|
|
throw new Error('config.exclude, if present, must be a string array');
|
|
}
|
|
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
|
throw new Error('config.exclude must contain only non-empty strings');
|
|
}
|
|
}
|
|
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
|
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
|
}
|
|
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
|
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
|
}
|
|
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
|
throw new Error("config.cspChecked, if present, must be a boolean");
|
|
}
|
|
}
|
|
|
|
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
|
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
|
|
|
|
function buildTagBlock(syntax, port, filePath) {
|
|
const open = commentOpen(syntax);
|
|
const close = commentClose(syntax);
|
|
// Astro processes <script> tags by default and rewrites src to its own
|
|
// bundled URL. is:inline opts out so the literal external src survives.
|
|
const isAstro = typeof filePath === 'string' && filePath.endsWith('.astro');
|
|
const scriptAttrs = isAstro ? 'is:inline ' : '';
|
|
return (
|
|
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
|
|
'<script ' + scriptAttrs + 'src="http://localhost:' + port + '/live.js"></script>\n' +
|
|
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
|
|
);
|
|
}
|
|
|
|
function detectLineEnding(content) {
|
|
if (content.includes('\r\n')) return '\r\n';
|
|
if (content.includes('\r')) return '\r';
|
|
return '\n';
|
|
}
|
|
|
|
function normalizeLineEndings(content, lineEnding) {
|
|
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
|
|
}
|
|
|
|
function readLineEndingAt(content, index) {
|
|
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
|
|
if (content[index] === '\n') return '\n';
|
|
if (content[index] === '\r') return '\r';
|
|
return '';
|
|
}
|
|
|
|
function insertTag(content, config, port, filePath) {
|
|
const lineEnding = detectLineEnding(content);
|
|
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
|
|
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
|
|
// belong at the end, and the same literal can appear earlier in code blocks
|
|
// within rendered documentation pages.
|
|
if (config.insertBefore) {
|
|
const idx = content.lastIndexOf(config.insertBefore);
|
|
if (idx === -1) return content;
|
|
return content.slice(0, idx) + block + content.slice(idx);
|
|
}
|
|
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
|
|
// `<body>` open near the top of the document.
|
|
const idx = content.indexOf(config.insertAfter);
|
|
if (idx === -1) return content;
|
|
const after = idx + config.insertAfter.length;
|
|
// Preserve an existing trailing newline if the anchor already has one.
|
|
// Slice the remainder from the original anchor offset, not prefix.length:
|
|
// in the no-newline case prefix is one char longer than the anchor (the
|
|
// appended '\n'), so slicing by prefix.length would drop the first real
|
|
// character after the anchor (#227).
|
|
const existingNewline = readLineEndingAt(content, after);
|
|
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
|
|
const rest = content.slice(after + existingNewline.length);
|
|
return prefix + block + rest;
|
|
}
|
|
|
|
/**
|
|
* Remove the live script block. Matches either HTML or JSX comment markers
|
|
* regardless of config (so stale tags from a wrong config can still be cleaned).
|
|
*
|
|
* Indent-preserving: captures any whitespace immediately preceding the opener
|
|
* marker and re-emits it in place of the removed block. `insertTag` inserted
|
|
* the block *after* the original line's indent and *before* the anchor (e.g.
|
|
* `</body>`), which moved the indent onto the opener line and left the anchor
|
|
* unindented. Replacing the whole block (plus its trailing newline) with just
|
|
* the captured indent hands the indent back to the anchor that follows.
|
|
*/
|
|
function removeTag(content, _syntax) {
|
|
const patterns = [
|
|
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
|
|
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
|
|
];
|
|
for (const pat of patterns) {
|
|
let changed = false;
|
|
let next = content;
|
|
do {
|
|
content = next;
|
|
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
|
|
if (/[\r\n]/.test(trailing)) return leadingIndent;
|
|
return leadingIndent || trailing || '';
|
|
});
|
|
if (next !== content) changed = true;
|
|
} while (next !== content);
|
|
if (changed) return next;
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content-Security-Policy meta-tag patcher
|
|
//
|
|
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
|
|
// the cross-origin load of /live.js (and the SSE/POST connection back to
|
|
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
|
|
//
|
|
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
|
|
// and stash the original `content` value in a `data-impeccable-csp-original`
|
|
// attribute (base64) so revert is exact.
|
|
//
|
|
// On remove: detect the marker attribute, decode it, restore the original
|
|
// content value verbatim, drop the marker.
|
|
//
|
|
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
|
|
// shared helpers) is NOT patched here — those need framework-specific config
|
|
// edits and are handled via the existing detect-csp.mjs reference output.
|
|
// Only the in-source meta-tag form gets the auto-patch.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
|
|
|
|
function findCspMetaTags(content) {
|
|
const out = [];
|
|
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
|
|
let m;
|
|
while ((m = tagRe.exec(content)) !== null) {
|
|
const attrs = m[1];
|
|
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
|
|
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function getAttr(attrs, name) {
|
|
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
|
|
const m = attrs.match(re);
|
|
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
|
|
}
|
|
|
|
function appendOriginToDirective(csp, directive, origin) {
|
|
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
|
|
const m = csp.match(re);
|
|
if (m) {
|
|
const tokens = m[4].trim().split(/\s+/);
|
|
if (tokens.includes(origin)) return csp;
|
|
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
|
|
}
|
|
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
|
|
// narrow the policy compared to the default-src fallback (most users with
|
|
// an explicit CSP have 'self' there).
|
|
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
|
|
}
|
|
|
|
export function patchCspMeta(content, port) {
|
|
const tags = findCspMetaTags(content);
|
|
if (tags.length === 0) return content;
|
|
const origin = `http://localhost:${port}`;
|
|
|
|
// Walk last-to-first so prior splices don't invalidate later indices.
|
|
let result = content;
|
|
for (let i = tags.length - 1; i >= 0; i--) {
|
|
const tag = tags[i];
|
|
const attrs = tag.attrs;
|
|
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
|
|
const contentAttr = getAttr(attrs, 'content');
|
|
if (!contentAttr) continue;
|
|
|
|
const original = contentAttr.value;
|
|
let patched = original;
|
|
patched = appendOriginToDirective(patched, 'script-src', origin);
|
|
patched = appendOriginToDirective(patched, 'connect-src', origin);
|
|
// The shader overlay during 'generating' creates a screenshot via
|
|
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
|
|
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
|
|
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
|
|
if (patched === original) continue;
|
|
|
|
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
|
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
|
// The tagRe captures any whitespace between the last attribute and the
|
|
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
|
// a replace would land it BEFORE that trailing space, leaving a double
|
|
// space inside attrs and clobbering the space before `/>`. Split off
|
|
// the trailing whitespace, splice the marker into the attribute body,
|
|
// and re-append the original trailing whitespace so a self-closing
|
|
// `<meta … />` round-trips byte-for-byte.
|
|
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
|
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
|
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
|
const newTag = tag.full.replace(attrs, newAttrs);
|
|
|
|
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function revertCspMeta(content) {
|
|
const tags = findCspMetaTags(content);
|
|
if (tags.length === 0) return content;
|
|
|
|
let result = content;
|
|
for (let i = tags.length - 1; i >= 0; i--) {
|
|
const tag = tags[i];
|
|
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
|
|
if (!origAttr) continue;
|
|
const contentAttr = getAttr(tag.attrs, 'content');
|
|
if (!contentAttr) continue;
|
|
|
|
let originalValue;
|
|
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
|
|
catch { continue; }
|
|
|
|
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
|
|
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
|
|
// Drop the marker attribute and any single space immediately preceding it.
|
|
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
|
|
const newTag = tag.full.replace(tag.attrs, newAttrs);
|
|
|
|
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-execute
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _running = process.argv[1];
|
|
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
|
|
injectCli();
|
|
}
|
|
|
|
export { insertTag, removeTag, validateConfig, buildTagBlock };
|
|
// patchCspMeta + revertCspMeta are exported above where they're defined.
|