chore(skills): configuration des compétences Impeccable pour les agents
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Astro registry entry.
|
||||
*
|
||||
* Astro takes the generic tag strategy, with two Astro-specific values that
|
||||
* used to sit as inline `endsWith('.astro')` branches in live-inject.mjs and
|
||||
* live-wrap.mjs:
|
||||
*
|
||||
* injectScriptAttrs Astro processes <script> tags by default and rewrites
|
||||
* src to its own bundled URL; is:inline opts out.
|
||||
* styleMode Astro scopes component styles, which strips preview CSS
|
||||
* off the generated variant wrappers, so preview rules are
|
||||
* authored global and prefixed instead of @scope'd.
|
||||
*/
|
||||
|
||||
import { findConfigFile, hasAnyDependency, literalConfigFiles } from './detect-utils.mjs';
|
||||
|
||||
const ASTRO_CONFIG_RE = /^astro\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectAstroProject(cwd = process.cwd(), config = null) {
|
||||
const configFile = findConfigFile(cwd, ASTRO_CONFIG_RE);
|
||||
if (configFile) return { configFile, via: 'config' };
|
||||
if (hasAnyDependency(cwd, ['astro'])) return { configFile: null, via: 'package' };
|
||||
// A tree of .astro entry templates with no astro.config still belongs to
|
||||
// Astro; the configured injection target names it.
|
||||
const entry = literalConfigFiles(cwd, config).find((rel) => rel.endsWith('.astro'));
|
||||
if (entry) return { configFile: null, via: 'config-files', entry };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const astro = {
|
||||
name: 'astro',
|
||||
|
||||
detect(cwd, config) {
|
||||
return detectAstroProject(cwd, config);
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.astro'],
|
||||
preview: 'source',
|
||||
styleMode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
commentSyntax: 'html',
|
||||
injectScriptAttrs: 'is:inline ',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Small read-only probes the framework entries share.
|
||||
*
|
||||
* Every helper here is cheap and failure-tolerant: detection runs on every
|
||||
* inject, against project trees that may be half-installed, so a missing or
|
||||
* malformed file means "not this framework", never a throw.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Merged dependency names from package.json, or an empty object. */
|
||||
export function readPackageDeps(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
return {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAnyDependency(cwd, names) {
|
||||
const deps = readPackageDeps(cwd);
|
||||
return names.some((name) => Boolean(deps[name]));
|
||||
}
|
||||
|
||||
/** First top-level file name matching `re`, or null. */
|
||||
export function findConfigFile(cwd, re) {
|
||||
try {
|
||||
return fs.readdirSync(cwd, { withFileTypes: true })
|
||||
.find((entry) => entry.isFile() && re.test(entry.name))
|
||||
?.name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function fileExists(cwd, rel) {
|
||||
try {
|
||||
return fs.existsSync(path.join(cwd, rel));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function firstExistingFile(cwd, candidates) {
|
||||
for (const rel of candidates) {
|
||||
if (fileExists(cwd, rel)) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal (non-glob) entries of `config.files` that exist on disk. Several
|
||||
* detectors read the configured injection target as a signal, which is how the
|
||||
* bare fixtures — a tree of `.astro` files with no astro.config — still resolve
|
||||
* to the framework that authored them.
|
||||
*/
|
||||
export function literalConfigFiles(cwd, config) {
|
||||
const files = Array.isArray(config?.files) ? config.files : [];
|
||||
const out = [];
|
||||
for (const rel of files) {
|
||||
if (typeof rel !== 'string' || rel.includes('*') || rel.includes('?')) continue;
|
||||
const normalized = rel.split(path.sep).join('/');
|
||||
if (fileExists(cwd, normalized)) out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
143
.opencode/skills/impeccable/scripts/live/frameworks/index.mjs
Normal file
143
.opencode/skills/impeccable/scripts/live/frameworks/index.mjs
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The live-mode framework registry.
|
||||
*
|
||||
* Before this existed, framework knowledge was smeared across live-inject.mjs
|
||||
* (detection order, the Nuxt adapter, the Astro `is:inline` branch), the two
|
||||
* adapter modules, and live-wrap.mjs (which extension gets component preview,
|
||||
* which gets Astro's global-prefixed CSS, which gets JSX comments). Adding or
|
||||
* fixing a framework meant reading all of them.
|
||||
*
|
||||
* One entry per framework now declares everything the live scripts need:
|
||||
*
|
||||
* name stable identifier; also the `adapter` value in inject JSON.
|
||||
* detect (cwd, config) → falsy when this is not the project, otherwise
|
||||
* a truthy project descriptor that apply/remove/artifacts read.
|
||||
* Order in FRAMEWORKS is priority order; first truthy wins.
|
||||
* inject { kind: 'adapter', apply, remove, ignorePatterns, artifacts,
|
||||
* unpatch } for frameworks that server-render their document
|
||||
* shell, or { kind: 'tag' } for the generic marker-wrapped
|
||||
* <script src> block.
|
||||
* source how live-wrap treats files this framework authors:
|
||||
* extensions, preview ('source' | 'component'), styleMode,
|
||||
* styleTag, commentSyntax, injectScriptAttrs. Anything omitted
|
||||
* falls back to SOURCE_TRAIT_DEFAULTS.
|
||||
*
|
||||
* Two rules hold the thing together:
|
||||
*
|
||||
* 1. **Detection order is injection priority.** SvelteKit → Nuxt → TanStack
|
||||
* Start → Astro → Next → Vite → static HTML, exactly the order
|
||||
* live-inject.mjs used to hard-code. static-html always matches, so
|
||||
* resolveFramework never returns null.
|
||||
* 2. **Source traits resolve by file extension, not by project.** A SvelteKit
|
||||
* project's injection target is `src/app.html`; a Vite app can contain
|
||||
* `.astro` partials. live-wrap has always keyed these off the target file,
|
||||
* and resolveSourceTraits keeps it that way. Several entries may claim the
|
||||
* same extension (`.tsx` belongs to three); when they do, the values must
|
||||
* agree, which tests/live-frameworks.test.mjs asserts.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import { sveltekit } from './sveltekit.mjs';
|
||||
import { nuxt } from './nuxt.mjs';
|
||||
import { tanstackStart } from './tanstack-start.mjs';
|
||||
import { astro } from './astro.mjs';
|
||||
import { nextjs } from './nextjs.mjs';
|
||||
import { viteGeneric } from './vite-generic.mjs';
|
||||
import { staticHtml } from './static-html.mjs';
|
||||
import { TAG_PATCH_MARKERS, unpatchTagFile } from './tag-strategy.mjs';
|
||||
|
||||
/** Priority order. Do not reorder without re-reading rule 1 above. */
|
||||
export const FRAMEWORKS = Object.freeze([
|
||||
sveltekit,
|
||||
nuxt,
|
||||
tanstackStart,
|
||||
astro,
|
||||
nextjs,
|
||||
viteGeneric,
|
||||
staticHtml,
|
||||
]);
|
||||
|
||||
export const PREVIEW_MODES = Object.freeze(['source', 'component']);
|
||||
export const STYLE_MODES = Object.freeze(['scoped', 'astro-global-prefixed']);
|
||||
export const COMMENT_SYNTAXES = Object.freeze(['html', 'jsx']);
|
||||
export const INJECT_KINDS = Object.freeze(['adapter', 'tag']);
|
||||
|
||||
export const SOURCE_TRAIT_DEFAULTS = Object.freeze({
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
commentSyntax: 'html',
|
||||
injectScriptAttrs: '',
|
||||
});
|
||||
|
||||
/** The patch kind the generic tag strategy records in the journal. */
|
||||
export const TAG_PATCH_KIND = 'live-tag';
|
||||
|
||||
/**
|
||||
* Undo functions keyed by the `patch` value an artifact carries. Built from
|
||||
* the entries so a new adapter registers its own undo alongside its apply.
|
||||
*/
|
||||
export const PATCH_UNDOERS = Object.freeze(Object.assign(
|
||||
{ [TAG_PATCH_KIND]: unpatchTagFile },
|
||||
...FRAMEWORKS.map((framework) => framework.inject.unpatch || {}),
|
||||
));
|
||||
|
||||
/**
|
||||
* First entry whose detect() matches. Returns { framework, project } where
|
||||
* project is the detector's descriptor (adapters read it; tag frameworks
|
||||
* mostly ignore it).
|
||||
*/
|
||||
export function resolveFramework(cwd = process.cwd(), config = null) {
|
||||
for (const framework of FRAMEWORKS) {
|
||||
const project = framework.detect(cwd, config);
|
||||
if (project) return { framework, project };
|
||||
}
|
||||
// Unreachable while static-html stays terminal, but a caller that reorders
|
||||
// the array should get a diagnosable null rather than a silent tag inject.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-authoring traits for one file, merged over SOURCE_TRAIT_DEFAULTS.
|
||||
* `framework` names the entry that claimed the extension, or null.
|
||||
*/
|
||||
export function resolveSourceTraits(filePath) {
|
||||
const ext = path.extname(String(filePath || '')).toLowerCase();
|
||||
for (const framework of FRAMEWORKS) {
|
||||
const source = framework.source;
|
||||
if (!source || !source.extensions.includes(ext)) continue;
|
||||
const { extensions, ...traits } = source;
|
||||
return { framework: framework.name, ...SOURCE_TRAIT_DEFAULTS, ...traits };
|
||||
}
|
||||
return { framework: null, ...SOURCE_TRAIT_DEFAULTS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra gitignore patterns the resolved framework needs beyond the static
|
||||
* LIVE_IGNORE_PATTERNS list (paths that depend on a detected srcDir or file
|
||||
* extension and so cannot be written down ahead of time).
|
||||
*/
|
||||
export function frameworkIgnorePatterns(resolved) {
|
||||
const fn = resolved?.framework?.inject?.ignorePatterns;
|
||||
return typeof fn === 'function' ? (fn(resolved.project) || []) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The files this injection will create or patch, in journal-artifact form.
|
||||
* Adapters declare their own; the tag strategy patches exactly the resolved
|
||||
* config files.
|
||||
*/
|
||||
export function describeInjectArtifacts(resolved, { cwd = process.cwd(), files = [] } = {}) {
|
||||
if (!resolved) return [];
|
||||
const { framework, project } = resolved;
|
||||
if (framework.inject.kind === 'adapter') {
|
||||
return (framework.inject.artifacts?.({ cwd, project }) || []).filter((a) => a && a.path);
|
||||
}
|
||||
return files.map((file) => ({
|
||||
kind: 'patched',
|
||||
path: file,
|
||||
patch: TAG_PATCH_KIND,
|
||||
markers: [...TAG_PATCH_MARKERS],
|
||||
}));
|
||||
}
|
||||
197
.opencode/skills/impeccable/scripts/live/frameworks/journal.mjs
Normal file
197
.opencode/skills/impeccable/scripts/live/frameworks/journal.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Crash-safe injection journal.
|
||||
*
|
||||
* Injection writes into the user's source tree: generated components, a Nuxt
|
||||
* client plugin, marker blocks inside a layout, a patched CSP meta tag. The
|
||||
* clean path removes all of it on stop. The unclean paths do not:
|
||||
*
|
||||
* - the dev server is SIGKILLed, so `--remove` never runs;
|
||||
* - the project changes shape between start and stop (a nuxt.config appears,
|
||||
* a package.json is edited), so detection resolves a different framework
|
||||
* and the old framework's artifacts are nobody's business;
|
||||
* - stop runs from a different directory than start did.
|
||||
*
|
||||
* So every inject records what it wrote to `.impeccable/live/inject-journal.json`
|
||||
* before the next one runs, and both inject and `--remove` reconcile that
|
||||
* record against the tree.
|
||||
*
|
||||
* **The journal is a claim of ownership, not a to-do list.** Healing an
|
||||
* artifact only ever removes what still carries our marker; a generated file
|
||||
* the user has since replaced, or a layout they have since un-patched by hand,
|
||||
* is dropped from the journal untouched.
|
||||
*
|
||||
* **Path resolution is appRoot-relative.** Live entry scripts chdir onto the
|
||||
* roots manifest (`enterLiveRoot`) before doing anything, so a journal written
|
||||
* by a session started in the app root is found by a stop issued from any
|
||||
* directory inside the repo.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { PATCH_UNDOERS } from './index.mjs';
|
||||
|
||||
export const INJECT_JOURNAL_VERSION = 1;
|
||||
export const INJECT_JOURNAL_RELPATH = '.impeccable/live/inject-journal.json';
|
||||
|
||||
export function injectJournalPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, ...INJECT_JOURNAL_RELPATH.split('/'));
|
||||
}
|
||||
|
||||
export function readInjectJournal(cwd = process.cwd()) {
|
||||
const file = injectJournalPath(cwd);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.artifacts)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function clearInjectJournal(cwd = process.cwd()) {
|
||||
try { fs.unlinkSync(injectJournalPath(cwd)); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
function writeInjectJournal(cwd, journal) {
|
||||
const file = injectJournalPath(cwd);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the artifacts an injection just wrote. Replaces any previous record:
|
||||
* callers heal first (see healInjectJournal), so nothing survivable is lost.
|
||||
*/
|
||||
export function recordInjection(cwd = process.cwd(), { framework, port, artifacts = [] } = {}) {
|
||||
if (!artifacts.length) {
|
||||
clearInjectJournal(cwd);
|
||||
return null;
|
||||
}
|
||||
return writeInjectJournal(cwd, {
|
||||
version: INJECT_JOURNAL_VERSION,
|
||||
appRoot: path.resolve(cwd),
|
||||
framework: framework || null,
|
||||
port: Number.isFinite(Number(port)) ? Number(port) : null,
|
||||
pid: process.pid,
|
||||
recordedAt: new Date().toISOString(),
|
||||
artifacts,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRel(cwd, rel) {
|
||||
return path.resolve(cwd, String(rel || '')).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function readIfPresent(abs) {
|
||||
try {
|
||||
return fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDirs(dir, stopDir) {
|
||||
let current = path.resolve(dir);
|
||||
const stop = path.resolve(stopDir);
|
||||
while (current !== stop && current.startsWith(stop + path.sep)) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
function insideProject(cwd, abs) {
|
||||
const rel = path.relative(path.resolve(cwd), path.resolve(abs));
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function healArtifact(cwd, artifact, undoers) {
|
||||
const abs = path.resolve(cwd, artifact.path);
|
||||
// The journal is a project-local file, i.e. attacker-writable input in a
|
||||
// cloned repo. Never touch anything outside the project tree, whatever the
|
||||
// journal claims to own.
|
||||
if (!insideProject(cwd, abs)) return { path: artifact.path, action: 'refused_outside_project' };
|
||||
const content = readIfPresent(abs);
|
||||
if (content === null) return { path: artifact.path, action: 'absent' };
|
||||
|
||||
if (artifact.kind === 'created') {
|
||||
// Only reclaim a generated file that still carries our marker; a created
|
||||
// artifact with no marker at all is unverifiable and stays untouched.
|
||||
if (!artifact.marker || !content.includes(artifact.marker)) {
|
||||
return { path: artifact.path, action: 'disowned' };
|
||||
}
|
||||
try { fs.rmSync(abs, { force: true }); } catch { return null; }
|
||||
if (artifact.pruneTo !== undefined) {
|
||||
const pruneRoot = path.resolve(cwd, artifact.pruneTo || '.');
|
||||
if (insideProject(cwd, pruneRoot) || pruneRoot === path.resolve(cwd)) {
|
||||
pruneEmptyDirs(path.dirname(abs), pruneRoot);
|
||||
}
|
||||
}
|
||||
return { path: artifact.path, action: 'removed' };
|
||||
}
|
||||
|
||||
if (artifact.kind === 'patched') {
|
||||
const markers = Array.isArray(artifact.markers) ? artifact.markers : [];
|
||||
// No marker left means the patch is already gone; never run an undo over
|
||||
// a file we no longer recognize (the undoers normalize whitespace).
|
||||
if (markers.length && !markers.some((marker) => content.includes(marker))) {
|
||||
return { path: artifact.path, action: 'disowned' };
|
||||
}
|
||||
const undo = undoers[artifact.patch];
|
||||
if (typeof undo !== 'function') return null;
|
||||
const next = undo(content);
|
||||
if (next === content) return { path: artifact.path, action: 'disowned' };
|
||||
try { fs.writeFileSync(abs, next, 'utf-8'); } catch { return null; }
|
||||
return { path: artifact.path, action: 'unpatched' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the journal against the tree.
|
||||
*
|
||||
* `keep` is the set of paths the current operation legitimately owns — the
|
||||
* artifacts an inject is about to (re)write. Everything else in the journal is
|
||||
* an orphan of a session that is gone, and gets healed. This keeps a repeat
|
||||
* inject byte-idempotent: the artifacts it is about to rewrite are kept, not
|
||||
* torn down and rebuilt.
|
||||
*
|
||||
* Returns `{ healed, kept }`. `healed` lists only artifacts whose file was
|
||||
* actually changed or removed, so callers can stay silent when nothing was
|
||||
* orphaned. Idempotent: a second call finds an empty journal.
|
||||
*/
|
||||
export function healInjectJournal(cwd = process.cwd(), { keep = [], undoers = PATCH_UNDOERS } = {}) {
|
||||
const journal = readInjectJournal(cwd);
|
||||
if (!journal) return { healed: [], kept: [] };
|
||||
|
||||
const keepSet = new Set(keep.map((rel) => normalizeRel(cwd, rel)));
|
||||
const healed = [];
|
||||
const kept = [];
|
||||
|
||||
for (const artifact of journal.artifacts) {
|
||||
if (!artifact || typeof artifact.path !== 'string') continue;
|
||||
if (keepSet.has(normalizeRel(cwd, artifact.path))) {
|
||||
kept.push(artifact);
|
||||
continue;
|
||||
}
|
||||
const outcome = healArtifact(cwd, artifact, undoers);
|
||||
if (outcome && (outcome.action === 'removed' || outcome.action === 'unpatched')) {
|
||||
healed.push(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
if (kept.length) {
|
||||
writeInjectJournal(cwd, { ...journal, artifacts: kept });
|
||||
} else {
|
||||
clearInjectJournal(cwd);
|
||||
}
|
||||
|
||||
return { healed, kept };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Next.js registry entry.
|
||||
*
|
||||
* Next takes the generic tag strategy: the App Router's root layout renders
|
||||
* `<html>…<body>` in JSX, so the marker-wrapped script block goes in there
|
||||
* verbatim. Nothing about injection differs from a plain Vite app, which is
|
||||
* why live-inject.mjs never had a Next branch. The entry exists so the
|
||||
* registry can name what it is looking at.
|
||||
*/
|
||||
|
||||
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
|
||||
|
||||
const NEXT_CONFIG_RE = /^next\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
const ROUTER_ENTRY_CANDIDATES = [
|
||||
'app/layout.tsx', 'app/layout.jsx', 'app/layout.ts', 'app/layout.js',
|
||||
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.ts', 'src/app/layout.js',
|
||||
'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.ts', 'pages/_app.js',
|
||||
'pages/_document.tsx', 'pages/_document.jsx',
|
||||
'src/pages/_app.tsx', 'src/pages/_app.jsx',
|
||||
];
|
||||
|
||||
export function detectNextProject(cwd = process.cwd()) {
|
||||
const configFile = findConfigFile(cwd, NEXT_CONFIG_RE);
|
||||
if (configFile) return { configFile, via: 'config' };
|
||||
if (hasAnyDependency(cwd, ['next'])) return { configFile: null, via: 'package' };
|
||||
// Next's file conventions are distinctive enough to stand alone: a root
|
||||
// `app/layout.*` or `pages/_app.*` is not a shape other bundlers produce.
|
||||
const entry = ROUTER_ENTRY_CANDIDATES.find((rel) => fileExists(cwd, rel));
|
||||
if (entry) return { configFile: null, via: 'router-entry', entry };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const nextjs = {
|
||||
name: 'nextjs',
|
||||
|
||||
detect(cwd) {
|
||||
return detectNextProject(cwd);
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'jsx',
|
||||
},
|
||||
};
|
||||
161
.opencode/skills/impeccable/scripts/live/frameworks/nuxt.mjs
Normal file
161
.opencode/skills/impeccable/scripts/live/frameworks/nuxt.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Nuxt registry entry, and the Nuxt adapter itself.
|
||||
*
|
||||
* A script element placed in app.vue is compiled as Vue-rendered DOM and is
|
||||
* not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
|
||||
* generated, dev-only, and outside user-authored source: Live creates one
|
||||
* marked .client.ts plugin on start and removes it on stop.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from './script-src.mjs';
|
||||
import { findConfigFile } from './detect-utils.mjs';
|
||||
|
||||
export const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
|
||||
export const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
|
||||
|
||||
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectNuxtProject(cwd = process.cwd()) {
|
||||
const configFile = findConfigFile(cwd, NUXT_CONFIG_RE);
|
||||
if (!configFile) return null;
|
||||
|
||||
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
||||
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
||||
let appDir = '';
|
||||
if (literalSrcDir) {
|
||||
const candidate = literalSrcDir[2]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
|
||||
appDir = normalized === '.' ? '' : normalized;
|
||||
}
|
||||
} else if (
|
||||
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|
||||
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
|
||||
) {
|
||||
appDir = 'app';
|
||||
}
|
||||
|
||||
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
|
||||
return { configFile, appDir, pluginFile };
|
||||
}
|
||||
|
||||
export function buildNuxtPlugin(port, token) {
|
||||
return `/* ${NUXT_PLUGIN_MARKER} */
|
||||
const liveSrc = '${buildLiveScriptSrc(port, token)}';
|
||||
const liveSelector = 'script[data-impeccable-live-nuxt]';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.dev || typeof document === 'undefined') return;
|
||||
|
||||
const expectedSrc = new URL(liveSrc, window.location.href).href;
|
||||
let script = document.querySelector(liveSelector);
|
||||
if (script?.src === expectedSrc) return;
|
||||
script?.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = liveSrc;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveNuxt = '';
|
||||
document.head.appendChild(script);
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
if (script?.isConnected) script.remove();
|
||||
});
|
||||
});
|
||||
/* /${NUXT_PLUGIN_MARKER} */
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, token, project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
|
||||
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buildNuxtPlugin(port, token);
|
||||
fs.mkdirSync(path.dirname(absFile), { recursive: true });
|
||||
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
inserted: true,
|
||||
changed: content !== existing,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
|
||||
if (!project) return { error: 'nuxt_not_detected' };
|
||||
const absFile = path.join(cwd, project.pluginFile);
|
||||
if (!fs.existsSync(absFile)) {
|
||||
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
|
||||
}
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
if (!content.includes(NUXT_PLUGIN_MARKER)) {
|
||||
return {
|
||||
file: project.pluginFile,
|
||||
removed: false,
|
||||
error: 'nuxt_plugin_conflict',
|
||||
hint: `${project.pluginFile} is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.unlinkSync(absFile);
|
||||
const pluginDir = path.dirname(absFile);
|
||||
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
|
||||
return { file: project.pluginFile, removed: true };
|
||||
}
|
||||
|
||||
export const nuxt = {
|
||||
name: 'nuxt',
|
||||
|
||||
detect(cwd) {
|
||||
return detectNuxtProject(cwd);
|
||||
},
|
||||
|
||||
inject: {
|
||||
kind: 'adapter',
|
||||
|
||||
apply({ cwd, port, token, project }) {
|
||||
return applyNuxtLiveAdapter({ cwd, port, token, project });
|
||||
},
|
||||
|
||||
remove({ cwd, project }) {
|
||||
return removeNuxtLiveAdapter({ cwd, project });
|
||||
},
|
||||
|
||||
// The plugin path depends on the resolved srcDir, so it cannot live in the
|
||||
// static ignore list the way the SvelteKit paths do.
|
||||
ignorePatterns(project) {
|
||||
return project?.pluginFile ? [project.pluginFile] : [];
|
||||
},
|
||||
|
||||
artifacts({ project }) {
|
||||
if (!project?.pluginFile) return [];
|
||||
return [{
|
||||
kind: 'created',
|
||||
path: project.pluginFile,
|
||||
marker: NUXT_PLUGIN_MARKER,
|
||||
// Mirrors removeNuxtLiveAdapter: the generated `plugins/` directory
|
||||
// goes when it empties, its parent stays.
|
||||
pruneTo: path.posix.dirname(path.posix.dirname(project.pluginFile)),
|
||||
}];
|
||||
},
|
||||
},
|
||||
|
||||
source: {
|
||||
extensions: ['.vue'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'html',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The one place that builds the `/live.js` URL the browser loads.
|
||||
*
|
||||
* Every injection path needs it (the generic script tag, the Nuxt client
|
||||
* plugin, the SvelteKit root component, the TanStack mount component), and a
|
||||
* separate module keeps that shared leaf free of import cycles: the framework
|
||||
* entries import it, and nothing here imports a framework entry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* When a token is supplied it rides as a `?token=...` query param so the
|
||||
* server's token-gated /live.js handler authorizes the fetch.
|
||||
*/
|
||||
export function buildLiveScriptSrc(port, token) {
|
||||
const base = 'http://localhost:' + port + '/live.js';
|
||||
return token ? base + '?token=' + encodeURIComponent(token) : base;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Static HTML registry entry: the terminal fallback.
|
||||
*
|
||||
* Hand-written pages, a multi-page site emitted by a generator, anything with
|
||||
* no bundler config at the app root. `detect` always matches, so this entry
|
||||
* must stay last in FRAMEWORKS. Its behavior is the plain tag strategy, which
|
||||
* is what live-inject.mjs did for every unrecognized project before the
|
||||
* registry existed.
|
||||
*/
|
||||
|
||||
export const staticHtml = {
|
||||
name: 'static-html',
|
||||
|
||||
detect() {
|
||||
return { via: 'fallback' };
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.html', '.htm'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'html',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* SvelteKit registry entry.
|
||||
*
|
||||
* Detection and the apply/remove pair are the existing adapter's
|
||||
* (`../sveltekit-adapter.mjs`); this file only declares them to the registry
|
||||
* and names the artifacts the journal has to be able to heal.
|
||||
*/
|
||||
|
||||
import {
|
||||
SVELTE_LAYOUT_MARKER_OPEN,
|
||||
SVELTE_LIVE_ROOT_COMPONENT,
|
||||
applySvelteKitLiveAdapter,
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
unpatchSvelteLayout,
|
||||
} from '../sveltekit-adapter.mjs';
|
||||
|
||||
export const sveltekit = {
|
||||
name: 'sveltekit',
|
||||
|
||||
detect(cwd, config) {
|
||||
return detectSvelteKitProject(cwd, config);
|
||||
},
|
||||
|
||||
inject: {
|
||||
kind: 'adapter',
|
||||
|
||||
apply({ cwd, port, token, config }) {
|
||||
return applySvelteKitLiveAdapter({ cwd, port, token, config });
|
||||
},
|
||||
|
||||
remove({ cwd, config }) {
|
||||
return removeSvelteKitLiveAdapter({ cwd, config });
|
||||
},
|
||||
|
||||
// The generated root component and the `src/lib/impeccable/` runtime paths
|
||||
// are already in the static LIVE_IGNORE_PATTERNS list, so nothing extra.
|
||||
ignorePatterns() {
|
||||
return [];
|
||||
},
|
||||
|
||||
artifacts({ project }) {
|
||||
return [
|
||||
{
|
||||
kind: 'created',
|
||||
path: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
marker: 'impeccable-live-root',
|
||||
pruneTo: 'src',
|
||||
},
|
||||
{
|
||||
kind: 'patched',
|
||||
path: project?.layoutFile || 'src/routes/+layout.svelte',
|
||||
patch: 'sveltekit-layout',
|
||||
markers: [SVELTE_LAYOUT_MARKER_OPEN],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
unpatch: {
|
||||
'sveltekit-layout': unpatchSvelteLayout,
|
||||
},
|
||||
},
|
||||
|
||||
source: {
|
||||
extensions: ['.svelte'],
|
||||
// Svelte resets component-local state on markup HMR updates, so variants
|
||||
// are mounted from generated components rather than written into the route.
|
||||
preview: 'component',
|
||||
commentSyntax: 'html',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The generic `tag` injection strategy.
|
||||
*
|
||||
* Frameworks without a dedicated adapter get a literal marker-wrapped
|
||||
* `<script src>` block written into the entry template named by
|
||||
* `.impeccable/live/config.json`. This module owns that block: building it,
|
||||
* inserting it at the configured anchor, removing it again, and the
|
||||
* Content-Security-Policy meta patch that keeps the cross-origin load allowed.
|
||||
*
|
||||
* It is deliberately framework-agnostic. Per-framework knowledge (Astro's
|
||||
* `is:inline`, for instance) arrives as the `scriptAttrs` argument, resolved
|
||||
* from the registry by the caller, so nothing here has to branch on a file
|
||||
* extension or a project shape.
|
||||
*/
|
||||
|
||||
import { buildLiveScriptSrc } from './script-src.mjs';
|
||||
|
||||
export const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
export const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
/** Markers that identify a file as still carrying our tag-strategy patch. */
|
||||
export const TAG_PATCH_MARKERS = Object.freeze([MARKER_OPEN_TEXT, 'data-impeccable-csp-original']);
|
||||
|
||||
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
|
||||
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
|
||||
|
||||
/**
|
||||
* `scriptAttrs` is a pre-rendered attribute string (trailing space included)
|
||||
* that the registry supplies for the target file. Astro is the only framework
|
||||
* that uses it today: Astro processes `<script>` tags by default and rewrites
|
||||
* src to its own bundled URL, so `is:inline ` opts out and the literal external
|
||||
* src survives.
|
||||
*/
|
||||
export function buildTagBlock(syntax, port, token, scriptAttrs = '') {
|
||||
const open = commentOpen(syntax);
|
||||
const close = commentClose(syntax);
|
||||
return (
|
||||
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
|
||||
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></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 '';
|
||||
}
|
||||
|
||||
export function insertTag(content, config, port, token, scriptAttrs = '') {
|
||||
const lineEnding = detectLineEnding(content);
|
||||
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), 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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */
|
||||
export function unpatchTagFile(content) {
|
||||
return revertCspMeta(removeTag(content));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* TanStack Start registry entry.
|
||||
*
|
||||
* Detection and the apply/remove pair are the existing adapter's
|
||||
* (`../tanstack-adapter.mjs`); this file only declares them to the registry
|
||||
* and names the artifacts the journal has to be able to heal.
|
||||
*/
|
||||
|
||||
import {
|
||||
TANSTACK_MARKER_OPEN,
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
unpatchTanStackRoot,
|
||||
} from '../tanstack-adapter.mjs';
|
||||
|
||||
export const tanstackStart = {
|
||||
name: 'tanstack-start',
|
||||
|
||||
detect(cwd) {
|
||||
return detectTanStackStartProject(cwd);
|
||||
},
|
||||
|
||||
inject: {
|
||||
kind: 'adapter',
|
||||
|
||||
apply({ cwd, port, token, project }) {
|
||||
return applyTanStackLiveAdapter({ cwd, port, token, project });
|
||||
},
|
||||
|
||||
remove({ cwd, project }) {
|
||||
return removeTanStackLiveAdapter({ cwd, project });
|
||||
},
|
||||
|
||||
// The mount component's extension follows the root route's, so the path
|
||||
// cannot live in the static ignore list.
|
||||
ignorePatterns(project) {
|
||||
return project?.componentFile ? [project.componentFile] : [];
|
||||
},
|
||||
|
||||
artifacts({ project }) {
|
||||
if (!project) return [];
|
||||
return [
|
||||
{
|
||||
kind: 'created',
|
||||
path: project.componentFile,
|
||||
marker: 'impeccable-live-tanstack',
|
||||
pruneTo: 'src',
|
||||
},
|
||||
{
|
||||
kind: 'patched',
|
||||
path: project.rootRoute,
|
||||
patch: 'tanstack-root',
|
||||
markers: [TANSTACK_MARKER_OPEN],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
unpatch: {
|
||||
'tanstack-root': unpatchTanStackRoot,
|
||||
},
|
||||
},
|
||||
|
||||
source: {
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'jsx',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Generic Vite registry entry: a bundled app with a real `index.html` entry
|
||||
* and no framework-specific document ownership. React, Vue, Solid, Preact and
|
||||
* a plain TanStack Router SPA all land here — the marker-wrapped script block
|
||||
* goes straight into the HTML entry.
|
||||
*
|
||||
* This is the entry that catches everything with a bundler config; only
|
||||
* static-html sits below it.
|
||||
*/
|
||||
|
||||
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
|
||||
|
||||
const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
||||
|
||||
export function detectViteProject(cwd = process.cwd()) {
|
||||
const configFile = findConfigFile(cwd, VITE_CONFIG_RE);
|
||||
if (configFile) return { configFile, via: 'config' };
|
||||
if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' };
|
||||
// A zero-config Vite app is index.html + package.json, the same pair
|
||||
// roots.mjs treats as an app root.
|
||||
if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) {
|
||||
return { configFile: null, via: 'zero-config' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const viteGeneric = {
|
||||
name: 'vite-generic',
|
||||
|
||||
detect(cwd) {
|
||||
return detectViteProject(cwd);
|
||||
},
|
||||
|
||||
inject: { kind: 'tag' },
|
||||
|
||||
source: {
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
preview: 'source',
|
||||
styleMode: 'scoped',
|
||||
commentSyntax: 'jsx',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user