Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@harnessa-fe/unplugin

Package Overview
Dependencies
Maintainers
1
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@harnessa-fe/unplugin - npm Package Compare versions

Comparing version
1.0.2
to
2.0.0
+16
-0
dist/core.d.ts

@@ -34,4 +34,20 @@ /**

buildId?: string;
/**
* Token to authenticate against the daemon when it's bound to a non-
* loopback host. Appended as `?token=…` to the WS URL and propagated
* to the runtime client via `__HARNESSA_FE__`. Read from
* `HARNESSA_FE_TOKEN` when omitted.
*/
token?: string;
/**
* Vue SFC transform safety: when true (default), the plugin re-parses
* its own output to catch any mis-aligned attribute injection — old
* Vue 2 syntax (`{{ x | filter }}`, `<template functional>`, …) is
* silently dropped instead of risking a corrupt template fed to
* vue-loader. Set to false only if you've measured the perf overhead
* and your project is pure Vue 3.
*/
safeMode?: boolean;
}
export declare const unpluginFactory: UnpluginFactory<HarnessaFEOptions | undefined>;
export declare const unplugin: import("unplugin").UnpluginInstance<HarnessaFEOptions | undefined, boolean>;
+45
-5

@@ -28,3 +28,3 @@ /**

import { resolveBuildId } from './resolveBuildId.js';
import { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, } from './vue-transform.js';
import { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, createVueTransformStats, formatVueTransformReport, } from './vue-transform.js';
import { resolveProjectId } from './resolveProjectId.js';

@@ -35,2 +35,11 @@ function newId() {

}
/** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */
function appendTokenQuery(url, token) {
if (!token)
return url;
if (/[?&]token=/.test(url))
return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${encodeURIComponent(token)}`;
}
/**

@@ -63,3 +72,5 @@ * Intercepts `process.stdout.write` and `process.stderr.write` to emit

// agree on which socket to use even when mcp.json overrides the default.
const mcpUrl = options.mcpUrl ?? process.env.HARNESSA_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
const baseMcpUrl = options.mcpUrl ?? process.env.HARNESSA_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
const token = options.token ?? process.env.HARNESSA_FE_TOKEN;
const mcpUrl = appendTokenQuery(baseMcpUrl, token);
let ws;

@@ -71,2 +82,28 @@ let isActive = false;

let logCaptureCleanup;
// Vue 2 hardening — safeMode on by default, dry-run gated by env so
// legacy projects can collect a coverage report before flipping the
// plugin on for real.
const dryRun = process.env.HARNESSA_FE_DRY_RUN === '1';
const vueStats = createVueTransformStats();
const vueOptions = {
safeMode: options.safeMode !== false,
dryRun,
stats: vueStats,
};
let dumpReportInstalled = false;
function ensureExitReport() {
if (dumpReportInstalled)
return;
dumpReportInstalled = true;
const dump = () => {
if (vueStats.filesAttempted === 0)
return;
process.stderr.write(formatVueTransformReport(vueStats) + '\n');
};
process.once('exit', dump);
process.once('SIGINT', () => { dump(); process.exit(0); });
process.once('SIGTERM', () => { dump(); process.exit(0); });
}
if (dryRun)
ensureExitReport();
// Build identity — resolved lazily from projectRoot once it's known.

@@ -166,3 +203,6 @@ // The startTs is captured once so dev-mode fallback ids stay stable for

try {
ws = new WebSocket(mcpUrl);
const headers = {};
if (token)
headers.authorization = `Bearer ${token}`;
ws = new WebSocket(mcpUrl, { headers });
ws.on('open', () => {

@@ -284,3 +324,3 @@ const hello = {

}
const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset);
const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset, vueOptions);
if (!out)

@@ -296,3 +336,3 @@ return null;

if (filePath.endsWith('.vue') && !query) {
const out = transformVueSFC(code, rel, componentMap);
const out = transformVueSFC(code, rel, componentMap, vueOptions);
if (!out)

@@ -299,0 +339,0 @@ return null;

@@ -21,2 +21,36 @@ /**

/**
* Counters maintained across calls — populated even in dry-run mode. The
* unplugin core attaches a single instance per dev-server lifetime and
* dumps it on process exit so users can see how many Vue 2-era files were
* skipped (filter syntax, functional templates, malformed offsets, …).
*/
export interface VueTransformStats {
filesAttempted: number;
filesInjected: number;
elementsTagged: number;
skippedSfcError: number;
skippedTemplateError: number;
skippedWalkError: number;
skippedSelfCheck: number;
/** Sample of skipped file paths (capped at 50 to bound memory). */
skippedPaths: string[];
}
export declare function createVueTransformStats(): VueTransformStats;
export interface VueTransformOptions {
/**
* When true (default), the transform re-parses its own output before
* returning it. Catches MagicString offset bugs against malformed Vue
* 2-era syntax before vue-loader ever sees them.
*/
safeMode?: boolean;
/**
* When true, walk the AST and populate the componentMap as usual, but
* always return null (no source injection). Used by the dry-run
* coverage report.
*/
dryRun?: boolean;
/** Counters to update; ignored if omitted. */
stats?: VueTransformStats;
}
/**
* Inject `data-morphix-*` attributes into a raw Vue template HTML fragment.

@@ -33,3 +67,3 @@ *

*/
export declare function transformVueTemplate(templateSource: string, relPath: string, componentName: string | undefined, componentMap: ComponentMap, lineOffset?: number): {
export declare function transformVueTemplate(templateSource: string, relPath: string, componentName: string | undefined, componentMap: ComponentMap, lineOffset?: number, options?: VueTransformOptions): {
code: string;

@@ -53,2 +87,7 @@ map?: object;

export declare function getTemplateLineOffset(source: string, relPath: string): number;
export declare function transformVueSFC(source: string, relPath: string, componentMap: ComponentMap): VueTransformResult | null;
export declare function transformVueSFC(source: string, relPath: string, componentMap: ComponentMap, options?: VueTransformOptions): VueTransformResult | null;
/**
* Format the stats counter for a human-readable shutdown report. Used by
* the unplugin core's process-exit handler.
*/
export declare function formatVueTransformReport(stats: VueTransformStats): string;

@@ -16,2 +16,23 @@ /**

import MagicString from 'magic-string';
export function createVueTransformStats() {
return {
filesAttempted: 0,
filesInjected: 0,
elementsTagged: 0,
skippedSfcError: 0,
skippedTemplateError: 0,
skippedWalkError: 0,
skippedSelfCheck: 0,
skippedPaths: [],
};
}
const SKIP_PATH_CAP = 50;
function recordSkip(stats, kind, relPath) {
if (!stats)
return;
stats[kind] += 1;
if (stats.skippedPaths.length < SKIP_PATH_CAP) {
stats.skippedPaths.push(relPath);
}
}
const ATTR_COMP = 'data-morphix-comp';

@@ -80,3 +101,7 @@ const ATTR_LOC = 'data-morphix-loc';

*/
export function transformVueTemplate(templateSource, relPath, componentName, componentMap, lineOffset = 0) {
export function transformVueTemplate(templateSource, relPath, componentName, componentMap, lineOffset = 0, options = {}) {
const safeMode = options.safeMode !== false;
const stats = options.stats;
if (stats)
stats.filesAttempted++;
let ast;

@@ -88,2 +113,3 @@ try {

console.warn(`[harnessa-fe] Failed to parse Vue template fragment: ${relPath}`, err);
recordSkip(stats, 'skippedTemplateError', relPath);
return null;

@@ -121,8 +147,38 @@ }

}
for (const child of ast.children)
walkNode(child);
try {
for (const child of ast.children)
walkNode(child);
}
catch (err) {
console.warn(`[harnessa-fe] template walk failed in ${relPath}`, err);
recordSkip(stats, 'skippedWalkError', relPath);
return null;
}
if (taggedCount === 0)
return null;
const code = magic.toString();
// SafeMode self-check: re-parse our output to make sure we didn't
// produce something vue-loader will choke on. Cheap insurance — Vue 2
// legacy syntax is the typical reason this fires.
if (safeMode) {
try {
parseTemplate(code);
}
catch (err) {
console.warn(`[harnessa-fe] safeMode dropped template injection in ${relPath} (self-check failed)`, err);
recordSkip(stats, 'skippedSelfCheck', relPath);
return null;
}
}
if (options.dryRun) {
if (stats)
stats.elementsTagged += taggedCount;
return null;
}
if (stats) {
stats.filesInjected++;
stats.elementsTagged += taggedCount;
}
return {
code: magic.toString(),
code,
map: magic.generateMap({ hires: true, source: relPath, includeContent: true }),

@@ -167,9 +223,17 @@ taggedCount,

}
export function transformVueSFC(source, relPath, componentMap) {
// Parse the SFC
export function transformVueSFC(source, relPath, componentMap, options = {}) {
const safeMode = options.safeMode !== false;
const stats = options.stats;
if (stats)
stats.filesAttempted++;
let descriptor;
try {
const result = parseSFC(source, { filename: relPath });
// Strict downgrade: if @vue/compiler-sfc surfaces any errors we don't
// trust the offsets it reports either. Skip the file entirely so
// vue-loader sees pristine source.
if (result.errors.length > 0) {
console.warn(`[harnessa-fe] Vue SFC parse errors in ${relPath}:`, result.errors);
recordSkip(stats, 'skippedSfcError', relPath);
return null;
}

@@ -180,10 +244,8 @@ descriptor = result.descriptor;

console.warn(`[harnessa-fe] Failed to parse Vue SFC: ${relPath}`, err);
recordSkip(stats, 'skippedSfcError', relPath);
return null;
}
// Must have a template block
if (!descriptor.template) {
if (!descriptor.template)
return null;
}
const componentName = resolveComponentName(descriptor, relPath);
// Parse the template AST using @vue/compiler-dom
const templateContent = descriptor.template.content;

@@ -196,2 +258,3 @@ let templateAst;

console.warn(`[harnessa-fe] Failed to parse template in ${relPath}`, err);
recordSkip(stats, 'skippedTemplateError', relPath);
return null;

@@ -202,3 +265,2 @@ }

let taggedCount = 0;
// Walk the AST and inject attributes on element nodes
function walkNode(node) {

@@ -209,21 +271,14 @@ if (node.type === NODE_ELEMENT && node.tag) {

const locValue = `${relPath}:${line}:${col}`;
// Check if attributes already exist
const hasLoc = node.props?.some((p) => p.name === ATTR_LOC) ?? false;
const hasComp = node.props?.some((p) => p.name === ATTR_COMP) ?? false;
const attrs = [];
if (!hasLoc) {
if (!hasLoc)
attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`);
}
if (!hasComp && componentName) {
if (!hasComp && componentName)
attrs.push(`${ATTR_COMP}="${escapeAttr(componentName)}"`);
}
if (attrs.length > 0) {
// Insert after the tag name in the original source
// The tag starts at node.loc.start.offset in the template content
// In the full source, add templateOffset
const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length; // +1 for '<'
const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length;
magic.appendLeft(tagNameEnd, ' ' + attrs.join(' '));
taggedCount++;
}
// Register in component map
if (componentName) {

@@ -235,16 +290,45 @@ const entries = componentMap.get(componentName) ?? [];

}
// Recurse into children
if (node.children) {
for (const child of node.children) {
for (const child of node.children)
walkNode(child);
}
}
}
for (const child of templateAst.children) {
walkNode(child);
try {
for (const child of templateAst.children)
walkNode(child);
}
catch (err) {
console.warn(`[harnessa-fe] SFC walk failed in ${relPath}`, err);
recordSkip(stats, 'skippedWalkError', relPath);
return null;
}
if (taggedCount === 0)
return null;
const code = magic.toString();
if (safeMode) {
try {
const recheck = parseSFC(code, { filename: relPath });
if (recheck.errors.length > 0) {
console.warn(`[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check found errors)`, recheck.errors);
recordSkip(stats, 'skippedSelfCheck', relPath);
return null;
}
}
catch (err) {
console.warn(`[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check threw)`, err);
recordSkip(stats, 'skippedSelfCheck', relPath);
return null;
}
}
if (options.dryRun) {
if (stats)
stats.elementsTagged += taggedCount;
return null;
}
if (stats) {
stats.filesInjected++;
stats.elementsTagged += taggedCount;
}
return {
code: magic.toString(),
code,
map: magic.generateMap({ hires: true, source: relPath, includeContent: true }),

@@ -255,1 +339,23 @@ taggedCount,

}
/**
* Format the stats counter for a human-readable shutdown report. Used by
* the unplugin core's process-exit handler.
*/
export function formatVueTransformReport(stats) {
const lines = [
'[harnessa-fe] Vue transform coverage report',
` files attempted: ${stats.filesAttempted}`,
` files injected: ${stats.filesInjected}`,
` elements tagged: ${stats.elementsTagged}`,
` skipped (SFC error): ${stats.skippedSfcError}`,
` skipped (template): ${stats.skippedTemplateError}`,
` skipped (walk error): ${stats.skippedWalkError}`,
` skipped (self-check): ${stats.skippedSelfCheck}`,
];
if (stats.skippedPaths.length > 0) {
lines.push(` first ${Math.min(stats.skippedPaths.length, 20)} skipped paths:`);
for (const p of stats.skippedPaths.slice(0, 20))
lines.push(` ${p}`);
}
return lines.join('\n');
}
+2
-2
{
"name": "@harnessa-fe/unplugin",
"version": "1.0.2",
"version": "2.0.0",
"description": "Unified build plugin for Harnessa-FE. Supports Vite, Webpack, Rspack, esbuild, and Rollup via unplugin.",

@@ -59,3 +59,3 @@ "type": "module",

"ws": "^8.18.0",
"@harnessa-fe/protocol": "1.0.2"
"@harnessa-fe/protocol": "2.0.0"
},

@@ -62,0 +62,0 @@ "devDependencies": {

@@ -46,2 +46,5 @@ /**

getTemplateLineOffset,
createVueTransformStats,
formatVueTransformReport,
type VueTransformOptions,
} from './vue-transform.js';

@@ -72,2 +75,18 @@ import { resolveProjectId } from './resolveProjectId.js';

buildId?: string;
/**
* Token to authenticate against the daemon when it's bound to a non-
* loopback host. Appended as `?token=…` to the WS URL and propagated
* to the runtime client via `__HARNESSA_FE__`. Read from
* `HARNESSA_FE_TOKEN` when omitted.
*/
token?: string;
/**
* Vue SFC transform safety: when true (default), the plugin re-parses
* its own output to catch any mis-aligned attribute injection — old
* Vue 2 syntax (`{{ x | filter }}`, `<template functional>`, …) is
* silently dropped instead of risking a corrupt template fed to
* vue-loader. Set to false only if you've measured the perf overhead
* and your project is pure Vue 3.
*/
safeMode?: boolean;
}

@@ -80,2 +99,10 @@

/** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */
function appendTokenQuery(url: string, token: string | undefined): string {
if (!token) return url;
if (/[?&]token=/.test(url)) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${encodeURIComponent(token)}`;
}
/**

@@ -111,4 +138,6 @@ * Intercepts `process.stdout.write` and `process.stderr.write` to emit

// agree on which socket to use even when mcp.json overrides the default.
const mcpUrl =
const baseMcpUrl =
options.mcpUrl ?? process.env.HARNESSA_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
const token = options.token ?? process.env.HARNESSA_FE_TOKEN;
const mcpUrl = appendTokenQuery(baseMcpUrl, token);
let ws: WebSocket | undefined;

@@ -121,2 +150,26 @@ let isActive = false;

// Vue 2 hardening — safeMode on by default, dry-run gated by env so
// legacy projects can collect a coverage report before flipping the
// plugin on for real.
const dryRun = process.env.HARNESSA_FE_DRY_RUN === '1';
const vueStats = createVueTransformStats();
const vueOptions: VueTransformOptions = {
safeMode: options.safeMode !== false,
dryRun,
stats: vueStats,
};
let dumpReportInstalled = false;
function ensureExitReport(): void {
if (dumpReportInstalled) return;
dumpReportInstalled = true;
const dump = () => {
if (vueStats.filesAttempted === 0) return;
process.stderr.write(formatVueTransformReport(vueStats) + '\n');
};
process.once('exit', dump);
process.once('SIGINT', () => { dump(); process.exit(0); });
process.once('SIGTERM', () => { dump(); process.exit(0); });
}
if (dryRun) ensureExitReport();
// Build identity — resolved lazily from projectRoot once it's known.

@@ -216,3 +269,5 @@ // The startTs is captured once so dev-mode fallback ids stay stable for

try {
ws = new WebSocket(mcpUrl);
const headers: Record<string, string> = {};
if (token) headers.authorization = `Bearer ${token}`;
ws = new WebSocket(mcpUrl, { headers });
ws.on('open', () => {

@@ -331,3 +386,3 @@ const hello: HelloFrame = {

}
const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset);
const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset, vueOptions);
if (!out) return null;

@@ -343,3 +398,3 @@ return { code: out.code, map: out.map as any };

if (filePath.endsWith('.vue') && !query) {
const out = transformVueSFC(code, rel, componentMap);
const out = transformVueSFC(code, rel, componentMap, vueOptions);
if (!out) return null;

@@ -346,0 +401,0 @@ return { code: out.code, map: out.map as any };

@@ -270,1 +270,130 @@ import { describe, it, expect } from 'vitest';

});
// ─── Vue 2 hardening — pathological inputs must not break the build ────────
describe('Vue 2 legacy syntax — must never throw, must never corrupt output', () => {
it('Vue 2 filter syntax {{ x | foo }} is handled without throwing', () => {
// @vue/compiler-dom in Vue 3 either errors or treats `|` as bitwise.
// Either way: never throw, never emit a broken template.
const source = `<template>
<div>{{ message | uppercase }}</div>
</template>
<script>export default { name: 'LegacyFilter' };</script>
`;
const map = makeMap();
expect(() => transformVueSFC(source, 'src/LegacyFilter.vue', map)).not.toThrow();
});
it('<template functional> functional component does not throw', () => {
const source = `<template functional>
<div>{{ props.value }}</div>
</template>
`;
const map = makeMap();
expect(() => transformVueSFC(source, 'src/Func.vue', map)).not.toThrow();
});
it('v-bind.sync attribute parses through (Vue 2 modifier kept as attribute)', () => {
const source = `<template>
<input :value.sync="model" />
</template>
<script>export default { name: 'SyncInput' };</script>
`;
const map = makeMap();
const result = transformVueSFC(source, 'src/SyncInput.vue', map);
// .sync is no longer a real Vue 3 modifier but it's a valid attribute
// string from the parser's perspective. The element still gets tagged.
expect(result).not.toBeNull();
expect(result!.code).toContain('data-morphix-loc=');
});
it('slot="x" / slot-scope still allow tagging the host element', () => {
const source = `<template>
<div>
<child>
<template slot="header" slot-scope="props">
<span>{{ props.title }}</span>
</template>
</child>
</div>
</template>
<script>export default { name: 'SlotHost' };</script>
`;
const map = makeMap();
expect(() => transformVueSFC(source, 'src/SlotHost.vue', map)).not.toThrow();
});
it('safeMode (default) returns null on synthesised malformed SFC', () => {
// Real-world miss: SFC with unbalanced template that compiler-sfc may
// partially accept. Guarded by safeMode self-check.
const source = `<template>
<div><span></div></span>
</template>
`;
const map = makeMap();
const result = transformVueSFC(source, 'src/Bad.vue', map);
// Either returned null OR returned a result whose code re-parses
// cleanly. The contract: never throw, never hand back broken output.
if (result) {
expect(() => {
// Cheap sanity check: there should be the same number of
// injected attrs as opening tags.
const count = (result.code.match(/data-morphix-loc=/g) ?? []).length;
expect(count).toBeGreaterThanOrEqual(0);
}).not.toThrow();
}
});
it('updates stats counters for skipped files', () => {
const stats = {
filesAttempted: 0,
filesInjected: 0,
elementsTagged: 0,
skippedSfcError: 0,
skippedTemplateError: 0,
skippedWalkError: 0,
skippedSelfCheck: 0,
skippedPaths: [] as string[],
};
const source = `<template>
<div>{{ x | filter }}</div>
</template>
`;
const map = makeMap();
// safeMode on by default — filter syntax should NOT throw.
transformVueSFC(source, 'src/Filter.vue', map, { stats });
expect(stats.filesAttempted).toBe(1);
// We don't assert which counter incremented — different compiler
// versions classify filters differently — only that at most one
// skip counter went up (or it injected cleanly).
const totalSkips =
stats.skippedSfcError + stats.skippedTemplateError +
stats.skippedWalkError + stats.skippedSelfCheck;
expect(totalSkips + stats.filesInjected).toBe(1);
});
it('dryRun=true populates componentMap but returns null', () => {
const source = `<template>
<div><span>hi</span></div>
</template>
<script>export default { name: 'DryRunVue' };</script>
`;
const map = makeMap();
const result = transformVueSFC(source, 'src/DryRun.vue', map, { dryRun: true });
expect(result).toBeNull();
// Component map still populated so source-aware tools work in dry-run.
expect(map.has('DryRunVue')).toBe(true);
});
it('safeMode=false skips the self-check', () => {
// Smoke test: same input passes through both modes without throwing.
const source = `<template><div>x</div></template>
<script>export default { name: 'NoCheck' };</script>
`;
const map = makeMap();
const safe = transformVueSFC(source, 'src/NoCheck.vue', map);
const unsafe = transformVueSFC(source, 'src/NoCheck.vue', makeMap(), { safeMode: false });
expect(safe).not.toBeNull();
expect(unsafe).not.toBeNull();
});
});

@@ -26,2 +26,62 @@ /**

/**
* Counters maintained across calls — populated even in dry-run mode. The
* unplugin core attaches a single instance per dev-server lifetime and
* dumps it on process exit so users can see how many Vue 2-era files were
* skipped (filter syntax, functional templates, malformed offsets, …).
*/
export interface VueTransformStats {
filesAttempted: number;
filesInjected: number;
elementsTagged: number;
skippedSfcError: number;
skippedTemplateError: number;
skippedWalkError: number;
skippedSelfCheck: number;
/** Sample of skipped file paths (capped at 50 to bound memory). */
skippedPaths: string[];
}
export function createVueTransformStats(): VueTransformStats {
return {
filesAttempted: 0,
filesInjected: 0,
elementsTagged: 0,
skippedSfcError: 0,
skippedTemplateError: 0,
skippedWalkError: 0,
skippedSelfCheck: 0,
skippedPaths: [],
};
}
export interface VueTransformOptions {
/**
* When true (default), the transform re-parses its own output before
* returning it. Catches MagicString offset bugs against malformed Vue
* 2-era syntax before vue-loader ever sees them.
*/
safeMode?: boolean;
/**
* When true, walk the AST and populate the componentMap as usual, but
* always return null (no source injection). Used by the dry-run
* coverage report.
*/
dryRun?: boolean;
/** Counters to update; ignored if omitted. */
stats?: VueTransformStats;
}
const SKIP_PATH_CAP = 50;
type SkipKind = 'skippedSfcError' | 'skippedTemplateError' | 'skippedWalkError' | 'skippedSelfCheck';
function recordSkip(stats: VueTransformStats | undefined, kind: SkipKind, relPath: string): void {
if (!stats) return;
stats[kind] += 1;
if (stats.skippedPaths.length < SKIP_PATH_CAP) {
stats.skippedPaths.push(relPath);
}
}
const ATTR_COMP = 'data-morphix-comp';

@@ -120,3 +180,8 @@ const ATTR_LOC = 'data-morphix-loc';

lineOffset: number = 0,
options: VueTransformOptions = {},
): { code: string; map?: object; taggedCount: number } | null {
const safeMode = options.safeMode !== false;
const stats = options.stats;
if (stats) stats.filesAttempted++;
let ast;

@@ -127,2 +192,3 @@ try {

console.warn(`[harnessa-fe] Failed to parse Vue template fragment: ${relPath}`, err);
recordSkip(stats, 'skippedTemplateError', relPath);
return null;

@@ -164,8 +230,42 @@ }

for (const child of ast.children) walkNode(child as TemplateNode);
try {
for (const child of ast.children) walkNode(child as TemplateNode);
} catch (err) {
console.warn(`[harnessa-fe] template walk failed in ${relPath}`, err);
recordSkip(stats, 'skippedWalkError', relPath);
return null;
}
if (taggedCount === 0) return null;
const code = magic.toString();
// SafeMode self-check: re-parse our output to make sure we didn't
// produce something vue-loader will choke on. Cheap insurance — Vue 2
// legacy syntax is the typical reason this fires.
if (safeMode) {
try {
parseTemplate(code);
} catch (err) {
console.warn(
`[harnessa-fe] safeMode dropped template injection in ${relPath} (self-check failed)`,
err,
);
recordSkip(stats, 'skippedSelfCheck', relPath);
return null;
}
}
if (options.dryRun) {
if (stats) stats.elementsTagged += taggedCount;
return null;
}
if (stats) {
stats.filesInjected++;
stats.elementsTagged += taggedCount;
}
return {
code: magic.toString(),
code,
map: magic.generateMap({ hires: true, source: relPath, includeContent: true }),

@@ -214,9 +314,18 @@ taggedCount,

componentMap: ComponentMap,
options: VueTransformOptions = {},
): VueTransformResult | null {
// Parse the SFC
const safeMode = options.safeMode !== false;
const stats = options.stats;
if (stats) stats.filesAttempted++;
let descriptor;
try {
const result = parseSFC(source, { filename: relPath });
// Strict downgrade: if @vue/compiler-sfc surfaces any errors we don't
// trust the offsets it reports either. Skip the file entirely so
// vue-loader sees pristine source.
if (result.errors.length > 0) {
console.warn(`[harnessa-fe] Vue SFC parse errors in ${relPath}:`, result.errors);
recordSkip(stats, 'skippedSfcError', relPath);
return null;
}

@@ -226,13 +335,10 @@ descriptor = result.descriptor;

console.warn(`[harnessa-fe] Failed to parse Vue SFC: ${relPath}`, err);
recordSkip(stats, 'skippedSfcError', relPath);
return null;
}
// Must have a template block
if (!descriptor.template) {
return null;
}
if (!descriptor.template) return null;
const componentName = resolveComponentName(descriptor, relPath);
// Parse the template AST using @vue/compiler-dom
const templateContent = descriptor.template.content;

@@ -244,2 +350,3 @@ let templateAst;

console.warn(`[harnessa-fe] Failed to parse template in ${relPath}`, err);
recordSkip(stats, 'skippedTemplateError', relPath);
return null;

@@ -252,3 +359,2 @@ }

// Walk the AST and inject attributes on element nodes
function walkNode(node: TemplateNode): void {

@@ -260,3 +366,2 @@ if (node.type === NODE_ELEMENT && node.tag) {

// Check if attributes already exist
const hasLoc = node.props?.some((p) => p.name === ATTR_LOC) ?? false;

@@ -266,14 +371,8 @@ const hasComp = node.props?.some((p) => p.name === ATTR_COMP) ?? false;

const attrs: string[] = [];
if (!hasLoc) {
attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`);
}
if (!hasComp && componentName) {
if (!hasLoc) attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`);
if (!hasComp && componentName)
attrs.push(`${ATTR_COMP}="${escapeAttr(componentName)}"`);
}
if (attrs.length > 0) {
// Insert after the tag name in the original source
// The tag starts at node.loc.start.offset in the template content
// In the full source, add templateOffset
const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length; // +1 for '<'
const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length;
magic.appendLeft(tagNameEnd, ' ' + attrs.join(' '));

@@ -283,3 +382,2 @@ taggedCount++;

// Register in component map
if (componentName) {

@@ -292,12 +390,13 @@ const entries = componentMap.get(componentName) ?? [];

// Recurse into children
if (node.children) {
for (const child of node.children) {
walkNode(child);
}
for (const child of node.children) walkNode(child);
}
}
for (const child of templateAst.children) {
walkNode(child as TemplateNode);
try {
for (const child of templateAst.children) walkNode(child as TemplateNode);
} catch (err) {
console.warn(`[harnessa-fe] SFC walk failed in ${relPath}`, err);
recordSkip(stats, 'skippedWalkError', relPath);
return null;
}

@@ -307,4 +406,37 @@

const code = magic.toString();
if (safeMode) {
try {
const recheck = parseSFC(code, { filename: relPath });
if (recheck.errors.length > 0) {
console.warn(
`[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check found errors)`,
recheck.errors,
);
recordSkip(stats, 'skippedSelfCheck', relPath);
return null;
}
} catch (err) {
console.warn(
`[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check threw)`,
err,
);
recordSkip(stats, 'skippedSelfCheck', relPath);
return null;
}
}
if (options.dryRun) {
if (stats) stats.elementsTagged += taggedCount;
return null;
}
if (stats) {
stats.filesInjected++;
stats.elementsTagged += taggedCount;
}
return {
code: magic.toString(),
code,
map: magic.generateMap({ hires: true, source: relPath, includeContent: true }),

@@ -315,1 +447,23 @@ taggedCount,

}
/**
* Format the stats counter for a human-readable shutdown report. Used by
* the unplugin core's process-exit handler.
*/
export function formatVueTransformReport(stats: VueTransformStats): string {
const lines = [
'[harnessa-fe] Vue transform coverage report',
` files attempted: ${stats.filesAttempted}`,
` files injected: ${stats.filesInjected}`,
` elements tagged: ${stats.elementsTagged}`,
` skipped (SFC error): ${stats.skippedSfcError}`,
` skipped (template): ${stats.skippedTemplateError}`,
` skipped (walk error): ${stats.skippedWalkError}`,
` skipped (self-check): ${stats.skippedSelfCheck}`,
];
if (stats.skippedPaths.length > 0) {
lines.push(` first ${Math.min(stats.skippedPaths.length, 20)} skipped paths:`);
for (const p of stats.skippedPaths.slice(0, 20)) lines.push(` ${p}`);
}
return lines.join('\n');
}