@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/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
+ }