@rozie/babel-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.cjs +87796 -0
- package/dist/index.d.cts +24 -0
- package/dist/index.d.mts +24 -0
- package/dist/index.mjs +87722 -0
- package/package.json +60 -0
- package/src/compileImport.ts +22 -0
- package/src/index.ts +114 -0
- package/src/writeSibling.ts +122 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rozie/babel-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Babel plugin for Rozie.js — resolves .rozie imports and compiles them to idiomatic target-framework code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.mts",
|
|
13
|
+
"import": "./dist/index.mjs",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"!src/**/__tests__/**",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.tsx",
|
|
23
|
+
"!src/**/*.spec.ts"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@babel/helper-plugin-utils": "^7.28.0",
|
|
27
|
+
"@rozie/core": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@babel/core": "^7.29.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@babel/core": "^7.29.0",
|
|
34
|
+
"@babel/types": "^7.29.0",
|
|
35
|
+
"@types/babel__core": "^7.20.0",
|
|
36
|
+
"@types/babel__helper-plugin-utils": "^7.10.3",
|
|
37
|
+
"tsdown": "^0.21.10",
|
|
38
|
+
"vitest": "^4.1.0"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/One-Learning-Community/rozie.js.git",
|
|
47
|
+
"directory": "packages/babel-plugin"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/One-Learning-Community/rozie.js#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/One-Learning-Community/rozie.js/issues"
|
|
52
|
+
},
|
|
53
|
+
"author": "One Learning Community (https://github.com/One-Learning-Community)",
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsdown",
|
|
56
|
+
"test": "vitest run --passWithNoTests",
|
|
57
|
+
"lint": "biome lint src",
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// compileImport — thin indirection between the visitor in index.ts and
|
|
2
|
+
// the fs-side writeSibling helper. Per CONTEXT.md recommended structure,
|
|
3
|
+
// this layer exists so future cache strategies (content-hash sidecar v2,
|
|
4
|
+
// in-memory LRU) can land without touching the visitor file or the
|
|
5
|
+
// fs primitives. v1 is a one-liner.
|
|
6
|
+
import { writeSiblingIfStale, type RozieBabelTarget } from './writeSibling.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read the .rozie at `roziePath`, compile it for `target`, and write the
|
|
10
|
+
* sibling `siblingPath` (plus React sidecars when applicable). Idempotent
|
|
11
|
+
* via mtime check. Errors are bubbled up unchanged for the visitor to
|
|
12
|
+
* re-shape into a Babel error via `path.buildCodeFrameError`.
|
|
13
|
+
*/
|
|
14
|
+
export function compileImport(
|
|
15
|
+
roziePath: string,
|
|
16
|
+
siblingPath: string,
|
|
17
|
+
target: RozieBabelTarget,
|
|
18
|
+
// Phase 23 — Angular CVA opt-out forwarded to writeSiblingIfStale → compile().
|
|
19
|
+
cva?: boolean,
|
|
20
|
+
): void {
|
|
21
|
+
writeSiblingIfStale(roziePath, siblingPath, target, cva);
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @rozie/babel-plugin — Phase 6 Plan 04 / D-92 ImportDeclaration visitor.
|
|
2
|
+
//
|
|
3
|
+
// Replaces the Phase 1 placeholder with a thin Babel plugin that:
|
|
4
|
+
// 1. Visits each `import Foo from './Foo.rozie'` declaration.
|
|
5
|
+
// 2. Resolves the absolute .rozie path via the importer's `state.filename`.
|
|
6
|
+
// 3. Calls compileImport() → writeSiblingIfStale() → @rozie/core.compile()
|
|
7
|
+
// to emit a sibling `Foo.{ext}` (and React sidecars when target='react').
|
|
8
|
+
// 4. Rewrites the import source to the sibling extension so downstream
|
|
9
|
+
// Babel passes (preset-typescript / preset-react / preset-vue) and
|
|
10
|
+
// bundler resolution (Webpack / Metro) handle it as a normal module.
|
|
11
|
+
//
|
|
12
|
+
// Targets non-Vite distributions: Webpack/babel-loader, Metro, or any
|
|
13
|
+
// Babel-driven pipeline. The Vite path remains @rozie/unplugin per D-48.
|
|
14
|
+
//
|
|
15
|
+
// Plugin order (Pitfall 3): plugins run BEFORE presets in default Babel
|
|
16
|
+
// order, so this plugin's import-source rewrite lands before
|
|
17
|
+
// preset-typescript / preset-react try to resolve `./Foo.rozie`. Document
|
|
18
|
+
// the canonical config in README.md.
|
|
19
|
+
//
|
|
20
|
+
// Errors are surfaced as Babel errors via `path.buildCodeFrameError` per
|
|
21
|
+
// D-97 — consumer sees a Babel-shaped error frame, not a raw Node throw.
|
|
22
|
+
//
|
|
23
|
+
// Source-LOC budget per D-92: ~50 LOC for this visitor file. Helpers
|
|
24
|
+
// (writeSibling.ts, compileImport.ts) carry the fs + cache logic.
|
|
25
|
+
import { declare } from '@babel/helper-plugin-utils';
|
|
26
|
+
import type { PluginObj } from '@babel/core';
|
|
27
|
+
import { resolve as pathResolve, dirname } from 'node:path';
|
|
28
|
+
import { compileImport } from './compileImport.js';
|
|
29
|
+
import type { RozieBabelTarget } from './writeSibling.js';
|
|
30
|
+
|
|
31
|
+
export interface RozieBabelPluginOptions {
|
|
32
|
+
/** Required: target framework. Selects emit branch + sibling extension. */
|
|
33
|
+
target: RozieBabelTarget;
|
|
34
|
+
/**
|
|
35
|
+
* Phase 23 — per-target emit-semantics namespace (mirrors
|
|
36
|
+
* `CompileOptions.angular`). `angular.cva` (default ON) opts out of the auto
|
|
37
|
+
* `ControlValueAccessor` emit when `false`. Omitting it preserves the
|
|
38
|
+
* emitter default-ON path byte-identically to the other entrypoints.
|
|
39
|
+
* No-op for non-Angular targets.
|
|
40
|
+
*/
|
|
41
|
+
angular?: { cva?: boolean };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** D-92 sibling-extension dispatch table. */
|
|
45
|
+
const TARGET_EXTENSIONS = {
|
|
46
|
+
vue: '.vue',
|
|
47
|
+
react: '.tsx',
|
|
48
|
+
svelte: '.svelte',
|
|
49
|
+
angular: '.ts',
|
|
50
|
+
solid: '.tsx',
|
|
51
|
+
lit: '.ts',
|
|
52
|
+
} as const satisfies Record<RozieBabelTarget, string>;
|
|
53
|
+
|
|
54
|
+
const ROZIE_EXT = '.rozie';
|
|
55
|
+
|
|
56
|
+
export default declare((api, options: RozieBabelPluginOptions): PluginObj => {
|
|
57
|
+
api.assertVersion(7);
|
|
58
|
+
|
|
59
|
+
const { target } = options;
|
|
60
|
+
// Phase 23 — Angular CVA opt-out forwarded into the compile chain. Undefined
|
|
61
|
+
// when omitted → emitter default-ON (byte-identical to the other entrypoints).
|
|
62
|
+
const cva = options.angular?.cva;
|
|
63
|
+
if (!target || !(target in TARGET_EXTENSIONS)) {
|
|
64
|
+
// ROZ820 — invalid target option. Thrown at plugin instantiation so
|
|
65
|
+
// misconfigurations fail fast (before any visitor runs).
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[ROZ820] @rozie/babel-plugin: 'target' option is required and must be one of vue|react|svelte|angular|solid|lit (got ${JSON.stringify(target)})`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const ext = TARGET_EXTENSIONS[target];
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: '@rozie/babel-plugin',
|
|
74
|
+
visitor: {
|
|
75
|
+
ImportDeclaration(path, state) {
|
|
76
|
+
const src = path.node.source.value;
|
|
77
|
+
// Phase 54 negative route: this guard is INTENTIONALLY `.rozie`-only.
|
|
78
|
+
// A `.rzts`/`.rzjs` script-partial import falls through here and is NOT
|
|
79
|
+
// intercepted — it has no standalone module surface to compile, so it
|
|
80
|
+
// must never trigger compileImport → writeSiblingIfStale (no `Foo.tsx`
|
|
81
|
+
// sibling for a partial). Partials vanish into the host's emitted module
|
|
82
|
+
// at lowerToIR inline time; the host's compiled sibling already carries
|
|
83
|
+
// the inlined declarations. Do NOT widen this guard to the partial exts.
|
|
84
|
+
if (!src.endsWith(ROZIE_EXT)) return;
|
|
85
|
+
|
|
86
|
+
// state.filename is the importer; needed to resolve the relative
|
|
87
|
+
// .rozie path. Babel always provides this when caller passes
|
|
88
|
+
// `filename` to transformAsync; if missing, surface ROZ821 with
|
|
89
|
+
// a code-frame so the consumer sees the offending import.
|
|
90
|
+
const importerFile = state.filename ?? state.file?.opts?.filename;
|
|
91
|
+
if (!importerFile) {
|
|
92
|
+
throw path.buildCodeFrameError(
|
|
93
|
+
`[ROZ821] @rozie/babel-plugin: cannot resolve relative .rozie import without state.filename`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const roziePath = pathResolve(dirname(importerFile), src);
|
|
97
|
+
const siblingPath = roziePath.slice(0, -ROZIE_EXT.length) + ext;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
compileImport(roziePath, siblingPath, target, cva);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Re-shape any error from compileImport (ROZ822 compile error
|
|
103
|
+
// or ROZ823 fs error) as a Babel error with code-frame per D-97.
|
|
104
|
+
throw path.buildCodeFrameError(`${(err as Error).message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Rewrite the import source so downstream Babel passes + the
|
|
108
|
+
// consumer's bundler resolve the sibling artifact naturally.
|
|
109
|
+
const newSrc = src.slice(0, -ROZIE_EXT.length) + ext;
|
|
110
|
+
path.node.source.value = newSrc;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// writeSibling — fs-side effects for @rozie/babel-plugin (Plan 06-04 D-92).
|
|
2
|
+
//
|
|
3
|
+
// Idempotency strategy: mtime comparison with a 100ms tolerance window.
|
|
4
|
+
// Per RESEARCH Pitfall 1 (atomic-save editor jitter on APFS/NFS), if the
|
|
5
|
+
// sibling's mtime is within 100ms BEHIND the .rozie's mtime, treat the
|
|
6
|
+
// sibling as up-to-date. False negatives (an unnecessary recompile) are
|
|
7
|
+
// preferred over false positives (silent stale output).
|
|
8
|
+
//
|
|
9
|
+
// Recovery: consumers can `rm Foo.{ext}` to force a fresh compile. The v2
|
|
10
|
+
// path is a content-hash sidecar (deferred per CONTEXT.md).
|
|
11
|
+
//
|
|
12
|
+
// All writes go through a single try/catch so any fs error becomes a
|
|
13
|
+
// stable ROZ823 diagnostic carrying the offending path.
|
|
14
|
+
//
|
|
15
|
+
// React-only sidecars: when target='react' and compile() populated
|
|
16
|
+
// result.types/css/globalCss, write Foo.d.ts / Foo.css / Foo.global.css
|
|
17
|
+
// alongside the primary Foo.tsx (Phase 25 — the scoped-CSS sibling is a plain
|
|
18
|
+
// stylesheet, not a CSS-Modules sidecar; React no longer routes scoped <style>
|
|
19
|
+
// through CSS Modules). Other targets only receive the primary artifact
|
|
20
|
+
// (D-84 inline-typed).
|
|
21
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
// WR-03 — import compile() via RELATIVE path into the sibling workspace package,
|
|
23
|
+
// matching every other compile() entrypoint (compile.ts, unplugin/transform.ts,
|
|
24
|
+
// cli/commands/*, dist-parity). The package-name form (`@rozie/core`) can
|
|
25
|
+
// resolve to a stale built dist/ while the others resolve to live source —
|
|
26
|
+
// under which the Phase 23 dist-parity byte-equality contract
|
|
27
|
+
// (FIXTURE_ANGULAR_CVA_OFF across all four entrypoints) could pass in CI yet
|
|
28
|
+
// drift in a real babel consumer build. tsdown inlines the workspace sibling
|
|
29
|
+
// when bundling (the strategy proven by @rozie/unplugin — see compile.ts:30-37).
|
|
30
|
+
import { compile } from '../../core/src/compile.js';
|
|
31
|
+
|
|
32
|
+
/** Editor atomic-save jitter window per RESEARCH Pitfall 1. */
|
|
33
|
+
const MTIME_TOLERANCE_MS = 100;
|
|
34
|
+
|
|
35
|
+
export type RozieBabelTarget = 'vue' | 'react' | 'svelte' | 'angular' | 'solid' | 'lit';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compile the .rozie at `roziePath` and write its sibling `siblingPath`,
|
|
39
|
+
* skipping the work entirely when the sibling appears up-to-date.
|
|
40
|
+
*
|
|
41
|
+
* @param roziePath Absolute path to the source .rozie file on disk.
|
|
42
|
+
* @param siblingPath Absolute path to the target sibling (e.g. Foo.tsx).
|
|
43
|
+
* @param target Target framework — selects emit branch and sidecar layout.
|
|
44
|
+
*
|
|
45
|
+
* @throws Error with `[ROZ822]` prefix when compile() reports any error
|
|
46
|
+
* severity diagnostic. The message embeds each [ROZxxx] code so the
|
|
47
|
+
* babel-plugin caller can buildCodeFrameError around it.
|
|
48
|
+
* @throws Error with `[ROZ823]` prefix when fs writeFileSync fails.
|
|
49
|
+
*/
|
|
50
|
+
export function writeSiblingIfStale(
|
|
51
|
+
roziePath: string,
|
|
52
|
+
siblingPath: string,
|
|
53
|
+
target: RozieBabelTarget,
|
|
54
|
+
// Phase 23 — Angular CVA opt-out. Undefined → emitter default-ON path,
|
|
55
|
+
// byte-identical to compile()/CLI/unplugin when omitted.
|
|
56
|
+
cva?: boolean,
|
|
57
|
+
): void {
|
|
58
|
+
// Idempotency check — if sibling is fresher than .rozie (or within the
|
|
59
|
+
// 100ms tolerance window), skip. Saves the compile() cost on hot paths
|
|
60
|
+
// (HMR, rebuild after unrelated edit, etc).
|
|
61
|
+
const rozieStat = statSync(roziePath);
|
|
62
|
+
if (existsSync(siblingPath)) {
|
|
63
|
+
const sibStat = statSync(siblingPath);
|
|
64
|
+
if (sibStat.mtimeMs >= rozieStat.mtimeMs - MTIME_TOLERANCE_MS) {
|
|
65
|
+
return; // up-to-date within tolerance
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Compile via the @rozie/core single source of truth (D-93 byte-identical).
|
|
70
|
+
const source = readFileSync(roziePath, 'utf8');
|
|
71
|
+
const result = compile(source, {
|
|
72
|
+
target,
|
|
73
|
+
filename: roziePath,
|
|
74
|
+
// Phase 23 — attach the `angular` namespace only on opt-out so the
|
|
75
|
+
// default-ON path stays byte-identical to the other entrypoints.
|
|
76
|
+
...(cva === false ? { angular: { cva: false } } : {}),
|
|
77
|
+
});
|
|
78
|
+
const errors = result.diagnostics.filter((d) => d.severity === 'error');
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
const detail = errors.map((d) => `[${d.code}] ${d.message}`).join('; ');
|
|
81
|
+
throw new Error(`[ROZ822] @rozie/babel-plugin: compile failed: ${detail}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Write phase — primary artifact + react sidecars in a single try/catch.
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(siblingPath, result.code, 'utf8');
|
|
87
|
+
if (target === 'react') {
|
|
88
|
+
// WR-03: assert the canonical .tsx invariant before deriving sidecar
|
|
89
|
+
// paths. `String.prototype.replace(/\.tsx$/, …)` is a no-op on a path
|
|
90
|
+
// that doesn't end in `.tsx`, which would silently overwrite the
|
|
91
|
+
// primary `.tsx` with the .d.ts/.css text. The visitor in `index.ts`
|
|
92
|
+
// is the only known caller and feeds canonical extensions via
|
|
93
|
+
// TARGET_EXTENSIONS, so this is defense-in-depth — bailing loudly is
|
|
94
|
+
// safer than silent corruption if a future caller drifts.
|
|
95
|
+
if (!siblingPath.endsWith('.tsx')) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[ROZ823] @rozie/babel-plugin: react sibling path must end with .tsx, got ${siblingPath}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const stem = siblingPath.slice(0, -'.tsx'.length);
|
|
101
|
+
if (result.types) {
|
|
102
|
+
writeFileSync(`${stem}.d.ts`, result.types, 'utf8');
|
|
103
|
+
}
|
|
104
|
+
if (result.css) {
|
|
105
|
+
// Phase 25 — plain `.css` sidecar (no longer a CSS-Modules sidecar).
|
|
106
|
+
// React no longer routes scoped `<style>` through CSS Modules; the
|
|
107
|
+
// emitted `.tsx` body imports it for side effect via `import './Foo.css'`,
|
|
108
|
+
// and class isolation is the `[data-rozie-s-HASH]` attribute selector
|
|
109
|
+
// inside the CSS. The bundler treats the plain `.css` as a global
|
|
110
|
+
// stylesheet.
|
|
111
|
+
writeFileSync(`${stem}.css`, result.css, 'utf8');
|
|
112
|
+
}
|
|
113
|
+
if (result.globalCss) {
|
|
114
|
+
writeFileSync(`${stem}.global.css`, result.globalCss, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[ROZ823] @rozie/babel-plugin: failed to write sibling ${siblingPath}: ${(err as Error).message}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|