diff --git a/.gitignore b/.gitignore index 709c948..533b16c 100644 --- a/.gitignore +++ b/.gitignore @@ -83,8 +83,11 @@ out dist .output -# Mini JSX temporary files -.mini-jsx-tmp.js +# Ono per-build temp directory +.ono/ + +# Generated barrel files in the example +packages/example/barrels/*.ts # Gatsby files .cache/ diff --git a/ONO.md b/ONO.md index a5d5dcb..2e89381 100644 --- a/ONO.md +++ b/ONO.md @@ -97,7 +97,8 @@ export default function Page() { ```js import { renderToString } from "@hashrock/ono/renderer"; -import { bundle } from "@hashrock/ono/bundler"; +import { transformJSX } from "@hashrock/ono/transformer"; +import { bundle } from "@hashrock/ono/bundler"; // ブラウザ互換ミニバンドラ ``` ## 重要なポイント diff --git a/README.md b/README.md index 21e83df..e7f0eec 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ OnoはAstroのミニマルな代替として設計されており、TypeScript | [@hashrock/ono](./packages/ono) | CLIを備えたコアSSGフレームワーク | [![npm](https://img.shields.io/npm/v/@hashrock/ono)](https://www.npmjs.com/package/@hashrock/ono) | | [create-ono](./packages/create-ono) | プロジェクトスキャフォールディングツール | [![npm](https://img.shields.io/npm/v/create-ono)](https://www.npmjs.com/package/create-ono) | | [@hashrock/ono-repl](./packages/repl) | ブラウザベースのREPLプレイグラウンド | - | -| [@hashrock/ono-lp](./packages/ono-lp) | LLM搭載のランディングページジェネレーター | - | ## クイックスタート diff --git a/SIMPLIFICATION_PLAN.md b/SIMPLIFICATION_PLAN.md new file mode 100644 index 0000000..1883cfa --- /dev/null +++ b/SIMPLIFICATION_PLAN.md @@ -0,0 +1,221 @@ +# Ono シンプル化計画 + +> **ステータス: 全フェーズ実装済み**(このブランチの後続コミットを参照)。 +> Phase 2 の「Nodeのモジュール解決に委譲」はオーナーの方針変更により置き換え済み: +> 現在はパーサコンビネータ(`src/parser.js`)でモジュール構文を解析する +> **ブラウザ互換ミニバンドラ**(`src/bundler.js`)を Node ビルドと REPL が共用する。 +> 実装中に追加で見つかった不具合も修正済み: +> ライブリロードはクライアントスクリプトが未注入で全く機能していなかった(SSE化と同時に修正)、 +> 生成バレルは `export ... from` がローカル束縛を作らないため import すると +> ReferenceError になっていた(import+export 形式に修正)。 +> 実測値: コア依存 5→4(typescript + @unocss/core/preset-uno/reset)、 +> リポジトリ全体で -3,179行/+1,516行、既知バグ 5件解消。 + +全体設計をレビューした結果に基づく、設計・実装・機能のシンプル化計画。 +「最小限の依存関係・高い移植性・シンプルなアーキテクチャ」というコア哲学に照らして、 +**現状がその哲学からずれている箇所**を特定し、フェーズごとに解消する。 + +## 現状サマリー + +| パッケージ | 役割 | 規模 | +|---|---|---| +| `packages/ono` | コアSSG + CLI | src 約1,600行 / テスト約1,600行 | +| `packages/create-ono` | スキャフォールディング | 約40行 | +| `packages/repl` | ブラウザREPL | 約400行 | +| `packages/ono-lp` | LLM LPジェネレーター | TS約300行 + 重い依存 | +| `packages/example` | サンプルサイト | - | + +コアの依存: `typescript`, `unocss`, `@unocss/preset-uno`, `h3`, `ws` + +## レビューで見つかった問題点 + +シンプル化の動機となる具体的な事実。**(A)〜(C) は実害のあるバグ**。 + +### (A) JSXランタイムが3重に存在し、すでに乖離してバグ化している + +1. `src/jsx-runtime.js` — 正規版。`Fragment` 対応済み +2. `src/constants.js` の `INLINE_JSX_RUNTIME` — ビルド時に注入される文字列版。**`Fragment` が未定義** +3. `src/barrels.js` の `loadMeta()` 内のインライン `h` — `flattenChildren` すらない3つ目のコピー + +結果: `transformer.js` は `jsxFragmentFactory: 'Fragment'` でコンパイルするため、 +**`<>...` を使ったページを `ono build` すると `ReferenceError: Fragment is not defined` で失敗する** +(PR #18 で Fragment 対応したのは REPL 経路のみで、CLI 経路は壊れたまま)。 + +### (B) `uno.config.js` は CLI から一切読まれていない + +`commands/dev.js:39` と `commands/build.js:110` は `loadUnoConfig()` を**引数なし**で呼ぶ。 +`loadUnoConfig(configPath)` は `path.resolve(undefined)` で即例外 → catch → 常に `{}` を返す。 +つまり **ユーザーの `uno.config.js`(テーマ、ショートカット等)はサイレントに無視される**。 +それにもかかわらず `unocssConfig` オプションが builder / watcher / commands の全域に配線されている +(常に `{}` しか流れない配管)。 + +### (C) ネストしたページがフラット化され、衝突する + +`builder.js` の `buildFile()` は `basename` しか見ないため: + +- `pages/blog/first-post.jsx` → `dist/first-post.html`(ディレクトリ構造消失) +- `pages/index.jsx` と `pages/blog/index.jsx` → 両方 `dist/index.html`(**後勝ちで上書き**) + +### (D) バンドラが2実装ある + +- `src/bundler.js` + `src/resolver.js`(計218行): 連結方式。import文を正規表現で除去して結合 +- `src/browser/compiler.js`(253行): 正規表現ベースの簡易モジュールシステム + +同じ「JSXの依存解決」を別々の意味論で2回実装している。どちらも正規表現パースで壊れやすい。 + +### (E) 細かい実装の重複・死にコード + +- `debounce` が2実装(`utils.browser.js` 版は誰も使っておらず、`watcher.js` が独自版を持つ) +- `transformer.js` の `transformJSXWithImports()` は本体から未使用(ヒューリスティックなimport自動付与) +- `utils.js` ⇄ `utils.browser.js` の再エクスポート層 +- `parseDevArgs` / `parseBuildArgs` がほぼ同一のargv手書きパース +- `constants.js` の過剰な定数化(`HTTP_STATUS.NOT_FOUND` や `ERROR_CODES.ENOENT` など、 + リテラルより読みにくくなる「定数のための定数」) + +### (F) 依存関係の過剰・不整合 + +- `h3`: 静的ファイル配信1ルートのためだけに使用 → `node:http` で40行相当 +- `ws`: 「reload」と一言送るためだけに使用 + 専用ポート(35729)を1つ消費 +- `unocss`(メタパッケージ)と `@unocss/preset-uno` を二重宣言 +- `browser/unocss.js` は `@unocss/core` を import しているが **package.json に未宣言**(幽霊依存) +- `unocss.js` の reset CSS 読み込みが `../node_modules/...` の相対パスハック(pnpm配下で壊れやすい) + +### (G) テスト体系の二重化 + +- unit テストと snapshot テストが transformer / renderer で同じ対象を二重にカバー +- 自作スナップショットフレームワーク `snapshot-utils.js`(139行)+ 専用ドキュメント + `SNAPSHOT_TESTING.md` — **Node 22 標準の `t.assert.snapshot` で置き換え可能**(engines は要更新) + +### (H) サンプル・スコープの散らばり + +- サンプルが4箇所: `packages/example`、`packages/ono/example`、`packages/ono/pages`、 + REPL の `main.js` 内ハードコード(さらに `create-ono/template`) +- `packages/ono/example/output.html` などビルド成果物がコミットされている +- `ono-lp` は SSG と無関係の別プロダクト(AI SDK×3 + commander + dotenv + zod という最重量の依存群)で、 + 「ミニマリストSSG」というリポジトリの焦点をぼかしている + +--- + +## シンプル化計画 + +フェーズごとに独立した PR にできる粒度で分割。上から順に価値が高い。 + +### Phase 1: バグを直す方向への統合((A)(B)(C)) + +**1-1. JSXランタイムの一本化** + +`INLINE_JSX_RUNTIME` 文字列と barrels 内のインライン `h` を削除し、 +ビルド時の一時ファイルには実体の import を注入する: + +```js +import { h, Fragment } from "@hashrock/ono/jsx-runtime"; +``` + +一時ファイルは Node の import で評価されるため node_modules 解決が効く。 +ランタイムの定義箇所が `jsx-runtime.js` の1箇所になり、Fragment バグ (A) が構造的に再発しなくなる。 + +**1-2. UnoCSS 設定読み込みの修正と配管の集約** + +- `loadUnoConfig()` をデフォルトで `cwd/uno.config.js` を探すよう修正((B) の修正) +- 「設定ファイルが無い」以外のエラー(設定ファイル内の構文エラー等)は握りつぶさず表示する +- `unocssConfig` の全域配線をやめ、`generateUnoCSS()` が自分で設定を読む形に集約 + (builder / watcher / commands のシグネチャから `unocssConfig` が消える) + +**1-3. 出力パスのディレクトリ構造保持** + +`buildFile()` の出力先を「入力ルートからの相対パス」で計算し、 +`pages/blog/first-post.jsx → dist/blog/first-post.html` とする((C) の修正)。 +※ 挙動変更だが、現状は衝突・上書きなので実質バグ修正。 + +### Phase 2: Node側バンドラの廃止((D) の半分と (E)) + +**2-1. bundler.js / resolver.js を削除し、Node のモジュール解決に任せる** + +現在の Node 側パイプラインは「自前で依存収集 → トポソート → 連結 → 一時ファイル1個 → import」。 +これを「**各 .jsx を変換して一時ディレクトリに書き出し → import specifier の `.jsx`→`.js` 書き換え → +エントリを import**」に変更する。 + +- 依存解決・トポソート・循環検出・npmパッケージ import をすべて Node 本体に任せられる +- `bundler.js`(81行) + `resolver.js`(137行) と正規表現 import パースが丸ごと消える +- 一時ファイルを `dist/` に書く現状(ビルド失敗時に `_temp_*.js` が成果物に残る)も解消され、 + 一時ディレクトリ(`node:os` の tmpdir か `.ono/` )に隔離される + +`browser/compiler.js` はブラウザに Node の解決系がない以上、自前実装が必要なので残す。 +ただし現状すでに独立しているので「Node 側と共有している振り」の構造だけ整理する。 + +**2-2. 死にコード・重複の削除** + +- `transformJSXWithImports()` 削除 +- `debounce` を1実装に統一(`watcher.js` のエラーログ付き版を採用) +- `utils.js` / `utils.browser.js` の再エクスポート層を解消: + ブラウザ互換関数は `utils.js` に一本化し、fs 依存の2関数(`cleanupTempFile`, + `getFilesRecursively`)は使用箇所へ移す +- `parseDevArgs` / `parseBuildArgs` を `node:util` の `parseArgs` による共通パーサ1つに統一 +- `constants.js` は実際に共有価値のあるもの(`SELF_CLOSING_TAGS`, `MIME_TYPES`, `DIRS`, + `PORTS`, `TIMING`)だけに縮小し、`HTTP_STATUS` / `ERROR_CODES` はリテラルへ戻す + +### Phase 3: 依存の削減((F)) + +**3-1. `h3` を削除** — `node:http` + 既存の `MIME_TYPES` で静的サーバーを実装(約40行)。 + +**3-2. `ws` を削除、SSE に移行** — ライブリロードは `EventSource` で十分: + +- dev サーバー自身が `/__reload` エンドポイントで SSE を配信(**専用ポート 35729 が不要になる**) +- クライアント注入スクリプトは `new EventSource('/__reload').onmessage = () => location.reload()` の1行相当 +- `createWebSocketServer` / ポート衝突フォールバック処理が消える + +**3-3. UnoCSS 依存の整理** + +- メタパッケージ `unocss` をやめ、実際に使う `@unocss/core` + `@unocss/preset-uno` + + `@unocss/reset` のみ宣言(幽霊依存の解消も兼ねる) +- reset CSS は `createRequire(import.meta.url).resolve("@unocss/reset/tailwind.css")` で + 正しく解決し、相対パスハックを削除 + +**達成後のコア依存: `typescript` + `@unocss/*` のみ**(README の哲学と一致する)。 + +### Phase 4: テストのシンプル化((G)) + +- engines を `>=22` に上げ、自作 `snapshot-utils.js`(139行)を Node 標準の + `t.assert.snapshot` に置き換え。`SNAPSHOT_TESTING.md` は数行の説明に縮小 +- transformer / renderer で unit と snapshot が二重カバーしている部分を統合し、 + 「挙動の分岐はunitテスト、出力形式はsnapshot」に役割分担 +- Phase 1〜3 の各変更はこのテスト群が回帰防止になるため、実施順は柔軟でよい + (先にやればフェーズ全体の安全網になる) + +### Phase 5: スコープ・サンプルの整理((H))— 要オーナー判断 + +- **`ono-lp` はモノレポから分離を推奨**(別リポジトリへ)。SSG コアと開発サイクルも + 依存も共有しておらず、lockfile とワークスペースを最も重くしている +- サンプルを `packages/example` に一本化し、`packages/ono/example`・`packages/ono/pages`・ + コミット済みビルド成果物(`output.html` 等)を削除 +- REPL 内ハードコードのサンプル3種は、効果が小さいので現状維持でよい(任意で外部ファイル化) + +### 維持するもの(判断と理由) + +- **barrels 機能**: PR #16 で Content Collections の代替として意図的に導入した直後であり、 + 唯一の「コンテンツ列挙」手段なので維持。ただし Phase 1-1 のランタイム統一で + `loadMeta` のインライン `h` は共通化する +- **`browser/compiler.js`**: REPL の中核であり Node の解決系が使えない環境なので自前実装を維持 +- **renderer / jsx-runtime / transformer**: すでに十分小さい(計約200行) + +--- + +## 期待効果 + +| 指標 | 現状 | 計画後 | +|---|---|---| +| コア src 行数 | 約1,600行 | 約1,000行 | +| コア依存 | typescript, unocss, @unocss/preset-uno, h3, ws | typescript, @unocss/core, @unocss/preset-uno, @unocss/reset | +| 使用ポート | HTTP + WebSocket(35729) | HTTP のみ | +| 既知バグ | Fragment 未定義 / uno.config 無視 / ネストページ衝突 | 0 | +| JSXランタイム定義 | 3箇所(乖離あり) | 1箇所 | +| バンドラ実装 | 2つ | 1つ(ブラウザのみ) | + +## 実施順の推奨 + +1. **Phase 1**(バグ修正を兼ねる。ユーザー影響が最も大きい) +2. **Phase 4**(テスト基盤を整え、以降の安全網にする) +3. **Phase 2** → **Phase 3**(内部リファクタリング。テストで回帰を検知) +4. **Phase 5**(オーナー判断が必要な項目。特に ono-lp 分離) + +各フェーズは独立した PR にでき、Phase 3-2(WS→SSE)以外に外部挙動の変更はない。 diff --git a/packages/ono/example/barrels/blog/getting-started.tsx b/packages/example/barrels/blog/getting-started.tsx similarity index 100% rename from packages/ono/example/barrels/blog/getting-started.tsx rename to packages/example/barrels/blog/getting-started.tsx diff --git a/packages/ono/example/barrels/blog/hello-world.tsx b/packages/example/barrels/blog/hello-world.tsx similarity index 100% rename from packages/ono/example/barrels/blog/hello-world.tsx rename to packages/example/barrels/blog/hello-world.tsx diff --git a/packages/example/components/Nav.jsx b/packages/example/components/Nav.jsx index 5356f93..b9f817d 100644 --- a/packages/example/components/Nav.jsx +++ b/packages/example/components/Nav.jsx @@ -3,6 +3,7 @@ function Nav() { { href: "/", label: "Home" }, { href: "/about.html", label: "About" }, { href: "/features.html", label: "Features" }, + { href: "/blog.html", label: "Blog" }, ]; return ( diff --git a/packages/example/pages/about.jsx b/packages/example/pages/about.jsx index 5ac9698..f0ceac8 100644 --- a/packages/example/pages/about.jsx +++ b/packages/example/pages/about.jsx @@ -6,7 +6,7 @@ function About() {

About This Example

-
+

This example site demonstrates how to build a modern static site using Ono with UnoCSS, reusable layouts, and component-based architecture. diff --git a/packages/example/pages/blog.jsx b/packages/example/pages/blog.jsx new file mode 100644 index 0000000..c6cdf42 --- /dev/null +++ b/packages/example/pages/blog.jsx @@ -0,0 +1,32 @@ +import Layout from "../components/Layout.jsx"; +import { entries, posts } from "../barrels/blog.ts"; + +function Blog() { + return ( + +

Blog

+

+ Posts under barrels/blog/ are collected into an + auto-generated barrel file with typed metadata. +

+ +
+ {entries.map((id) => { + const { component: Post, meta } = posts[id]; + return ( +
+ {meta && ( +

+ {meta.date} +

+ )} + +
+ ); + })} +
+ + ); +} + +export default Blog; diff --git a/packages/example/uno.config.js b/packages/example/uno.config.js index 459691f..e331065 100644 --- a/packages/example/uno.config.js +++ b/packages/example/uno.config.js @@ -1,10 +1,9 @@ -import { defineConfig, presetUno, presetTypography } from "unocss"; - -export default defineConfig({ - presets: [ - presetUno(), - presetTypography(), - ], +/** + * UnoCSS Configuration + * A plain object is enough — Ono applies its default preset (presetUno) + * and merges this config on top. + */ +export default { theme: { colors: { primary: { @@ -37,4 +36,4 @@ export default defineConfig({ "nav-link": "text-gray-600 hover:text-primary-600 transition-colors", "section": "py-12", }, -}); +}; diff --git a/packages/ono-lp/.env.example b/packages/ono-lp/.env.example deleted file mode 100644 index b85be2b..0000000 --- a/packages/ono-lp/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# Claude (Anthropic) -ANTHROPIC_API_KEY=sk-ant-... - -# OpenAI -OPENAI_API_KEY=sk-... - -# Gemini (Google) -GOOGLE_GENERATIVE_AI_API_KEY=AI... diff --git a/packages/ono-lp/.gitignore b/packages/ono-lp/.gitignore deleted file mode 100644 index c0c0e3e..0000000 --- a/packages/ono-lp/.gitignore +++ /dev/null @@ -1,20 +0,0 @@ -# Dependencies -node_modules/ - -# Build output -dist/ -output/ - -# Environment variables -.env -.env.local - -# Temp files -.mini-jsx-tmp.js - -# OS files -.DS_Store - -# Editor -.vscode/ -.idea/ diff --git a/packages/ono-lp/README.md b/packages/ono-lp/README.md deleted file mode 100644 index 579c20d..0000000 --- a/packages/ono-lp/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# @hashrock/ono-lp - -Ono用のLLM搭載ランディングページジェネレーター。 - -## インストール - -```bash -npm install -npm run build -``` - -## 使い方 - -```bash -# Claude(デフォルト)で生成 -npx ono-lp https://example.com - -# モデルを指定 -npx ono-lp https://example.com -m openai -npx ono-lp https://example.com -m gemini - -# カスタム出力ディレクトリ -npx ono-lp https://example.com -d ./my-output -``` - -## 環境変数 - -```bash -ANTHROPIC_API_KEY=sk-ant-... # Claude -OPENAI_API_KEY=sk-... # OpenAI -GOOGLE_GENERATIVE_AI_API_KEY=... # Gemini -``` - -## オプション - -| オプション | 説明 | デフォルト | -|--------|-------------|---------| -| `-m, --model` | AIプロバイダー(claude, openai, gemini) | claude | -| `-d, --outdir` | 出力ディレクトリ | ./output | -| `-s, --screenshot` | デザイン用参照画像 | - | diff --git a/packages/ono-lp/example.jpg b/packages/ono-lp/example.jpg deleted file mode 100644 index 1ee48f4..0000000 Binary files a/packages/ono-lp/example.jpg and /dev/null differ diff --git a/packages/ono-lp/package.json b/packages/ono-lp/package.json deleted file mode 100644 index 8353831..0000000 --- a/packages/ono-lp/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@hashrock/ono-lp", - "version": "1.0.0", - "description": "Generate ONO-style landing pages from URLs using LLMs", - "type": "module", - "bin": { - "ono-lp": "./dist/index.js" - }, - "scripts": { - "build": "tsc", - "start": "tsx src/index.ts" - }, - "dependencies": { - "@ai-sdk/anthropic": "^1.2.12", - "@ai-sdk/google": "^1.2.22", - "@ai-sdk/openai": "^1.3.24", - "@hashrock/ono": "workspace:*", - "ai": "^4.3.19", - "commander": "^12.0.0", - "dotenv": "^17.2.3", - "node-html-markdown": "^1.3.0", - "zod": "^3.25.76" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/packages/ono-lp/src/generator.ts b/packages/ono-lp/src/generator.ts deleted file mode 100644 index 60ec973..0000000 --- a/packages/ono-lp/src/generator.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { generateText, tool } from "ai"; -import { z } from "zod"; - -const SYSTEM_PROMPT = `あなたはONO SSGのエキスパートです。与えられたウェブページの内容を分析し、魅力的なランディングページをONO形式のJSXで生成してください。 - -## ONO SSGについて -Onoは最小限のJSX静的サイトジェネレーター。 - -## コンポーネント設計 -再利用可能なコンポーネントに分離してください: -- Button.jsx - ボタン -- Card.jsx - カード -- Nav.jsx - ナビゲーション -- Hero.jsx - ヒーローセクション -- Footer.jsx - フッター -等 - -## 重要なルール -- class属性を使用(classNameではない) -- UnoCSS/Tailwindのユーティリティクラスを使用 -- @jsxImportSourceは使用しない -- importは相対パス + .jsx拡張子 -- pages/index.jsxのには必ずを含める - -## ファイル生成 -writeFileツールを使って、1ファイルずつ生成してください。 -まずコンポーネントを生成し、最後にpages/index.jsxを生成してください。`; - -export interface Screenshot { - data: Buffer; - mimeType: string; -} - -export interface GeneratedFile { - path: string; - content: string; -} - -export interface GenerateResult { - files: GeneratedFile[]; -} - -export async function generateLP( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - model: any, - content: string, - screenshot?: Screenshot, - onFileGenerated?: (file: GeneratedFile) => void -): Promise { - const files: GeneratedFile[] = []; - - const writeFileTool = tool({ - description: "JSXファイルを生成する", - parameters: z.object({ - path: z.string().describe("ファイルパス(例: components/Button.jsx, pages/index.jsx)"), - content: z.string().describe("ファイルの内容(JSXコード)"), - }), - execute: async ({ path, content }) => { - // Skip if path is undefined or empty - if (!path || typeof path !== "string") { - console.warn(" ⚠ Skipped: invalid path"); - return "エラー: pathが不正です"; - } - const file = { path, content }; - files.push(file); - onFileGenerated?.(file); - return `${path} を生成しました`; - }, - }); - - const textPrompt = `ウェブページ内容: ---- -${content} ---- - -上記を元に、コンポーネントを分離してLPを生成してください。writeFileツールを使って1ファイルずつ生成してください。`; - - if (screenshot) { - await generateText({ - model, - maxTokens: 16000, - tools: { writeFile: writeFileTool }, - maxSteps: 20, - messages: [ - { - role: "system", - content: SYSTEM_PROMPT, - }, - { - role: "user", - content: [ - { - type: "image", - image: screenshot.data, - mimeType: screenshot.mimeType, - }, - { - type: "text", - text: `このスクリーンショットのデザインを参考に、以下の内容でLPを生成してください。\n\n${textPrompt}`, - }, - ], - }, - ], - }); - } else { - await generateText({ - model, - maxTokens: 16000, - tools: { writeFile: writeFileTool }, - maxSteps: 20, - system: SYSTEM_PROMPT, - prompt: textPrompt, - }); - } - - return { files }; -} diff --git a/packages/ono-lp/src/index.ts b/packages/ono-lp/src/index.ts deleted file mode 100644 index c56867d..0000000 --- a/packages/ono-lp/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node - -import "dotenv/config"; -import { program } from "commander"; -import { writeFile, mkdir, readFile } from "node:fs/promises"; -import { dirname, extname, join } from "node:path"; -import { scrapeUrl } from "./scraper.js"; -import { getModel, type ModelProvider } from "./providers.js"; -import { generateLP, type GeneratedFile } from "./generator.js"; - -interface Options { - model: string; - outdir: string; - screenshot?: string; -} - -function getMimeType(filePath: string): string { - const ext = extname(filePath).toLowerCase(); - const mimeTypes: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - }; - return mimeTypes[ext] || "image/png"; -} - -program - .name("ono-lp") - .description("Generate ONO-style landing pages from URLs using LLMs") - .argument("", "URL to scrape") - .option( - "-m, --model ", - "AI model provider (claude, openai, gemini)", - "claude" - ) - .option("-d, --outdir ", "Output base directory", "./output") - .option("-s, --screenshot ", "Reference screenshot image path") - .action(async (url: string, options: Options) => { - const provider = options.model as ModelProvider; - - if (!["claude", "openai", "gemini"].includes(provider)) { - console.error(`Error: Invalid model provider "${provider}"`); - console.error("Valid providers: claude, openai, gemini"); - process.exit(1); - } - - try { - console.log(`Fetching ${url}...`); - const content = await scrapeUrl(url); - - // Load screenshot if provided - let screenshot: { data: Buffer; mimeType: string } | undefined; - if (options.screenshot) { - console.log(`Loading screenshot: ${options.screenshot}`); - const imageData = await readFile(options.screenshot); - screenshot = { - data: imageData, - mimeType: getMimeType(options.screenshot), - }; - } - - console.log(`Generating LP with ${provider}...\n`); - const model = getModel(provider); - - const onFileGenerated = async (file: GeneratedFile) => { - const filePath = join(options.outdir, file.path); - const fileDir = dirname(filePath); - await mkdir(fileDir, { recursive: true }); - await writeFile(filePath, file.content, "utf-8"); - console.log(` ✓ ${file.path}`); - }; - - const result = await generateLP(model, content, screenshot, onFileGenerated); - - console.log(`\nGenerated ${result.files.length} file(s). Done!`); - } catch (error) { - console.error("Error:", error instanceof Error ? error.message : error); - process.exit(1); - } - }); - -program.parse(); diff --git a/packages/ono-lp/src/providers.ts b/packages/ono-lp/src/providers.ts deleted file mode 100644 index 4347ee3..0000000 --- a/packages/ono-lp/src/providers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createOpenAI } from "@ai-sdk/openai"; -import { createAnthropic } from "@ai-sdk/anthropic"; -import { createGoogleGenerativeAI } from "@ai-sdk/google"; - -export type ModelProvider = "claude" | "openai" | "gemini"; - -const DEFAULT_MODELS: Record = { - claude: "claude-sonnet-4-20250514", - openai: "gpt-4o", - gemini: "gemini-2.0-flash-001", -}; - -export function getModel(provider: ModelProvider) { - switch (provider) { - case "claude": { - const anthropic = createAnthropic(); - return anthropic(DEFAULT_MODELS.claude); - } - case "openai": { - const openai = createOpenAI(); - return openai(DEFAULT_MODELS.openai); - } - case "gemini": { - const google = createGoogleGenerativeAI(); - return google(DEFAULT_MODELS.gemini); - } - default: - throw new Error(`Unknown provider: ${provider}`); - } -} diff --git a/packages/ono-lp/src/scraper.ts b/packages/ono-lp/src/scraper.ts deleted file mode 100644 index 2fbab68..0000000 --- a/packages/ono-lp/src/scraper.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NodeHtmlMarkdown } from "node-html-markdown"; - -export async function scrapeUrl(url: string): Promise { - const response = await fetch(url, { - headers: { - "User-Agent": - "Mozilla/5.0 (compatible; OnoLP/1.0; +https://github.com/hashrock/ono)", - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status}`); - } - - const html = await response.text(); - const markdown = NodeHtmlMarkdown.translate(html); - - return markdown; -} diff --git a/packages/ono-lp/tsconfig.json b/packages/ono-lp/tsconfig.json deleted file mode 100644 index 318ebdb..0000000 --- a/packages/ono-lp/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/packages/ono/.npmignore b/packages/ono/.npmignore deleted file mode 100644 index 8c79338..0000000 --- a/packages/ono/.npmignore +++ /dev/null @@ -1,26 +0,0 @@ -# Test files -test/ -*.test.js - -# Example files -example/ -pages/ -components/ -public/ - -# Development files -.vscode/ -.git/ -.gitignore -tsconfig.json - -# Build output -dist/ -.mini-jsx-tmp.js - -# Node modules -node_modules/ - -# Misc -.DS_Store -*.log diff --git a/packages/ono/SNAPSHOT_TESTING.md b/packages/ono/SNAPSHOT_TESTING.md deleted file mode 100644 index 92d576c..0000000 --- a/packages/ono/SNAPSHOT_TESTING.md +++ /dev/null @@ -1,154 +0,0 @@ -# Snapshot Testing Guide - -This project uses snapshot testing to ensure consistent output across different components of the Ono SSG framework. - -## What is Snapshot Testing? - -Snapshot testing captures the output of your code at a specific point in time and stores it as a "snapshot" file. Future test runs compare the current output against the stored snapshot to detect unexpected changes. - -## Available Snapshot Tests - -### 1. HTML Renderer Snapshots (`test/renderer.snapshot.test.js`) -Tests the HTML output from JSX elements to ensure consistent rendering: -- Simple elements -- Elements with attributes -- Nested structures -- Component functions -- Boolean attributes -- Style objects -- Complex forms -- HTML entity escaping - -### 2. JSX Transformer Snapshots (`test/transformer.snapshot.test.js`) -Tests the JavaScript code generated from JSX transformation: -- Simple JSX elements -- JSX with props -- Nested JSX structures -- Component functions -- JSX with expressions -- Self-closing tags -- JSX fragments -- Complex components with imports -- Style prop objects -- Conditional rendering - -### 3. UnoCSS Generation Snapshots (`test/unocss.snapshot.test.js`) -Tests the CSS output generated from HTML content: -- Basic utility classes -- Layout classes -- Responsive design -- Color utilities -- Spacing and sizing -- Typography -- Form elements -- Blog layout - -## Running Snapshot Tests - -### Run all snapshot tests -```bash -npm run test:snapshot -``` - -### Update snapshots when intentional changes are made -```bash -npm run test:snapshot:update -``` - -### Run all tests (regular + snapshot) -```bash -npm run test:all -``` - -## Snapshot Files - -Snapshot files are stored in `test/__snapshots__/` with the naming convention: -``` -{test-file-name}.{test-name}.snap -``` - -For example: -- `renderer.snapshot.simple_element.snap` -- `transformer.snapshot.JSX_with_props.snap` -- `unocss.snapshot.basic_utility_classes.snap` - -## When to Update Snapshots - -Update snapshots when you've intentionally changed: - -1. **HTML Output**: Modified the renderer logic -2. **JSX Transformation**: Updated the JSX-to-JS transformation -3. **CSS Generation**: Changed UnoCSS configuration or generation logic - -### ⚠️ Important: Review Changes Before Updating - -Always review the differences between old and new snapshots before updating: - -1. Run tests to see failures -2. Manually verify the changes are intentional -3. Update snapshots using `npm run test:snapshot:update` - -## Example Workflow - -### Adding a New Feature -1. Write your code -2. Add/modify snapshot tests if needed -3. Run `npm run test:snapshot` -4. If snapshots fail due to intentional changes: - - Review the changes carefully - - Run `npm run test:snapshot:update` - - Commit both code and snapshot changes - -### Debugging Snapshot Failures -When snapshot tests fail: - -1. **Check the console output** - it shows expected vs actual -2. **Verify if the change was intentional**: - - If yes: update snapshots - - If no: fix your code -3. **For complex diffs**, you can examine snapshot files directly - -## Best Practices - -1. **Keep snapshots small and focused** - Test specific functionality -2. **Review snapshot changes in PRs** - Don't blindly update snapshots -3. **Use descriptive test names** - Makes snapshot files easier to understand -4. **Commit snapshot files** - They're part of your test suite -5. **Regular cleanup** - Remove unused snapshot files - -## Custom Snapshot Utilities - -The project includes custom snapshot utilities in `test/snapshot-utils.js`: - -- `matchSnapshot()` - Generic snapshot matching -- `matchHTMLSnapshot()` - HTML-specific formatting -- `matchCodeSnapshot()` - JavaScript code formatting - -### Example Usage - -```javascript -import { matchHTMLSnapshot } from "./snapshot-utils.js"; - -test("my html test", async () => { - const html = renderComponent(); - await matchHTMLSnapshot(html, "my-test-file.js", "my html test"); -}); -``` - -## Troubleshooting - -### Snapshot test fails with "No such file or directory" -- Make sure you're running tests from the project root -- Ensure `test/__snapshots__/` directory exists (it's created automatically) - -### Snapshots not updating -- Use the exact command: `npm run test:snapshot:update` -- Check file permissions on the `test/__snapshots__/` directory - -### Large snapshot diffs -- Consider breaking down large components into smaller, testable units -- Use more specific test names to identify which part changed - ---- - -**Note**: Snapshot testing complements, but doesn't replace, traditional unit tests. Use both approaches for comprehensive test coverage. \ No newline at end of file diff --git a/packages/ono/example/barrels/blog.ts b/packages/ono/example/barrels/blog.ts deleted file mode 100644 index f44dce6..0000000 --- a/packages/ono/example/barrels/blog.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Auto-generated barrel file - DO NOT EDIT -// Generated by Ono SSG - -export type Meta = { - title: string; - date: string; - author: string; - tags?: string[]; -}; - -export { default as gettingStarted, meta as gettingStartedMeta } from './blog/getting-started.tsx'; -export { default as helloWorld, meta as helloWorldMeta } from './blog/hello-world.tsx'; - -export const entries = ['getting-started', 'hello-world'] as const; -export type EntryId = typeof entries[number]; - -export const posts = { - 'getting-started': { component: gettingStarted, meta: gettingStartedMeta }, - 'hello-world': { component: helloWorld, meta: helloWorldMeta } -}; diff --git a/packages/ono/example/components/Button.jsx b/packages/ono/example/components/Button.jsx deleted file mode 100644 index bc7f330..0000000 --- a/packages/ono/example/components/Button.jsx +++ /dev/null @@ -1,18 +0,0 @@ -// Simple Button component -function Button(props) { - return ( - - ); -} - -export default Button; diff --git a/packages/ono/example/components/Card.jsx b/packages/ono/example/components/Card.jsx deleted file mode 100644 index 899415b..0000000 --- a/packages/ono/example/components/Card.jsx +++ /dev/null @@ -1,26 +0,0 @@ -// Card component demonstrating slots -function Card(props) { - return ( -
- {props.title && ( -

{props.title}

- )} -
- {props.children} -
- {props.actions && ( -
- {props.actions} -
- )} -
- ); -} - -export default Card; diff --git a/packages/ono/example/components/Layout.jsx b/packages/ono/example/components/Layout.jsx deleted file mode 100644 index 54f36a6..0000000 --- a/packages/ono/example/components/Layout.jsx +++ /dev/null @@ -1,46 +0,0 @@ -// Layout component with slot support -function Layout(props) { - return ( - - - - - {props.title || "Mini JSX Site"} - - - -
-

{props.title}

- {props.header} -
-
- {props.children} -
-
- {props.footer ||

© 2025 Mini JSX

} -
- - - ); -} - -export default Layout; diff --git a/packages/ono/example/index.html b/packages/ono/example/index.html deleted file mode 100644 index 5a8fdf9..0000000 --- a/packages/ono/example/index.html +++ /dev/null @@ -1,37 +0,0 @@ - -Mini JSX - SSG Example

Mini JSX - SSG Example

Hello, World!

This example demonstrates:

  • External component imports
  • Slot-based composition (children, header, footer, actions)
  • Props handling
  • Nested components

Feature: Slots

Cards can have title slots, content slots (children), and action slots for flexible composition.

Feature: External Imports

Import components from separate files to keep your code organized. The build system will resolve and bundle them automatically.

No title? No problem! Slots are optional.

Built with Mini JSX 🚀

A lightweight JSX library for static site generation

- - \ No newline at end of file diff --git a/packages/ono/example/index.jsx b/packages/ono/example/index.jsx deleted file mode 100644 index 7bbc894..0000000 --- a/packages/ono/example/index.jsx +++ /dev/null @@ -1,69 +0,0 @@ -// External imports -import Layout from "./components/Layout.jsx"; -import Card from "./components/Card.jsx"; -import Button from "./components/Button.jsx"; - -// Simple component -function Greeting(props) { - return
Hello, {props.name}!
; -} - -// Main page demonstrating slots and external imports -function App() { - return ( - - Home | About - - } - footer={ -
-

Built with Mini JSX 🚀

-

A lightweight JSX library for static site generation

-
- } - > - - -

This example demonstrates:

-
    -
  • External component imports
  • -
  • Slot-based composition (children, header, footer, actions)
  • -
  • Props handling
  • -
  • Nested components
  • -
- - - - -
- } - > -

- Cards can have title slots, content slots (children), - and action slots for flexible composition. -

- - - -

- Import components from separate files to keep your code organized. - The build system will resolve and bundle them automatically. -

-
- - -

- No title? No problem! Slots are optional. -

-
- - ); -} - -export default App; diff --git a/packages/ono/example/output.html b/packages/ono/example/output.html deleted file mode 100644 index 34a50d6..0000000 --- a/packages/ono/example/output.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - Mini JSX - SSG Example - - - -
-

Mini JSX - SSG Example

- -
-
-
Hello, World!
- -

This example demonstrates:

-
    -
  • External component imports
  • -
  • Slot-based composition (children, header, footer, actions)
  • -
  • Props handling
  • -
  • Nested components
  • -
- -
-

Feature: Slots

-
-

- Cards can have title slots, content slots (children), - and action slots for flexible composition. -

-
-
-
- - -
-
-
- -
-

Feature: External Imports

-
-

- Import components from separate files to keep your code organized. - The build system will resolve and bundle them automatically. -

-
-
- -
-
-

- No title? No problem! Slots are optional. -

-
-
-
-
-
-

Built with Mini JSX 🚀

-

A lightweight JSX library for static site generation

-
-
- - diff --git a/packages/ono/package.json b/packages/ono/package.json index ecbec6b..b8c5a5f 100644 --- a/packages/ono/package.json +++ b/packages/ono/package.json @@ -9,6 +9,7 @@ "./jsx-runtime": "./src/jsx-runtime.js", "./renderer": "./src/renderer.js", "./transformer": "./src/transformer.js", + "./parser": "./src/parser.js", "./bundler": "./src/bundler.js", "./barrels": "./src/barrels.js", "./browser/compiler": "./src/browser/compiler.js", @@ -23,11 +24,9 @@ "LICENSE" ], "scripts": { - "test": "node --test test/**/*.test.js", - "test:watch": "node --test --watch test/**/*.test.js", - "test:snapshot": "node --test test/**/*.snapshot.test.js", - "test:snapshot:update": "UPDATE_SNAPSHOTS=true node --test test/**/*.snapshot.test.js", - "test:all": "npm run test && npm run test:snapshot" + "test": "node --test test/*.test.js", + "test:watch": "node --test --watch test/*.test.js", + "test:update": "node --test --test-update-snapshots test/*.test.js" }, "keywords": [ "jsx", @@ -49,13 +48,12 @@ }, "homepage": "https://github.com/hashrock/ono#readme", "engines": { - "node": ">=18.0.0" + "node": ">=22.3.0" }, "dependencies": { + "@unocss/core": "^66.5.4", "@unocss/preset-uno": "^66.5.4", - "h3": "^2.0.1-rc.5", - "typescript": "^5.9.3", - "unocss": "^66.5.4", - "ws": "^8.18.3" + "@unocss/reset": "^66.5.4", + "typescript": "^5.9.3" } } diff --git a/packages/ono/pages/about.jsx b/packages/ono/pages/about.jsx deleted file mode 100644 index cb98b8a..0000000 --- a/packages/ono/pages/about.jsx +++ /dev/null @@ -1,32 +0,0 @@ -export default function About() { - return ( - - - - - About - Mini JSX Pages Example - - - - - -
-

About Mini JSX

-

Mini JSX is a lightweight JSX library for static site generation.

-

Features

-
    -
  • Simple JSX runtime
  • -
  • Fast bundler
  • -
  • Pages directory support
  • -
  • Live reload dev server
  • -
  • UnoCSS atomic CSS integration
  • -
-
- - - ); -} diff --git a/packages/ono/pages/blog/first-post.jsx b/packages/ono/pages/blog/first-post.jsx deleted file mode 100644 index 6c0bce1..0000000 --- a/packages/ono/pages/blog/first-post.jsx +++ /dev/null @@ -1,28 +0,0 @@ -export default function FirstPost() { - return ( - - - - - First Post - Blog - - - - - -
-

My First Blog Post

-

Published on October 29, 2025

-

Welcome to my first blog post built with Mini JSX!

-

This page is in a subdirectory (pages/blog/), and the output preserves the directory structure (dist/blog/first-post.html).

-

Why Mini JSX?

-

Mini JSX makes it easy to build static sites with JSX components, without the complexity of a full framework.

-
- - - ); -} diff --git a/packages/ono/pages/index.jsx b/packages/ono/pages/index.jsx deleted file mode 100644 index 5fa11ff..0000000 --- a/packages/ono/pages/index.jsx +++ /dev/null @@ -1,30 +0,0 @@ -export default function Home() { - return ( - - - - - Home - Mini JSX Pages Example - - - - - -
-

Welcome to Mini JSX

-

This is a multi-page static site built with Mini JSX!

-
    -
  • Pages are automatically discovered from the pages/ directory
  • -
  • Directory structure is preserved in the output
  • -
  • Live reload works across all pages
  • -
  • Now with UnoCSS atomic CSS!
  • -
-
- - - ); -} diff --git a/packages/ono/public/css/style.css b/packages/ono/public/css/style.css deleted file mode 100644 index 85f5cc6..0000000 --- a/packages/ono/public/css/style.css +++ /dev/null @@ -1,40 +0,0 @@ -/* Global styles for Mini JSX site */ -body { - font-family: system-ui, -apple-system, sans-serif; - max-width: 800px; - margin: 0 auto; - padding: 2rem; - line-height: 1.6; - color: #333; -} - -nav { - margin-bottom: 2rem; - padding-bottom: 1rem; - border-bottom: 2px solid #eee; -} - -nav a { - margin-right: 1rem; - color: #0070f3; - text-decoration: none; - font-weight: 500; -} - -nav a:hover { - text-decoration: underline; -} - -code { - background: #f4f4f4; - padding: 0.2rem 0.4rem; - border-radius: 3px; - font-family: 'Monaco', 'Courier New', monospace; - font-size: 0.9em; -} - -.meta { - color: #666; - font-size: 0.9rem; - font-style: italic; -} diff --git a/packages/ono/public/favicon.ico b/packages/ono/public/favicon.ico deleted file mode 100644 index 64ffac7..0000000 --- a/packages/ono/public/favicon.ico +++ /dev/null @@ -1 +0,0 @@ -# Placeholder favicon file diff --git a/packages/ono/src/barrels.js b/packages/ono/src/barrels.js index 0243874..7c066a5 100644 --- a/packages/ono/src/barrels.js +++ b/packages/ono/src/barrels.js @@ -2,9 +2,18 @@ * Barrels - Auto-generated barrel files with type inference */ import { readdir, writeFile } from "node:fs/promises"; -import { join, basename, dirname, relative } from "node:path"; -import { bundle } from "./bundler.js"; -import { toCamelCase, cleanupTempFile, isJSXFile } from "./utils.js"; +import { join, basename, relative } from "node:path"; +import { importJSXModule } from "./builder.js"; +import { isJSXFile } from "./utils.js"; + +/** + * Convert kebab-case or snake_case to camelCase + * @param {string} str - String to convert + * @returns {string} camelCase string + */ +function toCamelCase(str) { + return str.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase()); +} /** * Infer TypeScript type from a JavaScript value @@ -88,36 +97,17 @@ async function getBarrelEntries(barrelDir) { } /** - * Load meta from an entry file by bundling and evaluating + * Load meta from an entry file by compiling and evaluating it * @param {string} entryPath - Path to the entry file * @returns {Promise} Meta object or null */ async function loadMeta(entryPath) { - const tempDir = dirname(entryPath); - const tempFile = join(tempDir, `_temp_meta_${Date.now()}.js`); - try { - // Bundle the file to resolve imports - const bundledCode = await bundle(entryPath); - - // Add JSX runtime and extract meta - const code = `function h(tag, props, ...children) { - return { tag, props: props || {}, children }; -} -${bundledCode} -`; - - await writeFile(tempFile, code); - - const moduleUrl = new URL(`file://${tempFile}?t=${Date.now()}`); - const module = await import(moduleUrl.href); + const module = await importJSXModule(entryPath); return module.meta || null; } catch (error) { console.warn(`Warning: Could not load meta from ${entryPath}:`, error.message); return null; - } finally { - // Clean up - await cleanupTempFile(tempFile); } } @@ -141,15 +131,17 @@ export async function generateBarrel(barrelDir, options = {}) { // Generate type definition const metaType = generateMetaType(metas); - // Generate imports with camelCase identifiers + // Generate imports with camelCase identifiers. + // Import first, then export the local bindings — a plain + // `export ... from` would not bring the names into scope for `posts`. const imports = entries .map((entry, idx) => { const camelId = toCamelCase(entry.id); - const hasExportedMeta = metas[idx] !== null; - if (hasExportedMeta) { - return `export { default as ${camelId}, meta as ${camelId}Meta } from './${basename(barrelDir)}/${entry.file}';`; + const from = `'./${basename(barrelDir)}/${entry.file}'`; + if (metas[idx] !== null) { + return `import ${camelId}, { meta as ${camelId}Meta } from ${from};\nexport { ${camelId}, ${camelId}Meta };`; } - return `export { default as ${camelId} } from './${basename(barrelDir)}/${entry.file}';`; + return `import ${camelId} from ${from};\nexport { ${camelId} };`; }) .join("\n"); diff --git a/packages/ono/src/browser/compiler.js b/packages/ono/src/browser/compiler.js index 6215eaf..061a8d4 100644 --- a/packages/ono/src/browser/compiler.js +++ b/packages/ono/src/browser/compiler.js @@ -1,188 +1,73 @@ /** - * Browser compiler utilities shared with the REPL worker. + * Browser compiler - shared with the REPL worker. + * + * Uses the same mini bundler as the Node build (bundler.js/parser.js). + * Package imports are rejected — the browser has no module resolution — + * and the bundle is evaluated with new Function, with the JSX runtime + * passed in as parameters. */ import { transformJSX } from '../transformer.js'; import { renderToString } from '../renderer.js'; -import { h } from '../jsx-runtime.js'; +import { h, Fragment } from '../jsx-runtime.js'; +import { bundle } from '../bundler.js'; import { getUnoGenerator } from './unocss.js'; -function resolveImport(from, to) { - if (to.startsWith('./') || to.startsWith('../')) { - const fromParts = from.split('/').slice(0, -1); - const targetParts = to.split('/'); - const resolved = [...fromParts]; - - for (const part of targetParts) { - if (!part || part === '.') continue; - if (part === '..') { - resolved.pop(); - } else { - resolved.push(part); - } +/** Join a relative specifier against the importing file's virtual path */ +function resolveImport(fromId, specifier) { + const fromParts = fromId.split('/').slice(0, -1); + const targetParts = specifier.split('/'); + const resolved = [...fromParts]; + + for (const part of targetParts) { + if (!part || part === '.') continue; + if (part === '..') { + resolved.pop(); + } else { + resolved.push(part); } - - return resolved.join('/'); } - return to; + return resolved.join('/'); } -function topoSortModules(files, entryPoint) { - const filenames = Object.keys(files); - const dependencyMap = new Map(); - const importPattern = /import\s+(?:[\s\S]+?)?from\s+['"]([^'"]+)['"]/g; - - for (const filename of filenames) { - const source = files[filename] || ''; - const deps = []; - source.replace(importPattern, (match, specifier) => { - const resolved = resolveImport(filename, specifier); - if (files[resolved]) { - deps.push(resolved); - } - return match; - }); - dependencyMap.set(filename, deps); - } - - const visited = new Set(); - const order = []; - - const visit = (file) => { - if (!files[file] || visited.has(file)) return; - visited.add(file); - const deps = dependencyMap.get(file) || []; - for (const dep of deps) { - visit(dep); - } - order.push(file); - }; - - if (entryPoint) { - visit(entryPoint); - } - - for (const filename of filenames) { - if (!visited.has(filename)) { - visit(filename); - } +async function bundleProject(files, entryPoint) { + if (!(entryPoint in files)) { + throw new Error(`Entry point '${entryPoint}' not found`); } - return order; -} - -function bundleModules(files, entryPoint) { - const order = topoSortModules(files, entryPoint); - let bundledCode = 'const __modules = {};\n'; - - for (const filename of order) { - const transformedCode = transformJSX(files[filename], filename); - let code = transformedCode; - const exportMappings = new Map(); - - const addExport = (exportName, localName = exportName) => { - if (!exportName || exportMappings.has(exportName)) return; - exportMappings.set(exportName, localName); - }; - - code = code.replace(/import\s+{([^}]+)}\s+from\s+['"]([^'"]+)['"]/g, (match, imports, path) => { - const resolvedPath = resolveImport(filename, path); - const importNames = imports.split(',').map(part => part.trim()).filter(Boolean); - - return importNames - .map(name => { - const [local, alias] = name.split(/\s+as\s+/).map(token => token && token.trim()); - const localName = alias || local; - const exportName = local; - return `const ${localName} = __modules['${resolvedPath}']['${exportName}'];`; - }) - .join('\n'); - }); - - code = code.replace(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g, (match, localName, path) => { - const resolvedPath = resolveImport(filename, path); - return `const ${localName} = __modules['${resolvedPath}']['default'];`; - }); - - code = code.replace(/import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g, (match, localName, path) => { - const resolvedPath = resolveImport(filename, path); - return `const ${localName} = __modules['${resolvedPath}'];`; - }); - - code = code.replace(/export\s+default\s+function\s+([A-Za-z_$][\w$]*)/g, (match, name) => { - addExport('default', name); - return `function ${name}`; - }); - - code = code.replace(/export\s+default\s+([A-Za-z_$][\w$]*)/g, (match, name) => { - addExport('default', name); - return `${name}`; - }); - - code = code.replace(/export\s+(const|let|var)\s+([A-Za-z_$][\w$]*)/g, (match, kind, name) => { - addExport(name); - return `${kind} ${name}`; - }); - - code = code.replace(/export\s+function\s+([A-Za-z_$][\w$]*)/g, (match, name) => { - addExport(name); - return `function ${name}`; - }); - - code = code.replace(/export\s+class\s+([A-Za-z_$][\w$]*)/g, (match, name) => { - addExport(name); - return `class ${name}`; - }); - - code = code.replace(/export\s*{\s*([^}]+)\s*};?/g, (match, names) => { - names - .split(',') - .map(part => part.trim()) - .filter(Boolean) - .forEach(part => { - const [local, alias] = part.split(/\s+as\s+/).map(token => token && token.trim()); - addExport(alias || local, local); - }); - return ''; - }); - - code = code.replace(/export\s*{\s*};?/g, ''); - - if (filename === entryPoint) { - const functionMatches = code.matchAll(/function\s+([A-Za-z_$][\w$]*)/g); - for (const match of functionMatches) { - const name = match[1]; - addExport(name); + const { code } = await bundle({ + entry: entryPoint, + resolve: (specifier, fromId) => { + const resolved = resolveImport(fromId, specifier); + if (!(resolved in files)) { + throw new Error(`Cannot find module '${specifier}' imported from '${fromId}'`); } - } - - const exportEntries = Array.from(exportMappings.entries()).map(([exportName, localName]) => { - if (exportName === 'default') { - return `'default': ${localName}`; - } - if (exportName === localName) { - return exportName; - } - return `'${exportName}': ${localName}`; - }); - - const exportsObject = exportEntries.length > 0 ? `{ ${exportEntries.join(', ')} }` : '{}'; - - bundledCode += ` -__modules['${filename}'] = (() => { - ${code} - return ${exportsObject}; -})(); -`; - } + return resolved; + }, + load: (id) => transformJSX(files[id], id), + onExternal: 'error', + exposeEntryFunctions: true, + }); + + return code; +} - bundledCode += `const __entry = __modules['${entryPoint}'];\n`; +function evaluateEntryModule(bundledCode) { + const getEntryModule = new Function('h', 'Fragment', ` + ${bundledCode} + return __ono_entry; + `); - return bundledCode; + return getEntryModule(h, Fragment); } -function extractRenderCandidate(entryModule, entryCode) { +/** + * Pick what to render from the entry module: the default export if there + * is one, otherwise the last exported function (REPL snippets usually + * define components and finish with an App function), otherwise any value. + */ +function extractRenderCandidate(entryModule) { if (!entryModule || typeof entryModule !== 'object') { return null; } @@ -195,21 +80,9 @@ function extractRenderCandidate(entryModule, entryCode) { return () => defaultExport; } - const lastCallMatch = entryCode.match(/([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*$/m); - if (lastCallMatch) { - const lastCallName = lastCallMatch[1]; - const candidate = entryModule[lastCallName]; - if (typeof candidate === 'function') { - return candidate; - } - if (candidate !== undefined) { - return () => candidate; - } - } - - const fallbackFunction = Object.values(entryModule).find(value => typeof value === 'function'); - if (fallbackFunction) { - return fallbackFunction; + const functions = Object.values(entryModule).filter((value) => typeof value === 'function'); + if (functions.length > 0) { + return functions[functions.length - 1]; } const firstValue = Object.values(entryModule)[0]; @@ -220,20 +93,10 @@ function extractRenderCandidate(entryModule, entryCode) { return null; } -function evaluateEntryModule(bundledCode) { - const getEntryModule = new Function('h', ` - ${bundledCode} - return __entry; - `); - - return getEntryModule(h); -} - export async function compileProject(files, entryPoint = 'index.jsx', options = {}) { - const bundled = bundleModules(files, entryPoint); + const bundled = await bundleProject(files, entryPoint); const entryModule = evaluateEntryModule(bundled); - const entryCode = files[entryPoint] || ''; - const renderCandidate = extractRenderCandidate(entryModule, entryCode); + const renderCandidate = extractRenderCandidate(entryModule); if (!renderCandidate) { throw new Error('Unable to determine a render function in the entry module.'); diff --git a/packages/ono/src/builder.js b/packages/ono/src/builder.js index 281d25e..98c2d1b 100644 --- a/packages/ono/src/builder.js +++ b/packages/ono/src/builder.js @@ -1,43 +1,103 @@ /** * Build utilities for Ono SSG + * + * Pages are bundled with the browser-compatible mini bundler + * (bundler.js + parser.js), written to a single temp file, and imported. + * Package (bare) imports are hoisted to the top of the bundle where + * Node's own resolution handles them. */ -import { writeFile, mkdir } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { resolve, join, dirname, basename, relative } from "node:path"; +import { pathToFileURL } from "node:url"; import { renderToString } from "./renderer.js"; -import { generateCSSFromFiles } from "./unocss.js"; +import { generateCSSFromFiles, loadUnoConfig } from "./unocss.js"; +import { transformJSX } from "./transformer.js"; import { bundle } from "./bundler.js"; -import { cleanupTempFile, getFilesRecursively, isJSXFile, isHTMLFile } from "./utils.js"; -import { INLINE_JSX_RUNTIME, DIRS } from "./constants.js"; +import { getFilesRecursively, isJSXFile, isHTMLFile } from "./utils.js"; +import { DIRS } from "./constants.js"; + +/** Import statement for the real JSX runtime, injected into compiled pages */ +const JSX_RUNTIME_IMPORT = `import { h, Fragment } from ${JSON.stringify( + new URL("./jsx-runtime.js", import.meta.url).href, +)};\n`; + +/** Directory (inside the project) that holds per-build temp files */ +const TEMP_DIR = ".ono"; + +let buildCounter = 0; + +/** + * Bundle an entry file into a single ES module source string + * @param {string} entryFile - Absolute path to the entry file + * @returns {Promise} Bundled module source + */ +export async function compileBundle(entryFile) { + const { code, entryExports, externalBindings } = await bundle({ + entry: entryFile, + resolve: (specifier, fromId) => resolve(dirname(fromId), specifier), + load: async (id) => { + let source; + try { + source = await readFile(id, "utf-8"); + } catch (error) { + throw new Error(`Cannot read file: ${id}\n${error.message}`); + } + return transformJSX(source, id); + }, + }); + + // Provide h/Fragment unless a page pulls in the runtime itself + const header = + externalBindings.has("h") || externalBindings.has("Fragment") ? "" : JSX_RUNTIME_IMPORT; + + // Re-export the entry's exports so the bundle behaves like the entry module + const footer = entryExports + .map((name) => + name === "default" + ? "export default __ono_entry.default;" + : `export const ${name} = __ono_entry[${JSON.stringify(name)}];`, + ) + .join("\n"); + + return `${header}${code}\n${footer}\n`; +} + +/** + * Bundle a JSX entry file (with its local imports) and import it. + * A unique temp file per build doubles as ESM cache-busting for rebuilds. + * @param {string} entryFile - Path to the entry file + * @returns {Promise} The imported module namespace + */ +export async function importJSXModule(entryFile) { + const resolvedEntry = resolve(process.cwd(), entryFile); + const code = await compileBundle(resolvedEntry); + + const tempFile = join(process.cwd(), TEMP_DIR, `build-${process.pid}-${buildCounter++}.js`); + await mkdir(dirname(tempFile), { recursive: true }); + await writeFile(tempFile, code); + try { + return await import(pathToFileURL(tempFile).href); + } finally { + await rm(tempFile, { force: true }); + } +} /** * Build a single JSX file * @param {string} inputFile - Path to the JSX file * @param {Object} options - Build options * @param {string} [options.outputDir] - Output directory - * @param {Object} [options.unocssConfig] - UnoCSS configuration + * @param {string} [options.inputRoot] - Root directory the page paths are relative to * @param {boolean} [options.silent] - Suppress console output * @returns {Promise<{outputPath: string, html: string}>} */ export async function buildFile(inputFile, options = {}) { - const { outputDir = DIRS.OUTPUT, unocssConfig, silent = false } = options; + const { outputDir = DIRS.OUTPUT, inputRoot, silent = false } = options; const outDir = resolve(process.cwd(), outputDir); const resolvedInput = resolve(process.cwd(), inputFile); - // Bundle the file with all its dependencies - const bundledCode = await bundle(resolvedInput); - - // Add inline JSX runtime - const codeWithRuntime = INLINE_JSX_RUNTIME + "\n" + bundledCode; - - // Write transformed JS temporarily - const tempFile = join(outDir, `_temp_${Date.now()}.js`); - await mkdir(dirname(tempFile), { recursive: true }); - await writeFile(tempFile, codeWithRuntime); - - // Import and render - const moduleUrl = new URL(`file://${tempFile}?t=${Date.now()}`); - const module = await import(moduleUrl.href); + const module = await importJSXModule(resolvedInput); const App = module.default; if (!App) { @@ -51,10 +111,11 @@ export async function buildFile(inputFile, options = {}) { const html = renderToString(vnode); - // Determine output path - const baseName = basename(inputFile, ".jsx"); - const outputFileName = baseName === "index" ? "index.html" : `${baseName}.html`; - const outputPath = join(outDir, outputFileName); + // Determine output path, preserving directory structure relative to inputRoot + const relPath = inputRoot + ? relative(resolve(process.cwd(), inputRoot), resolvedInput) + : basename(resolvedInput); + const outputPath = join(outDir, relPath.replace(/\.(jsx|tsx)$/, ".html")); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, html); @@ -63,9 +124,6 @@ export async function buildFile(inputFile, options = {}) { console.log(`✓ Built successfully: ${relative(process.cwd(), outputPath)}`); } - // Clean up temp file - await cleanupTempFile(tempFile); - return { outputPath, html }; } @@ -74,41 +132,41 @@ export async function buildFile(inputFile, options = {}) { * @param {string} inputPattern - Input directory path * @param {Object} options - Build options * @param {string} [options.outputDir] - Output directory - * @param {Object} [options.unocssConfig] - UnoCSS configuration * @param {boolean} [options.silent] - Suppress console output * @returns {Promise>} */ export async function buildFiles(inputPattern, options = {}) { - const { outputDir = DIRS.OUTPUT, unocssConfig, silent = false } = options; + const { outputDir = DIRS.OUTPUT, silent = false } = options; const pagesDir = resolve(process.cwd(), inputPattern); const files = await getFilesRecursively(pagesDir, isJSXFile); if (!silent) { console.log(`Found ${files.length} page(s) in ${inputPattern}/\n`); - } - - if (!silent) { for (const file of files) { console.log(`Building ${relative(process.cwd(), file)}...`); } } return Promise.all( - files.map((file) => buildFile(file, { outputDir, unocssConfig, silent: true })), + files.map((file) => + buildFile(file, { outputDir, inputRoot: pagesDir, silent: true }), + ), ); } /** - * Generate UnoCSS file + * Generate UnoCSS file from the built HTML. + * Loads uno.config.js from the project root unless a config is passed in. * @param {Object} options - Generation options * @param {string} [options.outputDir] - Output directory - * @param {Object} [options.unocssConfig] - UnoCSS configuration + * @param {Object} [options.config] - UnoCSS configuration override * @param {boolean} [options.silent] - Suppress console output * @returns {Promise} CSS file path or null */ export async function generateUnoCSS(options = {}) { - const { outputDir = DIRS.OUTPUT, unocssConfig, silent = false } = options; + const { outputDir = DIRS.OUTPUT, silent = false } = options; + const config = options.config ?? (await loadUnoConfig()); const outDir = resolve(process.cwd(), outputDir); @@ -120,7 +178,7 @@ export async function generateUnoCSS(options = {}) { } // Generate CSS from HTML files - const css = await generateCSSFromFiles(htmlFiles, unocssConfig); + const css = await generateCSSFromFiles(htmlFiles, config); if (!css) { return null; diff --git a/packages/ono/src/bundler.js b/packages/ono/src/bundler.js index 52c6ca4..d25547f 100644 --- a/packages/ono/src/bundler.js +++ b/packages/ono/src/bundler.js @@ -1,81 +1,230 @@ /** - * Bundler - Bundle JSX files into a single executable module + * Mini bundler - browser-compatible. + * + * Each module is wrapped in a factory function and linked with a tiny + * lazy require(), so no topological sort is needed and import cycles + * behave like CommonJS. Module syntax is parsed with the parser + * combinators in parser.js — no regular expressions over source code. + * + * The host environment supplies I/O: + * - load(id): return the module's JavaScript source (JSX already transformed) + * - resolve(specifier, fromId): turn a relative specifier into a module id + * + * Known limitations (deliberate, for simplicity): no top-level await, + * no destructuring in exported declarations, `export *` copies a + * snapshot of the source module. */ +import { parseModule, isIdentifierName } from "./parser.js"; -import fs from "node:fs/promises"; -import { collectDependencies } from "./resolver.js"; -import { transformJSX } from "./transformer.js"; +const isRelative = (specifier) => + specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/"); -/** - * Bundle a JSX file and all its dependencies - * @param {string} entryFile - Absolute path to entry file - * @returns {Promise} Bundled JavaScript code - */ -export async function bundle(entryFile) { - // Collect all dependencies - const { order } = await collectDependencies(entryFile); - - const modules = []; - - // Process each module in dependency order - for (let i = 0; i < order.length; i++) { - const filePath = order[i]; - const isEntry = i === order.length - 1; // Last file is entry - - // Read the file - const source = await fs.readFile(filePath, "utf-8"); - - // Transform JSX to JS - const transformed = transformJSX(source, filePath); - - // Remove import statements (they're already resolved) - const withoutImports = removeImports(transformed); - - // Remove export default from non-entry files - const withoutExports = isEntry ? withoutImports : removeExportDefault(withoutImports); +/** Apply text replacements (non-overlapping) to a source string */ +function applyEdits(source, edits) { + let result = source; + for (const edit of [...edits].sort((a, b) => b.start - a.start)) { + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + } + return result; +} - modules.push({ - path: filePath, - code: withoutExports, - isEntry - }); +/** Generate the require/destructuring lines that replace an import statement */ +function importReplacement(imp, requireCall) { + const parts = []; + if (imp.defaultBinding) parts.push(`const ${imp.defaultBinding} = ${requireCall}.default;`); + if (imp.namespace) parts.push(`const ${imp.namespace} = ${requireCall};`); + if (imp.named && imp.named.length > 0) { + const bindings = imp.named + .map(({ imported, local }) => + imported === local ? imported : `${JSON.stringify(imported)}: ${local}`, + ) + .join(", "); + parts.push(`const { ${bindings} } = ${requireCall};`); } + if (parts.length === 0) parts.push(`${requireCall};`); // side-effect only + return parts.join(" "); +} - // Combine all modules into one - const bundledCode = modules.map(m => m.code).join("\n\n"); +/** Record the top-level names an import statement binds */ +function collectBindings(imp, bindings) { + if (imp.defaultBinding) bindings.add(imp.defaultBinding); + if (imp.namespace) bindings.add(imp.namespace); + for (const { local } of imp.named ?? []) bindings.add(local); +} - return bundledCode; +const LINKER_RUNTIME = `const __ono_cache = new Map(); +function __ono_require(id) { + let record = __ono_cache.get(id); + if (!record) { + record = { exports: {} }; + __ono_cache.set(id, record); + __ono_modules[id](record.exports, __ono_require); + } + return record.exports; } +function __ono_export_star(target, source) { + for (const key of Object.keys(source)) { + if (key !== "default") target[key] = source[key]; + } +}`; /** - * Remove import statements from code (but keep package imports) - * @param {string} code - JavaScript code - * @returns {string} Code with relative imports removed, package imports kept + * Bundle an entry module and its local imports into one script. + * + * @param {Object} options + * @param {string} options.entry - Module id of the entry point + * @param {(id: string) => string | Promise} options.load - Return a module's JS source + * @param {(specifier: string, fromId: string) => string} options.resolve - Resolve a relative specifier + * @param {"hoist"|"error"} [options.onExternal] - Bare (package) imports: hoist to the + * bundle top (needs an ESM host, e.g. Node) or throw (e.g. the browser REPL) + * @param {boolean} [options.exposeEntryFunctions] - Also export the entry's top-level + * function declarations (REPL convenience for code without exports) + * @returns {Promise<{code: string, entryId: string, entryExports: string[], externalBindings: Set}>} + * `code` defines __ono_modules/__ono_require and ends by evaluating the + * entry into `__ono_entry`. The caller decides how to expose it. */ -function removeImports(code) { - // Remove only relative import statements, keep package imports - const lines = code.split("\n"); - const filteredLines = lines.filter(line => { - const trimmed = line.trim(); - if (!trimmed.startsWith("import ")) return true; - - // Keep package imports (not starting with . or /) - const importMatch = trimmed.match(/from\s+['"]([^'"]+)['"]/); - if (importMatch && !importMatch[1].startsWith('.') && !importMatch[1].startsWith('/')) { - return true; // Keep package import +export async function bundle(options) { + const { entry, load, resolve, onExternal = "hoist", exposeEntryFunctions = false } = options; + + const moduleCodes = new Map(); + const externalImports = []; + const externalBindings = new Set(); + const entryExports = []; + const addEntryExport = (name) => { + if (!entryExports.includes(name)) entryExports.push(name); + }; + + const queue = [entry]; + while (queue.length > 0) { + const id = queue.shift(); + if (moduleCodes.has(id)) continue; + + const source = await load(id); + let parsed; + try { + parsed = parseModule(source); + } catch (error) { + throw new Error(`${error.message} (in ${id})`); } - return false; // Remove relative import - }); + const isEntry = id === entry; + const edits = []; + // Function declarations are hoisted, so their export assignments go at + // the top of the factory — this keeps them visible across import cycles, + // mirroring ESM hoisting. Everything else is assigned at the end. + const head = []; + const tail = []; + + for (const imp of parsed.imports) { + if (isRelative(imp.specifier)) { + const depId = resolve(imp.specifier, id); + queue.push(depId); + const requireCall = `__ono_require(${JSON.stringify(depId)})`; + edits.push({ start: imp.start, end: imp.end, text: importReplacement(imp, requireCall) }); + } else if (onExternal === "hoist") { + // Package import: move it to the top of the bundle, outside the factories + const statement = source.slice(imp.start, imp.end).trim(); + if (!externalImports.includes(statement)) externalImports.push(statement); + collectBindings(imp, externalBindings); + edits.push({ start: imp.start, end: imp.end, text: "" }); + } else { + throw new Error(`Cannot bundle package import "${imp.specifier}" (in ${id})`); + } + } - return filteredLines.join("\n"); -} + for (const exp of parsed.exports) { + switch (exp.type) { + case "exportStarFrom": { + if (!isRelative(exp.specifier)) { + throw new Error(`"export * from" a package is not supported (in ${id})`); + } + const depId = resolve(exp.specifier, id); + queue.push(depId); + const requireCall = `__ono_require(${JSON.stringify(depId)})`; + const text = exp.alias + ? `__ono_exports[${JSON.stringify(exp.alias)}] = ${requireCall};` + : `__ono_export_star(__ono_exports, ${requireCall});`; + edits.push({ start: exp.start, end: exp.end, text }); + if (isEntry && exp.alias) addEntryExport(exp.alias); + break; + } + case "exportNamedFrom": { + if (!isRelative(exp.specifier)) { + throw new Error(`Re-exporting from a package is not supported (in ${id})`); + } + const depId = resolve(exp.specifier, id); + queue.push(depId); + const requireCall = `__ono_require(${JSON.stringify(depId)})`; + const text = exp.named + .map( + ({ local, exported }) => + `__ono_exports[${JSON.stringify(exported)}] = ${requireCall}[${JSON.stringify(local)}];`, + ) + .join(" "); + edits.push({ start: exp.start, end: exp.end, text }); + if (isEntry) exp.named.forEach(({ exported }) => addEntryExport(exported)); + break; + } + case "exportNamed": { + edits.push({ start: exp.start, end: exp.end, text: "" }); + for (const { local, exported } of exp.named) { + tail.push(`__ono_exports[${JSON.stringify(exported)}] = ${local};`); + if (isEntry) addEntryExport(exported); + } + break; + } + case "exportDefaultDeclaration": { + edits.push({ start: exp.start, end: exp.headerEnd, text: "" }); + const target = exp.declarationKind === "function" ? head : tail; + target.push(`__ono_exports.default = ${exp.name};`); + if (isEntry) addEntryExport("default"); + break; + } + case "exportDefaultExpression": { + edits.push({ start: exp.start, end: exp.headerEnd, text: "__ono_exports.default =" }); + if (isEntry) addEntryExport("default"); + break; + } + case "exportDeclaration": { + edits.push({ start: exp.start, end: exp.headerEnd, text: "" }); + const target = exp.declarationKind === "function" ? head : tail; + for (const name of exp.names) { + target.push(`__ono_exports[${JSON.stringify(name)}] = ${name};`); + if (isEntry) addEntryExport(name); + } + break; + } + } + } -/** - * Remove export default from code - * @param {string} code - JavaScript code - * @returns {string} Code without export default - */ -function removeExportDefault(code) { - return code.replace(/export\s+default\s+/g, ""); + if (exposeEntryFunctions && isEntry) { + for (const name of parsed.topLevelFunctions) { + if (!entryExports.includes(name)) { + tail.push(`__ono_exports[${JSON.stringify(name)}] = ${name};`); + addEntryExport(name); + } + } + } + + let code = applyEdits(source, edits); + if (head.length > 0) code = `${head.join("\n")}\n${code}`; + if (tail.length > 0) code += `\n${tail.join("\n")}`; + moduleCodes.set(id, code); + } + + const parts = []; + if (externalImports.length > 0) parts.push(externalImports.join("\n")); + parts.push("const __ono_modules = {};"); + for (const [id, code] of moduleCodes) { + parts.push(`__ono_modules[${JSON.stringify(id)}] = function (__ono_exports, __ono_require) {\n${code}\n};`); + } + parts.push(LINKER_RUNTIME); + parts.push(`const __ono_entry = __ono_require(${JSON.stringify(entry)});`); + + return { + code: parts.join("\n\n"), + entryId: entry, + entryExports: entryExports.filter((name) => name === "default" || isIdentifierName(name)), + externalBindings, + }; } diff --git a/packages/ono/src/commands/build.js b/packages/ono/src/commands/build.js index db098c4..d88af09 100644 --- a/packages/ono/src/commands/build.js +++ b/packages/ono/src/commands/build.js @@ -1,29 +1,36 @@ /** * Build command for Ono CLI */ -import { resolve } from "node:path"; +import { resolve, join } from "node:path"; import { copyFile, mkdir, readdir, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { loadUnoConfig } from "../unocss.js"; +import { parseArgs } from "node:util"; import { buildFile, buildFiles, generateUnoCSS } from "../builder.js"; import { generateBarrels } from "../barrels.js"; -import { DIRS } from "../constants.js"; +import { DIRS, PORTS } from "../constants.js"; /** - * Parse build command arguments + * Parse arguments shared by the build and dev commands * @param {string[]} args - Command line arguments - * @returns {{ input: string, outputDir: string, showHelp: boolean }} + * @returns {{ input: string, outputDir: string, port: number, showHelp: boolean }} */ -export function parseBuildArgs(args) { - const showHelp = args.includes("--help") || args.includes("-h"); - const nonOptionArgs = args.filter((arg) => !arg.startsWith("--")); - const input = nonOptionArgs[0] || DIRS.PAGES; - const outputDir = args.includes("--output") - ? args[args.indexOf("--output") + 1] - : DIRS.OUTPUT; - - return { input, outputDir, showHelp }; +export function parseCommandArgs(args) { + const { values, positionals } = parseArgs({ + args, + options: { + output: { type: "string", default: DIRS.OUTPUT }, + port: { type: "string", default: String(PORTS.SERVER) }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + }); + + return { + input: positionals[0] || DIRS.PAGES, + outputDir: values.output, + port: Number.parseInt(values.port, 10), + showHelp: values.help, + }; } /** @@ -100,15 +107,13 @@ export async function initializeBarrels() { * @returns {Promise} */ export async function runBuildCommand(args) { - const { input, outputDir, showHelp } = parseBuildArgs(args); + const { input, outputDir, showHelp } = parseCommandArgs(args); if (showHelp) { showBuildHelp(); process.exit(0); } - const unocssConfig = await loadUnoConfig(); - // Generate barrel files if barrels directory exists await initializeBarrels(); @@ -117,16 +122,16 @@ export async function runBuildCommand(args) { const inputStat = await stat(inputPath); if (inputStat.isDirectory()) { - await buildFiles(input, { outputDir, unocssConfig }); + await buildFiles(input, { outputDir }); } else { - await buildFile(input, { outputDir, unocssConfig }); + await buildFile(input, { outputDir }); } // Copy public files await copyPublicFiles(outputDir); // Generate UnoCSS - await generateUnoCSS({ outputDir, unocssConfig }); + await generateUnoCSS({ outputDir }); console.log("\n✨ Build complete!"); } diff --git a/packages/ono/src/commands/dev.js b/packages/ono/src/commands/dev.js index 439fd8e..4b571bd 100644 --- a/packages/ono/src/commands/dev.js +++ b/packages/ono/src/commands/dev.js @@ -3,30 +3,10 @@ */ import { resolve, relative } from "node:path"; import { stat } from "node:fs/promises"; -import { loadUnoConfig } from "../unocss.js"; import { createDevServer } from "../server.js"; import { buildFile, buildFiles, generateUnoCSS } from "../builder.js"; -import { watchFile, watchFiles, createWebSocketServer } from "../watcher.js"; -import { copyPublicFiles, initializeBarrels } from "./build.js"; -import { DIRS, PORTS } from "../constants.js"; - -/** - * Parse dev command arguments - * @param {string[]} args - Command line arguments - * @returns {{ input: string, port: number, outputDir: string }} - */ -export function parseDevArgs(args) { - const nonOptionArgs = args.filter((arg) => !arg.startsWith("--")); - const input = nonOptionArgs[0] || DIRS.PAGES; - const port = args.includes("--port") - ? parseInt(args[args.indexOf("--port") + 1]) - : PORTS.SERVER; - const outputDir = args.includes("--output") - ? args[args.indexOf("--output") + 1] - : DIRS.OUTPUT; - - return { input, port, outputDir }; -} +import { watchFile, watchFiles } from "../watcher.js"; +import { copyPublicFiles, initializeBarrels, parseCommandArgs } from "./build.js"; /** * Run the dev command @@ -34,9 +14,7 @@ export function parseDevArgs(args) { * @returns {Promise} */ export async function runDevCommand(args) { - const { input, port, outputDir } = parseDevArgs(args); - - const unocssConfig = await loadUnoConfig(); + const { input, port, outputDir } = parseCommandArgs(args); // Generate barrel files if barrels directory exists await initializeBarrels(); @@ -46,20 +24,18 @@ export async function runDevCommand(args) { const isDirectory = inputStat.isDirectory(); const initialBuild = isDirectory - ? await buildFiles(input, { outputDir, unocssConfig }) - : [await buildFile(input, { outputDir, unocssConfig })]; + ? await buildFiles(input, { outputDir }) + : [await buildFile(input, { outputDir })]; await copyPublicFiles(outputDir); - await generateUnoCSS({ outputDir, unocssConfig }); - - const { wss } = createWebSocketServer(); + await generateUnoCSS({ outputDir }); const mode = isDirectory ? "pages" : "single"; const indexFile = isDirectory ? "index.html" : relative(outputDir, initialBuild[0].outputPath); - const { port: serverPort } = await createDevServer({ + const { port: serverPort, reload } = await createDevServer({ outputDir, port, mode, @@ -68,8 +44,7 @@ export async function runDevCommand(args) { const watchOpts = { outputDir, - unocssConfig, - wss, + reload, onRebuild: () => copyPublicFiles(outputDir), }; await (isDirectory ? watchFiles(input, watchOpts) : watchFile(input, watchOpts)); diff --git a/packages/ono/src/constants.js b/packages/ono/src/constants.js index e6e57c4..859e66d 100644 --- a/packages/ono/src/constants.js +++ b/packages/ono/src/constants.js @@ -8,8 +8,6 @@ export const PORTS = { /** Default HTTP server port */ SERVER: 3000, - /** Default WebSocket port for live reload */ - WEBSOCKET: 35729, }; /** @@ -74,41 +72,3 @@ export const MIME_TYPES = { ".ttf": "font/ttf", ".eot": "application/vnd.ms-fontobject", }; - -/** - * Node.js error codes - */ -export const ERROR_CODES = { - PORT_IN_USE: "EADDRINUSE", - FILE_NOT_FOUND: "ENOENT", -}; - -/** - * HTTP status codes - */ -export const HTTP_STATUS = { - OK: 200, - NOT_FOUND: 404, - SERVER_ERROR: 500, -}; - -/** - * Inline JSX runtime for bundled output - * This is injected into bundled files to provide JSX support without external dependencies - */ -export const INLINE_JSX_RUNTIME = `function flattenChildren(children) { - const result = []; - for (const child of children) { - if (child === null || child === undefined || typeof child === 'boolean') continue; - if (Array.isArray(child)) { - result.push(...flattenChildren(child)); - } else { - result.push(child); - } - } - return result; -} -function h(tag, props, ...children) { - return { tag, props: props || {}, children: flattenChildren(children) }; -} -`; diff --git a/packages/ono/src/jsx-runtime.js b/packages/ono/src/jsx-runtime.js index 617b998..e10db0f 100644 --- a/packages/ono/src/jsx-runtime.js +++ b/packages/ono/src/jsx-runtime.js @@ -1,14 +1,41 @@ /** * JSX Runtime - createElement function * Creates a VNode (Virtual Node) from JSX + * + * This module is self-contained (no imports) so the builder can inject it + * verbatim into compiled pages as the single source of truth for the runtime. + * It must stay browser-compatible for the REPL. */ -import { flattenChildren } from "./utils.browser.js"; /** - * Fragment symbol for grouping elements without a wrapper + * Fragment symbol for grouping elements without a wrapper. + * Symbol.for keeps identity stable even if the runtime is evaluated twice. */ export const Fragment = Symbol.for("ono.fragment"); +/** + * Flatten array recursively and filter out null/undefined/boolean children + * @param {any[]} children - Array of children to flatten + * @returns {any[]} Flattened array + */ +function flattenChildren(children) { + const result = []; + + for (const child of children) { + if (child === null || child === undefined || typeof child === "boolean") { + continue; + } + + if (Array.isArray(child)) { + result.push(...flattenChildren(child)); + } else { + result.push(child); + } + } + + return result; +} + /** * Create a VNode * @param {string|Function} tag - HTML tag name or component function diff --git a/packages/ono/src/parser.js b/packages/ono/src/parser.js new file mode 100644 index 0000000..8e7bfa2 --- /dev/null +++ b/packages/ono/src/parser.js @@ -0,0 +1,550 @@ +/** + * Parser combinators and an ES module-syntax parser. + * + * Parses only what the bundler needs — import/export declarations and + * top-level function names — using parser combinators instead of regular + * expressions. The rest of the source is skipped by a small scanner that + * understands strings, template literals, comments, and (heuristically) + * regex literals, so an "import" inside a string is never misparsed. + * + * Browser-compatible: no Node.js APIs. + */ + +// --- Combinator core ------------------------------------------------------- +// A parser is a function (input, pos) => result. +// Success: { ok: true, value, pos } Failure: { ok: false, expected, pos } + +const ok = (value, pos) => ({ ok: true, value, pos }); +const fail = (expected, pos) => ({ ok: false, expected, pos }); + +/** Match an exact string */ +export function str(expected) { + return (input, pos) => + input.startsWith(expected, pos) + ? ok(expected, pos + expected.length) + : fail(expected, pos); +} + +/** Run parsers in order, collecting their values */ +export function seq(...parsers) { + return (input, pos) => { + const values = []; + let current = pos; + for (const parser of parsers) { + const result = parser(input, current); + if (!result.ok) return result; + values.push(result.value); + current = result.pos; + } + return ok(values, current); + }; +} + +/** Try parsers in order, returning the first success */ +export function alt(...parsers) { + return (input, pos) => { + let furthest = null; + for (const parser of parsers) { + const result = parser(input, pos); + if (result.ok) return result; + if (!furthest || result.pos > furthest.pos) furthest = result; + } + return furthest || fail("no alternative matched", pos); + }; +} + +/** Match zero or more repetitions */ +export function many(parser) { + return (input, pos) => { + const values = []; + let current = pos; + for (;;) { + const result = parser(input, current); + if (!result.ok || result.pos === current) return ok(values, current); + values.push(result.value); + current = result.pos; + } + }; +} + +/** Make a parser optional (yields null when it fails) */ +export function opt(parser) { + return (input, pos) => { + const result = parser(input, pos); + return result.ok ? result : ok(null, pos); + }; +} + +/** Transform a parser's value */ +export function map(parser, fn) { + return (input, pos) => { + const result = parser(input, pos); + return result.ok ? ok(fn(result.value), result.pos) : result; + }; +} + +/** Zero or more items separated by a separator */ +export function sepBy(item, separator) { + return (input, pos) => { + const first = item(input, pos); + if (!first.ok) return ok([], pos); + const rest = many(map(seq(separator, item), ([, value]) => value)); + const result = rest(input, first.pos); + return ok([first.value, ...result.value], result.pos); + }; +} + +// --- Lexical helpers ------------------------------------------------------- + +const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f" || c === "\v"; +const isIdStart = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_" || c === "$"; +const isIdChar = (c) => isIdStart(c) || (c >= "0" && c <= "9"); + +/** True when the whole string is a valid (ASCII) identifier name */ +export function isIdentifierName(name) { + if (name.length === 0 || !isIdStart(name[0])) return false; + for (let i = 1; i < name.length; i++) { + if (!isIdChar(name[i])) return false; + } + return true; +} + +/** Skip whitespace and comments, returning the new position */ +export function skipTrivia(input, pos) { + for (;;) { + while (pos < input.length && isWs(input[pos])) pos++; + if (input.startsWith("//", pos)) { + while (pos < input.length && input[pos] !== "\n") pos++; + continue; + } + if (input.startsWith("/*", pos)) { + const end = input.indexOf("*/", pos + 2); + pos = end === -1 ? input.length : end + 2; + continue; + } + return pos; + } +} + +/** Wrap a parser so it skips leading trivia */ +function token(parser) { + return (input, pos) => parser(input, skipTrivia(input, pos)); +} + +/** Any identifier-like word (including reserved words) */ +const wordRaw = (input, pos) => { + if (pos >= input.length || !isIdStart(input[pos])) return fail("identifier", pos); + let end = pos + 1; + while (end < input.length && isIdChar(input[end])) end++; + return ok(input.slice(pos, end), end); +}; + +const word = token(wordRaw); + +/** A specific keyword (whole word) */ +const kw = (expected) => + token((input, pos) => { + const result = wordRaw(input, pos); + return result.ok && result.value === expected ? result : fail(`"${expected}"`, pos); + }); + +const punct = (s) => token(str(s)); + +/** A single- or double-quoted string literal, yielding its contents */ +const stringLit = token((input, pos) => { + const quote = input[pos]; + if (quote !== '"' && quote !== "'") return fail("string literal", pos); + let out = ""; + let i = pos + 1; + while (i < input.length) { + const c = input[i]; + if (c === "\\") { + out += input[i + 1] ?? ""; + i += 2; + } else if (c === quote) { + return ok(out, i + 1); + } else if (c === "\n") { + break; + } else { + out += c; + i++; + } + } + return fail("closing quote", pos); +}); + +// --- Import declaration grammar ------------------------------------------- + +// { a, b as c, default as d } +const importSpecifier = map( + seq(word, opt(seq(kw("as"), word))), + ([imported, alias]) => ({ imported, local: alias ? alias[1] : imported }), +); + +const namedList = (specifier) => + map( + seq(punct("{"), sepBy(specifier, punct(",")), opt(punct(",")), punct("}")), + ([, specs]) => specs, + ); + +const namespaceImport = map(seq(punct("*"), kw("as"), word), ([, , name]) => name); + +const importClause = alt( + map(namespaceImport, (namespace) => ({ namespace })), + map(namedList(importSpecifier), (named) => ({ named })), + map( + seq( + word, + opt( + seq( + punct(","), + alt( + map(namespaceImport, (namespace) => ({ namespace })), + map(namedList(importSpecifier), (named) => ({ named })), + ), + ), + ), + ), + ([defaultBinding, rest]) => ({ defaultBinding, ...(rest ? rest[1] : {}) }), + ), +); + +const importDecl = map( + seq( + kw("import"), + alt( + map(stringLit, (specifier) => ({ specifier, sideEffect: true })), + map(seq(importClause, kw("from"), stringLit), ([clause, , specifier]) => ({ + ...clause, + specifier, + })), + ), + opt(punct(";")), + ), + ([, decl]) => ({ type: "import", ...decl }), +); + +// --- Export declaration grammar -------------------------------------------- + +// { a, b as c } +const exportSpecifier = map( + seq(word, opt(seq(kw("as"), word))), + ([local, alias]) => ({ local, exported: alias ? alias[1] : local }), +); + +const exportStarFrom = map( + seq(punct("*"), opt(seq(kw("as"), word)), kw("from"), stringLit, opt(punct(";"))), + ([, alias, , specifier]) => ({ + type: "exportStarFrom", + specifier, + alias: alias ? alias[1] : null, + }), +); + +const exportNamedFrom = map( + seq(namedList(exportSpecifier), kw("from"), stringLit, opt(punct(";"))), + ([named, , specifier]) => ({ type: "exportNamedFrom", named, specifier }), +); + +const exportNamed = map( + seq(namedList(exportSpecifier), opt(punct(";"))), + ([named]) => ({ type: "exportNamed", named }), +); + +// function Foo / async function Foo / class Foo (name optional) +const functionOrClassHead = alt( + map(seq(opt(kw("async")), kw("function"), opt(punct("*")), opt(word)), ([, , , name]) => ({ + kind: "function", + name, + })), + map(seq(kw("class"), opt(word)), ([, name]) => ({ kind: "class", name })), +); + +/** + * Scan `const a = ..., b = ...;` collecting declared names. + * Initializers are skipped with depth/string tracking, not parsed. + */ +function scanDeclaratorNames(input, pos) { + const names = []; + for (;;) { + pos = skipTrivia(input, pos); + const c = input[pos]; + if (c === "{" || c === "[") { + throw new Error("Destructuring in an exported declaration is not supported by the Ono bundler"); + } + const nameResult = wordRaw(input, pos); + if (!nameResult.ok) break; + names.push(nameResult.value); + pos = nameResult.pos; + + // Skip to the next top-level "," (next declarator) or ";" (end) + let depth = 0; + let done = false; + while (pos < input.length) { + pos = skipTrivia(input, pos); + const ch = input[pos]; + if (ch === undefined) break; + if (ch === '"' || ch === "'") { + pos = skipStringLiteral(input, pos); + } else if (ch === "`") { + pos = skipTemplateLiteral(input, pos); + } else if (ch === "(" || ch === "[" || ch === "{") { + depth++; + pos++; + } else if (ch === ")" || ch === "]" || ch === "}") { + depth--; + pos++; + } else if (depth === 0 && ch === ",") { + pos++; + break; + } else if (depth === 0 && ch === ";") { + done = true; + break; + } else { + pos++; + } + } + if (done || pos >= input.length) break; + } + return names; +} + +/** + * Parse an export declaration starting at the `export` keyword. + * For `export ` forms only the header span is consumed + * (the declaration itself stays in place); `consumeTo` says where the + * scanner should continue. + */ +function exportDecl(input, pos) { + const head = kw("export")(input, pos); + if (!head.ok) return head; + const afterExport = head.pos; + + const fromForm = alt(exportStarFrom, exportNamedFrom, exportNamed)(input, afterExport); + if (fromForm.ok) { + return ok({ ...fromForm.value, consumeTo: fromForm.pos }, fromForm.pos); + } + + const defaultKw = kw("default")(input, afterExport); + if (defaultKw.ok) { + const headerEnd = defaultKw.pos; + const decl = functionOrClassHead(input, headerEnd); + if (decl.ok && decl.value.name) { + // export default function Foo() {} — keep the declaration, strip keywords + return ok( + { + type: "exportDefaultDeclaration", + name: decl.value.name, + declarationKind: decl.value.kind, + headerEnd, + consumeTo: headerEnd, + }, + headerEnd, + ); + } + // export default (or anonymous function/class) + return ok({ type: "exportDefaultExpression", headerEnd, consumeTo: headerEnd }, headerEnd); + } + + const fnHead = functionOrClassHead(input, afterExport); + if (fnHead.ok && fnHead.value.name) { + return ok( + { + type: "exportDeclaration", + names: [fnHead.value.name], + declarationKind: fnHead.value.kind, + headerEnd: afterExport, + consumeTo: afterExport, + }, + afterExport, + ); + } + + const kind = alt(kw("const"), kw("let"), kw("var"))(input, afterExport); + if (kind.ok) { + const names = scanDeclaratorNames(input, kind.pos); + if (names.length > 0) { + return ok( + { + type: "exportDeclaration", + names, + declarationKind: "variable", + headerEnd: afterExport, + consumeTo: afterExport, + }, + afterExport, + ); + } + } + + return fail("export declaration", afterExport); +} + +// --- Source scanner --------------------------------------------------------- + +function skipStringLiteral(input, pos) { + const quote = input[pos]; + let i = pos + 1; + while (i < input.length) { + const c = input[i]; + if (c === "\\") i += 2; + else if (c === quote) return i + 1; + else if (c === "\n") return i; // unterminated — bail out + else i++; + } + return i; +} + +function skipTemplateLiteral(input, pos) { + let i = pos + 1; + while (i < input.length) { + const c = input[i]; + if (c === "\\") { + i += 2; + } else if (c === "`") { + return i + 1; + } else if (c === "$" && input[i + 1] === "{") { + // Skip the embedded expression (may contain nested strings/templates) + i += 2; + let depth = 1; + while (i < input.length && depth > 0) { + i = skipTrivia(input, i); + const e = input[i]; + if (e === undefined) break; + if (e === '"' || e === "'") i = skipStringLiteral(input, i); + else if (e === "`") i = skipTemplateLiteral(input, i); + else { + if (e === "{") depth++; + else if (e === "}") depth--; + i++; + } + } + } else { + i++; + } + } + return i; +} + +function skipRegexLiteral(input, pos) { + let i = pos + 1; + let inClass = false; + while (i < input.length) { + const c = input[i]; + if (c === "\\") i += 2; + else if (c === "[") { inClass = true; i++; } + else if (c === "]") { inClass = false; i++; } + else if (c === "/" && !inClass) { + i++; + while (i < input.length && isIdChar(input[i])) i++; // flags + return i; + } else if (c === "\n") { + return i; // not a regex after all — bail out + } else { + i++; + } + } + return i; +} + +const REGEX_ALLOWED_AFTER_WORD = new Set([ + "return", "typeof", "case", "in", "of", "new", "delete", "void", + "instanceof", "do", "else", "yield", "await", +]); +const REGEX_ALLOWED_AFTER_CHAR = new Set([...("=([{,;:!&|?+-*%^~<>")]); + +/** True when the previous token puts us in expression position */ +function isExpressionContext(lastToken) { + return ( + REGEX_ALLOWED_AFTER_CHAR.has(lastToken) || + REGEX_ALLOWED_AFTER_WORD.has(lastToken) + ); +} + +/** + * Parse the module syntax of a JavaScript source file. + * @param {string} source - JavaScript source (post JSX transform) + * @returns {{imports: object[], exports: object[], topLevelFunctions: string[]}} + * Each statement carries a `start`/`end` (or `headerEnd`) span into `source`. + */ +export function parseModule(source) { + const imports = []; + const exportStatements = []; + const topLevelFunctions = []; + + let pos = 0; + let braceDepth = 0; + let lastToken = ""; // previous significant token (word or single char) + + while (pos < source.length) { + pos = skipTrivia(source, pos); + if (pos >= source.length) break; + const c = source[pos]; + + if (c === '"' || c === "'") { + pos = skipStringLiteral(source, pos); + lastToken = c; + continue; + } + if (c === "`") { + pos = skipTemplateLiteral(source, pos); + lastToken = c; + continue; + } + if (c === "/") { + // skipTrivia already handled comments, so this is division or a regex + if (lastToken === "" || REGEX_ALLOWED_AFTER_CHAR.has(lastToken) || REGEX_ALLOWED_AFTER_WORD.has(lastToken)) { + pos = skipRegexLiteral(source, pos); + } else { + pos++; + } + lastToken = "/"; + continue; + } + + if (isIdStart(c)) { + const start = pos; + let end = pos + 1; + while (end < source.length && isIdChar(source[end])) end++; + const wordText = source.slice(start, end); + + if (wordText === "import") { + const result = importDecl(source, start); + if (result.ok) { + imports.push({ ...result.value, start, end: result.pos }); + pos = result.pos; + lastToken = ";"; + continue; + } + // dynamic import() or import.meta — fall through + } else if (wordText === "export" && braceDepth === 0) { + const result = exportDecl(source, start); + if (result.ok) { + const { consumeTo, ...statement } = result.value; + exportStatements.push({ ...statement, start, end: result.pos }); + pos = consumeTo; + lastToken = ";"; + continue; + } + } else if (wordText === "function" && braceDepth === 0 && lastToken !== "async" && !isExpressionContext(lastToken)) { + // A function *declaration* — `const f = function foo() {}` is skipped + const name = word(source, end); + if (name.ok) topLevelFunctions.push(name.value); + } else if (wordText === "async" && braceDepth === 0 && !isExpressionContext(lastToken)) { + const fn = seq(kw("function"), opt(punct("*")), word)(source, end); + if (fn.ok) topLevelFunctions.push(fn.value[2]); + } + + pos = end; + lastToken = wordText; + continue; + } + + if (c === "{") braceDepth++; + else if (c === "}") braceDepth = Math.max(0, braceDepth - 1); + lastToken = c; + pos++; + } + + return { imports, exports: exportStatements, topLevelFunctions }; +} diff --git a/packages/ono/src/resolver.js b/packages/ono/src/resolver.js deleted file mode 100644 index 10ad294..0000000 --- a/packages/ono/src/resolver.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Module Resolver - Parse imports and resolve dependencies - */ - -import fs from "node:fs/promises"; -import path from "node:path"; - -/** - * Parse import statements from source code - * @param {string} code - Source code - * @returns {Array} Array of import objects with specifier - */ -export function parseImports(code) { - const imports = []; - - // Match various import patterns: - // import foo from "bar" - // import { foo } from "bar" - // import * as foo from "bar" - // import "bar" - const importRegex = /import\s+(?:[\w{},\s*]+\s+from\s+)?['"]([^'"]+)['"]/g; - - let match; - while ((match = importRegex.exec(code)) !== null) { - imports.push({ - specifier: match[1] - }); - } - - return imports; -} - -/** - * Resolve import path relative to the importing file - * @param {string} importPath - Import specifier (e.g., "./Button.jsx") - * @param {string} fromFile - Absolute path of the file doing the import - * @returns {string} Absolute path to the imported file - */ -export function resolveImportPath(importPath, fromFile) { - // If it's already an absolute path, return as-is - if (path.isAbsolute(importPath)) { - return importPath; - } - - // For relative imports, resolve relative to the importing file - const dir = path.dirname(fromFile); - return path.resolve(dir, importPath); -} - -/** - * Topological sort for dependency graph - * @param {Map} graph - Dependency graph (file -> [dependencies]) - * @returns {Array} Sorted array of file paths - */ -function topologicalSort(graph) { - const sorted = []; - const visited = new Set(); - const visiting = new Set(); - - function visit(node) { - if (visited.has(node)) return; - if (visiting.has(node)) { - throw new Error(`Circular dependency detected: ${node}`); - } - - visiting.add(node); - - const deps = graph.get(node) || []; - for (const dep of deps) { - visit(dep); - } - - visiting.delete(node); - visited.add(node); - sorted.push(node); - } - - // Visit all nodes - for (const node of graph.keys()) { - visit(node); - } - - return sorted; -} - -/** - * Collect all dependencies recursively - * @param {string} entryFile - Absolute path to entry file - * @returns {Object} Object with modules (Set), graph (Map), and order (Array) - */ -export async function collectDependencies(entryFile) { - const modules = new Set(); - const graph = new Map(); - const queue = [entryFile]; - - // BFS to collect all dependencies - while (queue.length > 0) { - const currentFile = queue.shift(); - - // Skip if already processed - if (modules.has(currentFile)) continue; - - // Read the file - let source; - try { - source = await fs.readFile(currentFile, "utf-8"); - } catch (error) { - throw new Error(`Cannot read file: ${currentFile}\n${error.message}`); - } - - // Parse imports - const imports = parseImports(source); - const dependencies = []; - - for (const imp of imports) { - // Only process relative imports (skip node_modules, etc.) - if (imp.specifier.startsWith(".")) { - const resolvedPath = resolveImportPath(imp.specifier, currentFile); - dependencies.push(resolvedPath); - queue.push(resolvedPath); - } - } - - // Add to modules and graph - modules.add(currentFile); - graph.set(currentFile, dependencies); - } - - // Sort dependencies topologically - const order = topologicalSort(graph); - - return { - modules, - graph, - order - }; -} diff --git a/packages/ono/src/server.js b/packages/ono/src/server.js index 58f58f9..94c7285 100644 --- a/packages/ono/src/server.js +++ b/packages/ono/src/server.js @@ -1,12 +1,23 @@ /** - * Dev server using h3 + * Dev server - static files plus SSE live reload, no dependencies. + * + * The server injects a small EventSource script into served HTML and + * exposes a reload() function that broadcasts to connected browsers. */ -import { createApp, eventHandler, setResponseHeader, createError } from "h3"; -import { toNodeHandler } from "h3/node"; import { createServer } from "node:http"; -import { resolve, join, extname } from "node:path"; +import { resolve, join, extname, normalize } from "node:path"; import { readFile } from "node:fs/promises"; -import { DIRS, PORTS, MIME_TYPES, HTTP_STATUS, ERROR_CODES } from "./constants.js"; +import { DIRS, PORTS, MIME_TYPES } from "./constants.js"; + +const RELOAD_PATH = "/__ono_reload"; +const RELOAD_SCRIPT = ``; + +function injectReloadScript(html) { + if (/<\/body>/i.test(html)) { + return html.replace(/<\/body>/i, `${RELOAD_SCRIPT}`); + } + return html + RELOAD_SCRIPT; +} /** * Create a development server @@ -15,50 +26,72 @@ import { DIRS, PORTS, MIME_TYPES, HTTP_STATUS, ERROR_CODES } from "./constants.j * @param {number} [options.port] - HTTP port * @param {string} [options.mode] - Server mode: 'pages' or 'single' * @param {string} [options.indexFile] - Index file for single mode - * @returns {Promise<{server: import('http').Server, app: any, port: number}>} + * @returns {Promise<{server: import('http').Server, port: number, reload: () => void}>} */ export async function createDevServer(options) { const { outputDir = DIRS.OUTPUT, port = PORTS.SERVER, mode = "pages", indexFile = "index.html" } = options; const outDir = resolve(process.cwd(), outputDir); + const clients = new Set(); + + const server = createServer(async (req, res) => { + const url = (req.url || "/").split("?")[0]; - const app = createApp(); + // SSE endpoint for live reload + if (url === RELOAD_PATH) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write(": connected\n\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } - app.use( - "/**", - eventHandler(async (event) => { - const url = event.path || "/"; - const isFavicon = url === "/favicon.ico"; - const rootFile = mode === "single" ? indexFile : "index.html"; - const filePath = join(outDir, url === "/" || url === "" ? rootFile : url); + const rootFile = mode === "single" ? indexFile : "index.html"; + const relPath = url === "/" ? rootFile : url.endsWith("/") ? join(url, "index.html") : url; + const filePath = normalize(join(outDir, relPath)); - try { - const content = await readFile(filePath); - setResponseHeader(event, "Content-Type", MIME_TYPES[extname(filePath)] || "application/octet-stream"); - return content; - } catch (error) { - if (!isFavicon) console.error("Server error:", error); - if (error.code === ERROR_CODES.FILE_NOT_FOUND) { - throw createError({ - statusCode: HTTP_STATUS.NOT_FOUND, - statusMessage: "Not Found", - message: isFavicon ? "Favicon not found" : `File not found: ${error.path}`, - }); - } - throw createError({ - statusCode: HTTP_STATUS.SERVER_ERROR, - statusMessage: "Internal Server Error", - message: error.message, - }); + if (!filePath.startsWith(outDir)) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Forbidden"); + return; + } + + try { + const content = await readFile(filePath); + const contentType = MIME_TYPES[extname(filePath)] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": contentType }); + + if (filePath.endsWith(".html")) { + res.end(injectReloadScript(content.toString())); + } else { + res.end(content); } - }) - ); + } catch (error) { + if (error.code === "ENOENT") { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(`Not Found: ${url}`); + } else { + console.error("Server error:", error); + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(`Server Error: ${error.message}`); + } + } + }); - const server = createServer(toNodeHandler(app)); + /** Tell all connected browsers to reload */ + const reload = () => { + for (const client of clients) { + client.write("data: reload\n\n"); + } + }; return new Promise((resolvePromise, rejectPromise) => { const onError = (err) => { - if (err.code === ERROR_CODES.PORT_IN_USE) { + if (err.code === "EADDRINUSE") { const nextPort = port + 1; console.log(`ℹ️ Port ${port} is busy, using port ${nextPort} instead`); server.removeListener("error", onError); @@ -70,7 +103,7 @@ export async function createDevServer(options) { server.once("error", onError); server.listen(port, () => { server.removeListener("error", onError); - resolvePromise({ server, app, port }); + resolvePromise({ server, port, reload }); }); }); } diff --git a/packages/ono/src/transformer.js b/packages/ono/src/transformer.js index 7ea1440..63e4c9a 100644 --- a/packages/ono/src/transformer.js +++ b/packages/ono/src/transformer.js @@ -29,23 +29,3 @@ export function transformJSX(source, filename = 'input.jsx') { return result.outputText; } - -/** - * Transform JSX file and add necessary imports if not present - * @param {string} source - JSX source code - * @param {string} [filename='input.jsx'] - Optional filename - * @returns {string} Transformed JavaScript with imports - */ -export function transformJSXWithImports(source, filename = 'input.jsx') { - let transformedCode = transformJSX(source, filename); - - // Check if the code uses 'h' function (JSX was transformed) - if (transformedCode.includes('h(') && !source.includes('import') && !source.includes('from')) { - // Add import statement for h function (and Fragment if used) - const usesFragment = transformedCode.includes('Fragment'); - const imports = usesFragment ? '{ h, Fragment }' : '{ h }'; - transformedCode = `import ${imports} from './jsx-runtime.js';\n${transformedCode}`; - } - - return transformedCode; -} diff --git a/packages/ono/src/unocss.js b/packages/ono/src/unocss.js index 430ac0f..9d5e512 100644 --- a/packages/ono/src/unocss.js +++ b/packages/ono/src/unocss.js @@ -1,31 +1,26 @@ /** - * UnoCSS Integration for Mini JSX + * UnoCSS Integration for Ono */ -import { createGenerator, presetUno } from "unocss"; +import { createGenerator } from "@unocss/core"; +import { presetUno } from "@unocss/preset-uno"; import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); /** * Get the Tailwind reset CSS * @returns {Promise} Reset CSS content */ async function getResetCSS() { - const resetPath = path.resolve(__dirname, "../node_modules/@unocss/reset/tailwind.css"); try { - return await fs.readFile(resetPath, "utf-8"); + return await fs.readFile(require.resolve("@unocss/reset/tailwind.css"), "utf-8"); } catch { - // Fallback: try to find it relative to the package - try { - const fallbackPath = new URL("../node_modules/@unocss/reset/tailwind.css", import.meta.url); - return await fs.readFile(fileURLToPath(fallbackPath), "utf-8"); - } catch { - return ""; - } + return ""; } } @@ -42,19 +37,20 @@ export async function createUnoGenerator(userConfig = {}) { } /** - * Load UnoCSS config from file - * @param {string} configPath - Path to config file + * Load UnoCSS config from file (defaults to uno.config.js in the project root). + * Returns an empty config when the file doesn't exist; errors inside an + * existing config file are NOT swallowed. + * @param {string} [configPath] - Path to config file * @returns {Promise} Configuration object */ export async function loadUnoConfig(configPath) { - try { - const configUrl = `file://${path.resolve(configPath)}?t=${Date.now()}`; - const module = await import(configUrl); - return module.default || module; - } catch (error) { - // Config file doesn't exist, return empty config + const resolved = path.resolve(process.cwd(), configPath || "uno.config.js"); + if (!existsSync(resolved)) { return {}; } + const configUrl = `${pathToFileURL(resolved).href}?t=${Date.now()}`; + const module = await import(configUrl); + return module.default || module; } /** diff --git a/packages/ono/src/utils.browser.js b/packages/ono/src/utils.browser.js deleted file mode 100644 index 2057b90..0000000 --- a/packages/ono/src/utils.browser.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Browser-compatible utilities for Ono SSG - * These functions can be used in both Node.js and browser environments. - */ - -/** - * Flatten array recursively and filter out falsy values (null, undefined, boolean) - * @param {any[]} children - Array of children to flatten - * @returns {any[]} Flattened array with falsy values removed - */ -export function flattenChildren(children) { - const result = []; - - for (const child of children) { - if (child === null || child === undefined || typeof child === "boolean") { - continue; - } - - if (Array.isArray(child)) { - result.push(...flattenChildren(child)); - } else { - result.push(child); - } - } - - return result; -} - -/** - * Check if a filename has a JSX extension (.jsx or .tsx) - * @param {string} filename - The filename to check - * @returns {boolean} True if the file has a JSX extension - */ -export function isJSXFile(filename) { - return filename.endsWith(".jsx") || filename.endsWith(".tsx"); -} - -/** - * Check if a filename has an HTML extension - * @param {string} filename - The filename to check - * @returns {boolean} True if the file has an HTML extension - */ -export function isHTMLFile(filename) { - return filename.endsWith(".html"); -} - -/** - * Convert kebab-case or snake_case to camelCase - * @param {string} str - String to convert - * @returns {string} camelCase string - */ -export function toCamelCase(str) { - return str.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase()); -} - -/** - * Create a debounced version of a function - * @param {Function} fn - Function to debounce - * @param {number} delay - Delay in milliseconds - * @returns {Function} Debounced function - */ -export function debounce(fn, delay) { - let timeoutId; - return (...args) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => fn(...args), delay); - }; -} diff --git a/packages/ono/src/utils.js b/packages/ono/src/utils.js index 4e381f9..af042cb 100644 --- a/packages/ono/src/utils.js +++ b/packages/ono/src/utils.js @@ -1,36 +1,29 @@ /** - * Node.js utilities for Ono SSG - * This file contains utilities that require Node.js runtime. - * For browser-compatible utilities, use utils.browser.js + * Shared utilities for Ono SSG */ -import { unlink } from "node:fs/promises"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; -// Re-export browser-compatible utilities for backward compatibility -export { - flattenChildren, - isJSXFile, - isHTMLFile, - toCamelCase, - debounce, -} from "./utils.browser.js"; +/** + * Check if a filename has a JSX extension (.jsx or .tsx) + * @param {string} filename - The filename to check + * @returns {boolean} True if the file has a JSX extension + */ +export function isJSXFile(filename) { + return filename.endsWith(".jsx") || filename.endsWith(".tsx"); +} /** - * Clean up a temporary file, silently ignoring errors - * @param {string} tempFile - Path to the temporary file to delete - * @returns {Promise} + * Check if a filename has an HTML extension + * @param {string} filename - The filename to check + * @returns {boolean} True if the file has an HTML extension */ -export async function cleanupTempFile(tempFile) { - try { - await unlink(tempFile); - } catch { - // Ignore cleanup errors - } +export function isHTMLFile(filename) { + return filename.endsWith(".html"); } /** - * Get all files with specified extensions recursively from a directory + * Get all files matching a predicate recursively from a directory * @param {string} dir - Directory to search * @param {(filename: string) => boolean} predicate - Function to test filenames * @returns {Promise} Array of file paths diff --git a/packages/ono/src/watcher.js b/packages/ono/src/watcher.js index 8f5d579..0f8ddf4 100644 --- a/packages/ono/src/watcher.js +++ b/packages/ono/src/watcher.js @@ -3,39 +3,10 @@ */ import { watch } from "node:fs"; import { resolve, join, relative } from "node:path"; -import { WebSocketServer } from "ws"; import { buildFile, buildFiles, generateUnoCSS } from "./builder.js"; import { generateBarrel } from "./barrels.js"; import { isJSXFile } from "./utils.js"; -import { PORTS, TIMING, DIRS, ERROR_CODES } from "./constants.js"; - -/** - * Create a WebSocket server for live reload - * @param {number} [port] - Port number for WebSocket server - * @returns {{wss: WebSocketServer, port: number}} - */ -export function createWebSocketServer(port = PORTS.WEBSOCKET) { - try { - return { wss: new WebSocketServer({ port }), port }; - } catch (error) { - if (error.code !== ERROR_CODES.PORT_IN_USE) throw error; - const actualPort = port + 1; - console.log(`ℹ️ WebSocket port ${port} is busy, using port ${actualPort} instead`); - return { wss: new WebSocketServer({ port: actualPort }), port: actualPort }; - } -} - -/** - * Broadcast reload message to all connected clients - * @param {WebSocketServer} wss - WebSocket server instance - */ -export function broadcastReload(wss) { - wss.clients.forEach((client) => { - if (client.readyState === 1) { - client.send("reload"); - } - }); -} +import { TIMING, DIRS } from "./constants.js"; /** * Create a debounced async runner that logs errors instead of throwing. @@ -55,11 +26,11 @@ function debounce(fn, ms = TIMING.DEBOUNCE_MS) { } /** - * Notify clients and trigger onRebuild callback. + * Trigger onRebuild callback and browser reload. */ -async function afterRebuild({ onRebuild, wss }) { +async function afterRebuild({ onRebuild, reload }) { if (onRebuild) await onRebuild(); - if (wss) broadcastReload(wss); + if (reload) reload(); } /** @@ -67,16 +38,15 @@ async function afterRebuild({ onRebuild, wss }) { * @param {string} inputPattern - Input directory to watch * @param {Object} options - Watch options * @param {string} [options.outputDir] - Output directory - * @param {Object} [options.unocssConfig] - UnoCSS configuration * @param {Function} [options.onRebuild] - Callback after rebuild - * @param {WebSocketServer} [options.wss] - WebSocket server for live reload + * @param {Function} [options.reload] - Live-reload broadcast from the dev server * @returns {Promise<{watcher: any, publicWatcher?: any, barrelsWatcher?: any}>} */ export async function watchFiles(inputPattern, options = {}) { - const { outputDir = DIRS.OUTPUT, unocssConfig, onRebuild, wss } = options; - const buildOpts = { outputDir, unocssConfig, silent: false }; + const { outputDir = DIRS.OUTPUT, onRebuild, reload } = options; const pagesDir = resolve(process.cwd(), inputPattern); + const buildOpts = { outputDir, inputRoot: pagesDir, silent: false }; const publicDir = resolve(process.cwd(), DIRS.PUBLIC); const barrelsDir = resolve(process.cwd(), DIRS.BARRELS); @@ -86,16 +56,16 @@ export async function watchFiles(inputPattern, options = {}) { console.log(`\n📝 File changed: ${relative(process.cwd(), file)}`); console.log("🔄 Rebuilding...\n"); await buildFile(file, buildOpts); - await generateUnoCSS(buildOpts); - await afterRebuild({ onRebuild, wss }); + await generateUnoCSS({ outputDir }); + await afterRebuild({ onRebuild, reload }); }); const rebuildAll = debounce(async (reason) => { console.log(`\n📝 ${reason}`); console.log("🔄 Rebuilding...\n"); await buildFiles(inputPattern, buildOpts); - await generateUnoCSS(buildOpts); - await afterRebuild({ onRebuild, wss }); + await generateUnoCSS({ outputDir }); + await afterRebuild({ onRebuild, reload }); }); const watcher = watch(pagesDir, { recursive: true }, (_eventType, filename) => { @@ -118,8 +88,8 @@ export async function watchFiles(inputPattern, options = {}) { console.log("🔄 Regenerating barrel...\n"); await generateBarrel(barrelDir); await buildFiles(inputPattern, buildOpts); - await generateUnoCSS(buildOpts); - await afterRebuild({ onRebuild, wss }); + await generateUnoCSS({ outputDir }); + await afterRebuild({ onRebuild, reload }); }); let barrelsWatcher; @@ -142,14 +112,13 @@ export async function watchFiles(inputPattern, options = {}) { * @param {string} inputFile - Input file to watch * @param {Object} options - Watch options * @param {string} [options.outputDir] - Output directory - * @param {Object} [options.unocssConfig] - UnoCSS configuration * @param {Function} [options.onRebuild] - Callback after rebuild - * @param {WebSocketServer} [options.wss] - WebSocket server for live reload + * @param {Function} [options.reload] - Live-reload broadcast from the dev server * @returns {Promise<{watcher: any}>} */ export async function watchFile(inputFile, options = {}) { - const { outputDir = DIRS.OUTPUT, unocssConfig, onRebuild, wss } = options; - const buildOpts = { outputDir, unocssConfig, silent: false }; + const { outputDir = DIRS.OUTPUT, onRebuild, reload } = options; + const buildOpts = { outputDir, silent: false }; const resolvedInput = resolve(process.cwd(), inputFile); console.log(`👀 Watching for changes in ${inputFile}...`); @@ -158,8 +127,8 @@ export async function watchFile(inputFile, options = {}) { console.log(`\n📝 File changed: ${inputFile}`); console.log("🔄 Rebuilding...\n"); await buildFile(resolvedInput, buildOpts); - await generateUnoCSS(buildOpts); - await afterRebuild({ onRebuild, wss }); + await generateUnoCSS({ outputDir }); + await afterRebuild({ onRebuild, reload }); }); return { watcher: watch(resolvedInput, rebuild) }; diff --git a/packages/ono/test/__snapshots__/cli.snapshot.build_command_help.snap b/packages/ono/test/__snapshots__/cli.snapshot.build_command_help.snap deleted file mode 100644 index 1eb518c..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.build_command_help.snap +++ /dev/null @@ -1,14 +0,0 @@ -Exit Code: 0 -STDOUT: -Usage: ono build [input] [options] -Build JSX files to static HTML -Arguments: - input File or directory to build (default: pages) -Options: - --output Output directory (default: dist) - --help Show this help message -Examples: - ono build Build all pages in pages/ directory - ono build pages/index.jsx Build a single file - ono build --output build Build to build/ directory -STDERR: \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.build_non-existent_directory.snap b/packages/ono/test/__snapshots__/cli.snapshot.build_non-existent_directory.snap deleted file mode 100644 index 52d899b..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.build_non-existent_directory.snap +++ /dev/null @@ -1,4 +0,0 @@ -Exit Code: 1 -STDOUT: -STDERR: -Error: ENOENT: no such file or directory, stat '/PATH/test-tmp/non-existent-dir' \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.build_non-existent_file.snap b/packages/ono/test/__snapshots__/cli.snapshot.build_non-existent_file.snap deleted file mode 100644 index b33aeb5..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.build_non-existent_file.snap +++ /dev/null @@ -1,4 +0,0 @@ -Exit Code: 1 -STDOUT: -STDERR: -Error: ENOENT: no such file or directory, stat '/PATH/test-tmp/non-existent.jsx' \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.help_message.snap b/packages/ono/test/__snapshots__/cli.snapshot.help_message.snap deleted file mode 100644 index e265ae2..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.help_message.snap +++ /dev/null @@ -1,20 +0,0 @@ -Exit Code: 0 -STDOUT: -Ono - Minimalist SSG Framework -Usage: - ono [options] -Commands: - build [input] Build JSX files to static HTML - input: file or directory (default: pages) - dev [input] Build, watch, and serve with live reload - input: file or directory (default: pages) -Options: - --output Output directory (default: dist) - --port Server port (default: 3000) - --help Show this help message -Examples: - ono build Build all pages in pages/ directory - ono build pages/index.jsx Build a single file - ono dev Start dev server with live reload - ono dev --port 8080 Start dev server on port 8080 -STDERR: \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.help_message_short_flag.snap b/packages/ono/test/__snapshots__/cli.snapshot.help_message_short_flag.snap deleted file mode 100644 index e265ae2..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.help_message_short_flag.snap +++ /dev/null @@ -1,20 +0,0 @@ -Exit Code: 0 -STDOUT: -Ono - Minimalist SSG Framework -Usage: - ono [options] -Commands: - build [input] Build JSX files to static HTML - input: file or directory (default: pages) - dev [input] Build, watch, and serve with live reload - input: file or directory (default: pages) -Options: - --output Output directory (default: dist) - --port Server port (default: 3000) - --help Show this help message -Examples: - ono build Build all pages in pages/ directory - ono build pages/index.jsx Build a single file - ono dev Start dev server with live reload - ono dev --port 8080 Start dev server on port 8080 -STDERR: \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.no_command_provided.snap b/packages/ono/test/__snapshots__/cli.snapshot.no_command_provided.snap deleted file mode 100644 index e265ae2..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.no_command_provided.snap +++ /dev/null @@ -1,20 +0,0 @@ -Exit Code: 0 -STDOUT: -Ono - Minimalist SSG Framework -Usage: - ono [options] -Commands: - build [input] Build JSX files to static HTML - input: file or directory (default: pages) - dev [input] Build, watch, and serve with live reload - input: file or directory (default: pages) -Options: - --output Output directory (default: dist) - --port Server port (default: 3000) - --help Show this help message -Examples: - ono build Build all pages in pages/ directory - ono build pages/index.jsx Build a single file - ono dev Start dev server with live reload - ono dev --port 8080 Start dev server on port 8080 -STDERR: \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.serve_command.snap b/packages/ono/test/__snapshots__/cli.snapshot.serve_command.snap deleted file mode 100644 index 322e1d1..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.serve_command.snap +++ /dev/null @@ -1,5 +0,0 @@ -Exit Code: 1 -STDOUT: -STDERR: -Unknown command: serve -Run "ono --help" for usage information \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.unknown_command.snap b/packages/ono/test/__snapshots__/cli.snapshot.unknown_command.snap deleted file mode 100644 index 58acc2e..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.unknown_command.snap +++ /dev/null @@ -1,5 +0,0 @@ -Exit Code: 1 -STDOUT: -STDERR: -Unknown command: unknown-command -Run "ono --help" for usage information \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.version_flag_not_supported.snap b/packages/ono/test/__snapshots__/cli.snapshot.version_flag_not_supported.snap deleted file mode 100644 index fc00d92..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.version_flag_not_supported.snap +++ /dev/null @@ -1,5 +0,0 @@ -Exit Code: 1 -STDOUT: -STDERR: -Unknown command: --version -Run "ono --help" for usage information \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/cli.snapshot.watch_command_on_empty_directory.snap b/packages/ono/test/__snapshots__/cli.snapshot.watch_command_on_empty_directory.snap deleted file mode 100644 index 13773e1..0000000 --- a/packages/ono/test/__snapshots__/cli.snapshot.watch_command_on_empty_directory.snap +++ /dev/null @@ -1,5 +0,0 @@ -Exit Code: 1 -STDOUT: -STDERR: -Unknown command: watch -Run "ono --help" for usage information \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.HTML_entities_escaping.snap b/packages/ono/test/__snapshots__/renderer.snapshot.HTML_entities_escaping.snap deleted file mode 100644 index 1e26344..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.HTML_entities_escaping.snap +++ /dev/null @@ -1 +0,0 @@ -
Content with <em>HTML</em> & "special" characters
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.boolean_attributes.snap b/packages/ono/test/__snapshots__/renderer.snapshot.boolean_attributes.snap deleted file mode 100644 index 24d8f32..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.boolean_attributes.snap +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.complex_form.snap b/packages/ono/test/__snapshots__/renderer.snapshot.complex_form.snap deleted file mode 100644 index d849960..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.complex_form.snap +++ /dev/null @@ -1,16 +0,0 @@ -
-
- - -
-
- - -
-
- - -
- -
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.component_function.snap b/packages/ono/test/__snapshots__/renderer.snapshot.component_function.snap deleted file mode 100644 index 6842eab..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.component_function.snap +++ /dev/null @@ -1,10 +0,0 @@ -
-
-

My First Post

-

By John Doe

-
-
-

This is the first paragraph.

-

This is the second paragraph.

-
-
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.element_with_attributes.snap b/packages/ono/test/__snapshots__/renderer.snapshot.element_with_attributes.snap deleted file mode 100644 index 9e0ac17..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.element_with_attributes.snap +++ /dev/null @@ -1,2 +0,0 @@ -
-
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.element_with_text.snap b/packages/ono/test/__snapshots__/renderer.snapshot.element_with_text.snap deleted file mode 100644 index 34fa97d..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.element_with_text.snap +++ /dev/null @@ -1 +0,0 @@ -
Hello World
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.nested_elements.snap b/packages/ono/test/__snapshots__/renderer.snapshot.nested_elements.snap deleted file mode 100644 index 254e9b4..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.nested_elements.snap +++ /dev/null @@ -1,9 +0,0 @@ -
-

Title

-

This is a paragraph with bold text and regular text.

-
    -
  • Item 1
  • -
  • Item 2
  • -
  • Item 3
  • -
-
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.simple_element.snap b/packages/ono/test/__snapshots__/renderer.snapshot.simple_element.snap deleted file mode 100644 index 094418b..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.simple_element.snap +++ /dev/null @@ -1,2 +0,0 @@ -
-
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/renderer.snapshot.style_object.snap b/packages/ono/test/__snapshots__/renderer.snapshot.style_object.snap deleted file mode 100644 index 0675fd3..0000000 --- a/packages/ono/test/__snapshots__/renderer.snapshot.style_object.snap +++ /dev/null @@ -1 +0,0 @@ -
Styled content
\ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.JSX_fragments.snap b/packages/ono/test/__snapshots__/transformer.snapshot.JSX_fragments.snap deleted file mode 100644 index 0c71f4b..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.JSX_fragments.snap +++ /dev/null @@ -1,4 +0,0 @@ -const content = (h(Fragment, null, - h("h1", null, "Title"), - h("p", null, "First paragraph"), - h("p", null, "Second paragraph"))); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.JSX_with_expressions.snap b/packages/ono/test/__snapshots__/transformer.snapshot.JSX_with_expressions.snap deleted file mode 100644 index 8ced704..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.JSX_with_expressions.snap +++ /dev/null @@ -1,4 +0,0 @@ -const todos = items.map(item => (h("li", { key: item.id, className: item.completed ? 'completed' : '' }, - h("input", { type: "checkbox", checked: item.completed, onChange: () => toggle(item.id) }), - h("span", null, item.text), - h("button", { onClick: () => remove(item.id) }, "\u00D7")))); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.JSX_with_props.snap b/packages/ono/test/__snapshots__/transformer.snapshot.JSX_with_props.snap deleted file mode 100644 index 714132f..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.JSX_with_props.snap +++ /dev/null @@ -1 +0,0 @@ -const elem = h("div", { className: "test", id: "main" }, "Hello"); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.complex_component_with_imports.snap b/packages/ono/test/__snapshots__/transformer.snapshot.complex_component_with_imports.snap deleted file mode 100644 index aae58fc..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.complex_component_with_imports.snap +++ /dev/null @@ -1,15 +0,0 @@ -import { useState } from 'react'; -import { Header } from './components/Header'; -import { Footer } from './components/Footer'; -export default function App() { - const [count, setCount] = useState(0); - return (h("div", { className: "app" }, - h(Header, { title: "My App" }), - h("main", null, - h("h1", null, - "Counter: ", - count), - h("button", { onClick: () => setCount(count + 1) }, "Increment"), - h("button", { onClick: () => setCount(count - 1) }, "Decrement")), - h(Footer, null))); -} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.component_function_with_JSX.snap b/packages/ono/test/__snapshots__/transformer.snapshot.component_function_with_JSX.snap deleted file mode 100644 index cc18556..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.component_function_with_JSX.snap +++ /dev/null @@ -1,5 +0,0 @@ -function BlogPost({ title, children }) { - return (h("article", { className: "blog-post" }, - h("h1", null, title), - h("div", { className: "content" }, children))); -} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.conditional_rendering.snap b/packages/ono/test/__snapshots__/transformer.snapshot.conditional_rendering.snap deleted file mode 100644 index a99ef58..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.conditional_rendering.snap +++ /dev/null @@ -1,7 +0,0 @@ -const content = (h("div", null, - isLoggedIn ? (h("div", null, - h("h1", null, "Welcome back!"), - h("button", { onClick: logout }, "Logout"))) : (h("div", null, - h("h1", null, "Please log in"), - h("button", { onClick: login }, "Login"))), - showMessage && h("p", { className: "message" }, message))); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.nested_JSX.snap b/packages/ono/test/__snapshots__/transformer.snapshot.nested_JSX.snap deleted file mode 100644 index 27d90f5..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.nested_JSX.snap +++ /dev/null @@ -1,9 +0,0 @@ -const elem = (h("div", { className: "container" }, - h("h1", null, "Title"), - h("p", null, - "Paragraph with ", - h("strong", null, "bold"), - " text"), - h("ul", null, - h("li", null, "Item 1"), - h("li", null, "Item 2")))); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.self-closing_tags.snap b/packages/ono/test/__snapshots__/transformer.snapshot.self-closing_tags.snap deleted file mode 100644 index 220a315..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.self-closing_tags.snap +++ /dev/null @@ -1,6 +0,0 @@ -const form = (h("form", null, - h("input", { type: "text", name: "name", required: true }), - h("input", { type: "email", name: "email", required: true }), - h("br", null), - h("hr", null), - h("img", { src: "/logo.png", alt: "Logo" }))); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.simple_JSX_element.snap b/packages/ono/test/__snapshots__/transformer.snapshot.simple_JSX_element.snap deleted file mode 100644 index 640c052..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.simple_JSX_element.snap +++ /dev/null @@ -1 +0,0 @@ -const elem = h("div", null, "Hello"); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/transformer.snapshot.style_prop_object.snap b/packages/ono/test/__snapshots__/transformer.snapshot.style_prop_object.snap deleted file mode 100644 index dc74980..0000000 --- a/packages/ono/test/__snapshots__/transformer.snapshot.style_prop_object.snap +++ /dev/null @@ -1,6 +0,0 @@ -const styledDiv = (h("div", { style: { - backgroundColor: 'red', - fontSize: '16px', - padding: '10px', - border: '1px solid black' - } }, "Styled content")); \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.basic_utility_classes.snap b/packages/ono/test/__snapshots__/unocss.snapshot.basic_utility_classes.snap deleted file mode 100644 index acb8944..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.basic_utility_classes.snap +++ /dev/null @@ -1,10 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.m-2{margin:0.5rem;} -.bg-blue-100{--un-bg-opacity:1;background-color:rgb(219 234 254 / var(--un-bg-opacity));} -.p-4{padding:1rem;} -.text-2xl{font-size:1.5rem;line-height:2rem;} -.text-gray-600{--un-text-opacity:1;color:rgb(75 85 99 / var(--un-text-opacity));} -.text-red-500{--un-text-opacity:1;color:rgb(239 68 68 / var(--un-text-opacity));} -.font-bold{font-weight:700;} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.blog_layout.snap b/packages/ono/test/__snapshots__/unocss.snapshot.blog_layout.snap deleted file mode 100644 index c5f9b18..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.blog_layout.snap +++ /dev/null @@ -1,39 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.mx-2{margin-left:0.5rem;margin-right:0.5rem;} -.mx-auto{margin-left:auto;margin-right:auto;} -.mb-2{margin-bottom:0.5rem;} -.mb-4{margin-bottom:1rem;} -.mb-8{margin-bottom:2rem;} -.mt-12{margin-top:3rem;} -.mt-2{margin-top:0.5rem;} -.max-w-4xl{max-width:56rem;} -.flex{display:flex;} -.gap-2{gap:0.5rem;} -.space-y-8>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(2rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(2rem * var(--un-space-y-reverse));} -.border-t{border-top-width:1px;} -.border-gray-200{--un-border-opacity:1;border-color:rgb(229 231 235 / var(--un-border-opacity));} -.rounded{border-radius:0.25rem;} -.rounded-lg{border-radius:0.5rem;} -.bg-blue-100{--un-bg-opacity:1;background-color:rgb(219 234 254 / var(--un-bg-opacity));} -.bg-green-100{--un-bg-opacity:1;background-color:rgb(220 252 231 / var(--un-bg-opacity));} -.bg-white{--un-bg-opacity:1;background-color:rgb(255 255 255 / var(--un-bg-opacity));} -.p-6{padding:1.5rem;} -.px-2{padding-left:0.5rem;padding-right:0.5rem;} -.px-4{padding-left:1rem;padding-right:1rem;} -.py-1{padding-top:0.25rem;padding-bottom:0.25rem;} -.py-8{padding-top:2rem;padding-bottom:2rem;} -.pt-8{padding-top:2rem;} -.text-center{text-align:center;} -.text-2xl{font-size:1.5rem;line-height:2rem;} -.text-4xl{font-size:2.25rem;line-height:2.5rem;} -.text-sm{font-size:0.875rem;line-height:1.25rem;} -.text-xs{font-size:0.75rem;line-height:1rem;} -.text-blue-700{--un-text-opacity:1;color:rgb(29 78 216 / var(--un-text-opacity));} -.text-gray-600{--un-text-opacity:1;color:rgb(75 85 99 / var(--un-text-opacity));} -.text-gray-900{--un-text-opacity:1;color:rgb(17 24 39 / var(--un-text-opacity));} -.text-green-700{--un-text-opacity:1;color:rgb(21 128 61 / var(--un-text-opacity));} -.hover\:text-blue-600:hover{--un-text-opacity:1;color:rgb(37 99 235 / var(--un-text-opacity));} -.font-bold{font-weight:700;} -.shadow-md{--un-shadow:var(--un-shadow-inset) 0 4px 6px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 2px 4px -2px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.color_utilities.snap b/packages/ono/test/__snapshots__/unocss.snapshot.color_utilities.snap deleted file mode 100644 index a2947c3..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.color_utilities.snap +++ /dev/null @@ -1,14 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.bg-black{--un-bg-opacity:1;background-color:rgb(0 0 0 / var(--un-bg-opacity));} -.bg-blue-500{--un-bg-opacity:1;background-color:rgb(59 130 246 / var(--un-bg-opacity));} -.hover\:bg-blue-700:hover{--un-bg-opacity:1;background-color:rgb(29 78 216 / var(--un-bg-opacity));} -.bg-opacity-50{--un-bg-opacity:0.5;} -.from-purple-400{--un-gradient-from-position:0%;--un-gradient-from:rgb(192 132 252 / var(--un-from-opacity, 1)) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(192 132 252 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);} -.via-pink-500{--un-gradient-via-position:50%;--un-gradient-to:rgb(236 72 153 / 0);--un-gradient-stops:var(--un-gradient-from), rgb(236 72 153 / var(--un-via-opacity, 1)) var(--un-gradient-via-position), var(--un-gradient-to);} -.to-red-500{--un-gradient-to-position:100%;--un-gradient-to:rgb(239 68 68 / var(--un-to-opacity, 1)) var(--un-gradient-to-position);} -.bg-gradient-to-r{--un-gradient-shape:to right in oklch;--un-gradient:var(--un-gradient-shape), var(--un-gradient-stops);background-image:linear-gradient(var(--un-gradient));} -.text-green-200{--un-text-opacity:1;color:rgb(187 247 208 / var(--un-text-opacity));} -.text-white{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity));} -.text-yellow-300{--un-text-opacity:1;color:rgb(253 224 71 / var(--un-text-opacity));} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.form_elements.snap b/packages/ono/test/__snapshots__/unocss.snapshot.form_elements.snap deleted file mode 100644 index cdcd2ed..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.form_elements.snap +++ /dev/null @@ -1,26 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.block{display:block;} -.max-w-md{max-width:28rem;} -.w-full{width:100%;} -.space-y-4>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1rem * var(--un-space-y-reverse));} -.border{border-width:1px;} -.border-gray-300{--un-border-opacity:1;border-color:rgb(209 213 219 / var(--un-border-opacity));} -.rounded{border-radius:0.25rem;} -.rounded-md{border-radius:0.375rem;} -.bg-blue-500{--un-bg-opacity:1;background-color:rgb(59 130 246 / var(--un-bg-opacity));} -.hover\:bg-blue-700:hover{--un-bg-opacity:1;background-color:rgb(29 78 216 / var(--un-bg-opacity));} -.px-3{padding-left:0.75rem;padding-right:0.75rem;} -.px-4{padding-left:1rem;padding-right:1rem;} -.py-2{padding-top:0.5rem;padding-bottom:0.5rem;} -.text-sm{font-size:0.875rem;line-height:1.25rem;} -.text-gray-700{--un-text-opacity:1;color:rgb(55 65 81 / var(--un-text-opacity));} -.text-white{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity));} -.font-bold{font-weight:700;} -.font-medium{font-weight:500;} -.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px;} -.focus\:ring-2:focus{--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} -.focus\:ring-blue-500:focus{--un-ring-opacity:1;--un-ring-color:rgb(59 130 246 / var(--un-ring-opacity));} -.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms;} -.duration-200{transition-duration:200ms;} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.layout_classes.snap b/packages/ono/test/__snapshots__/unocss.snapshot.layout_classes.snap deleted file mode 100644 index 77a2345..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.layout_classes.snap +++ /dev/null @@ -1,30 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: shortcuts */ -.container{width:100%;} -@media (min-width: 640px){ -.container{max-width:640px;} -} -@media (min-width: 768px){ -.container{max-width:768px;} -} -@media (min-width: 1024px){ -.container{max-width:1024px;} -} -@media (min-width: 1280px){ -.container{max-width:1280px;} -} -@media (min-width: 1536px){ -.container{max-width:1536px;} -} -/* layer: default */ -.grid{display:grid;} -.col-span-2{grid-column:span 2/span 2;} -.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr));} -.mx-auto{margin-left:auto;margin-right:auto;} -.w-1\/2{width:50%;} -.flex{display:flex;} -.items-center{align-items:center;} -.justify-between{justify-content:space-between;} -.gap-4{gap:1rem;} -.px-4{padding-left:1rem;padding-right:1rem;} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.responsive_design.snap b/packages/ono/test/__snapshots__/unocss.snapshot.responsive_design.snap deleted file mode 100644 index e1d7f33..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.responsive_design.snap +++ /dev/null @@ -1,19 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.block{display:block;} -.w-full{width:100%;} -.text-sm{font-size:0.875rem;line-height:1.25rem;} -.text-xl{font-size:1.25rem;line-height:1.75rem;} -@media (min-width: 768px){ -.md\:hidden{display:none;} -.md\:w-1\/2{width:50%;} -.md\:text-2xl{font-size:1.5rem;line-height:2rem;} -.md\:text-base{font-size:1rem;line-height:1.5rem;} -} -@media (min-width: 1024px){ -.lg\:w-1\/3{width:33.3333333333%;} -.lg\:flex{display:flex;} -.lg\:text-3xl{font-size:1.875rem;line-height:2.25rem;} -.lg\:text-lg{font-size:1.125rem;line-height:1.75rem;} -} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.spacing_and_sizing.snap b/packages/ono/test/__snapshots__/unocss.snapshot.spacing_and_sizing.snap deleted file mode 100644 index 44792a5..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.spacing_and_sizing.snap +++ /dev/null @@ -1,18 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.m-1{margin:0.25rem;} -.m-4{margin:1rem;} -.h-32{height:8rem;} -.max-w-4xl{max-width:56rem;} -.min-h-screen{min-height:100vh;} -.w-64{width:16rem;} -.space-y-6>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1.5rem * var(--un-space-y-reverse));} -.border-2{border-width:2px;} -.border-gray-300{--un-border-opacity:1;border-color:rgb(209 213 219 / var(--un-border-opacity));} -.rounded-lg{border-radius:0.5rem;} -.p-2{padding:0.5rem;} -.p-8{padding:2rem;} -.px-3{padding-left:0.75rem;padding-right:0.75rem;} -.py-1{padding-top:0.25rem;padding-bottom:0.25rem;} -.text-xs{font-size:0.75rem;line-height:1rem;} \ No newline at end of file diff --git a/packages/ono/test/__snapshots__/unocss.snapshot.typography.snap b/packages/ono/test/__snapshots__/unocss.snapshot.typography.snap deleted file mode 100644 index 84976bd..0000000 --- a/packages/ono/test/__snapshots__/unocss.snapshot.typography.snap +++ /dev/null @@ -1,21 +0,0 @@ -/* layer: preflights */ -*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* layer: default */ -.max-w-none{max-width:none;} -.border-l-4{border-left-width:4px;} -.border-gray-300{--un-border-opacity:1;border-color:rgb(209 213 219 / var(--un-border-opacity));} -.rounded{border-radius:0.25rem;} -.bg-gray-100{--un-bg-opacity:1;background-color:rgb(243 244 246 / var(--un-bg-opacity));} -.px-1{padding-left:0.25rem;padding-right:0.25rem;} -.pl-4{padding-left:1rem;} -.text-2xl{font-size:1.5rem;line-height:2rem;} -.text-4xl{font-size:2.25rem;line-height:2.5rem;} -.text-base{font-size:1rem;line-height:1.5rem;} -.text-sm{font-size:0.875rem;line-height:1.25rem;} -.font-bold{font-weight:700;} -.font-normal{font-weight:400;} -.font-semibold{font-weight:600;} -.leading-relaxed{line-height:1.625;} -.leading-tight{line-height:1.25;} -.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;} -.italic{font-style:italic;} \ No newline at end of file diff --git a/packages/ono/test/builder.test.js b/packages/ono/test/builder.test.js new file mode 100644 index 0000000..b40ccdc --- /dev/null +++ b/packages/ono/test/builder.test.js @@ -0,0 +1,107 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import path from "node:path"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { buildFile, buildFiles, importJSXModule } from "../src/builder.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, "fixtures"); +const outDir = path.join(__dirname, "builder-test-tmp"); + +beforeEach(async () => { + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(outDir, { recursive: true, force: true }); +}); + +test("importJSXModule - returns the module namespace", async () => { + const module = await importJSXModule(path.join(fixturesDir, "App.jsx")); + assert.strictEqual(typeof module.default, "function"); +}); + +test("importJSXModule - rejects on non-existent file", async () => { + await assert.rejects( + () => importJSXModule(path.join(fixturesDir, "NonExistent.jsx")), + /Cannot read file/i + ); +}); + +test("importJSXModule - cleans up its temp directory", async () => { + await importJSXModule(path.join(fixturesDir, "App.jsx")); + const tempDir = path.join(process.cwd(), ".ono"); + if (existsSync(tempDir)) { + const { readdir } = await import("node:fs/promises"); + const leftovers = await readdir(tempDir); + assert.deepStrictEqual(leftovers, []); + } +}); + +test("buildFile - single file with imports renders all components", async () => { + const { html, outputPath } = await buildFile(path.join(fixturesDir, "App.jsx"), { + outputDir: outDir, + silent: true, + }); + + assert.ok(html.includes("

App

")); + assert.ok(html.includes("My Card")); + assert.ok(html.includes("")); + assert.strictEqual(outputPath, path.join(outDir, "App.html")); + assert.ok(existsSync(outputPath)); +}); + +test("buildFile - JSX fragments render without a wrapper", async () => { + const pageDir = path.join(outDir, "src"); + await mkdir(pageDir, { recursive: true }); + const page = path.join(pageDir, "fragment.jsx"); + await writeFile( + page, + `export default function Page() { + return ( + <> +

Title

+

Body

+ + ); +}` + ); + + const { html } = await buildFile(page, { outputDir: outDir, silent: true }); + assert.strictEqual(html, "

Title

Body

"); +}); + +test("buildFile - rejects when there is no default export", async () => { + const page = path.join(outDir, "no-default.jsx"); + await writeFile(page, `export const meta = { title: "x" };`); + + await assert.rejects( + () => buildFile(page, { outputDir: outDir, silent: true }), + /No default export/i + ); +}); + +test("buildFiles - preserves nested directory structure", async () => { + const pagesDir = path.join(outDir, "pages"); + await mkdir(path.join(pagesDir, "blog"), { recursive: true }); + await writeFile( + path.join(pagesDir, "index.jsx"), + `export default () =>

Home

;` + ); + await writeFile( + path.join(pagesDir, "blog", "first-post.jsx"), + `export default () =>
Post
;` + ); + + const results = await buildFiles(pagesDir, { outputDir: outDir, silent: true }); + const outputs = results.map((r) => r.outputPath).sort(); + + assert.deepStrictEqual(outputs, [ + path.join(outDir, "blog", "first-post.html"), + path.join(outDir, "index.html"), + ]); + assert.ok(existsSync(path.join(outDir, "blog", "first-post.html"))); +}); diff --git a/packages/ono/test/bundler.test.js b/packages/ono/test/bundler.test.js index fba12f8..f03645e 100644 --- a/packages/ono/test/bundler.test.js +++ b/packages/ono/test/bundler.test.js @@ -1,78 +1,208 @@ import { test } from "node:test"; import assert from "node:assert"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { bundle } from "../src/bundler.js"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixturesDir = path.join(__dirname, "fixtures"); +/** Bundle a virtual file system (browser-style ids) */ +function bundleFiles(files, entry, options = {}) { + return bundle({ + entry, + resolve: (specifier, fromId) => { + const fromParts = fromId.split("/").slice(0, -1); + for (const part of specifier.split("/")) { + if (!part || part === ".") continue; + if (part === "..") fromParts.pop(); + else fromParts.push(part); + } + return fromParts.join("/"); + }, + load: (id) => { + if (!(id in files)) throw new Error(`Cannot read file: ${id}`); + return files[id]; + }, + onExternal: options.onExternal ?? "error", + exposeEntryFunctions: options.exposeEntryFunctions ?? false, + }); +} + +/** Evaluate a bundle (no externals) and return the entry module object */ +async function evaluate(files, entry, options) { + const { code, ...rest } = await bundleFiles(files, entry, options); + const entryModule = new Function(`${code}\nreturn __ono_entry;`)(); + return { entryModule, code, ...rest }; +} + +test("bundle - default and named imports across modules", async () => { + const { entryModule } = await evaluate( + { + "lib.js": `export default 10;\nexport const twice = (n) => n * 2;`, + "index.js": `import ten, { twice } from "./lib.js";\nexport default twice(ten);`, + }, + "index.js", + ); -test("bundle - single file no imports", async () => { - const entryFile = path.join(fixturesDir, "NoImports.jsx"); - const result = await bundle(entryFile); + assert.strictEqual(entryModule.default, 20); +}); - // Should contain the h function - assert.ok(result.includes("h(")); - assert.ok(result.includes("Hello")); +test("bundle - namespace import and aliases", async () => { + const { entryModule } = await evaluate( + { + "lib.js": `export const a = 1;\nexport const b = 2;`, + "index.js": `import * as lib from "./lib.js";\nimport { a as first } from "./lib.js";\nexport const sum = lib.a + lib.b + first;`, + }, + "index.js", + ); - // Should be valid JavaScript (no JSX) - assert.ok(!result.includes("
")); + assert.strictEqual(entryModule.sum, 4); }); -test("bundle - with imports", async () => { - const entryFile = path.join(fixturesDir, "Card.jsx"); - const result = await bundle(entryFile); +test("bundle - modules are evaluated once and in dependency order", async () => { + const { entryModule } = await evaluate( + { + "log.js": `export const order = [];`, + "a.js": `import { order } from "./log.js";\norder.push("a");\nexport const a = true;`, + "b.js": `import { order } from "./log.js";\nimport { a } from "./a.js";\norder.push("b");\nexport const b = a;`, + "index.js": `import { order } from "./log.js";\nimport { b } from "./b.js";\nimport { a } from "./a.js";\nexport const result = order.join(",");`, + }, + "index.js", + ); - // Should contain both Card and Button code - assert.ok(result.includes("Card")); - assert.ok(result.includes("Button")); + assert.strictEqual(entryModule.result, "a,b"); +}); - // Should have h function calls - assert.ok(result.includes("h(")); +test("bundle - nested directory resolution", async () => { + const { entryModule } = await evaluate( + { + "components/Button.js": `export default () => "button";`, + "components/Card.js": `import Button from "./Button.js";\nexport default () => "card+" + Button();`, + "index.js": `import Card from "./components/Card.js";\nexport default Card();`, + }, + "index.js", + ); - // Should not have import statements in output - assert.ok(!result.includes('import Button')); + assert.strictEqual(entryModule.default, "card+button"); }); -test("bundle - nested imports", async () => { - const entryFile = path.join(fixturesDir, "App.jsx"); - const result = await bundle(entryFile); +test("bundle - re-exports (barrel pattern)", async () => { + const { entryModule, entryExports } = await evaluate( + { + "posts/hello.js": `export const meta = { title: "Hello" };\nexport default () => "post";`, + "blog.js": `import hello, { meta as helloMeta } from "./posts/hello.js";\nexport { hello, helloMeta };`, + "index.js": `export { hello, helloMeta } from "./blog.js";`, + }, + "index.js", + ); - // Should contain all components - assert.ok(result.includes("App")); - assert.ok(result.includes("Card")); - assert.ok(result.includes("Button")); + assert.strictEqual(entryModule.hello(), "post"); + assert.strictEqual(entryModule.helloMeta.title, "Hello"); + assert.deepStrictEqual(entryExports, ["hello", "helloMeta"]); +}); - // Should be transformed (no JSX) - assert.ok(result.includes("h(")); +test("bundle - export star from", async () => { + const { entryModule } = await evaluate( + { + "lib.js": `export const x = 1;\nexport const y = 2;\nexport default "ignored";`, + "index.js": `export * from "./lib.js";`, + }, + "index.js", + ); + + assert.strictEqual(entryModule.x, 1); + assert.strictEqual(entryModule.y, 2); + assert.strictEqual(entryModule.default, undefined); }); -test("bundle - correct module order", async () => { - const entryFile = path.join(fixturesDir, "App.jsx"); - const result = await bundle(entryFile); +test("bundle - import cycles resolve like CommonJS", async () => { + const { entryModule } = await evaluate( + { + "a.js": `import { bName } from "./b.js";\nexport function aName() { return "a->" + bName(); }`, + "b.js": `import { aName } from "./a.js";\nexport function bName() { return "b"; }\nexport const viaA = () => aName();`, + "index.js": `import { aName } from "./a.js";\nimport { viaA } from "./b.js";\nexport const result = viaA();`, + }, + "index.js", + ); + + assert.strictEqual(entryModule.result, "a->b"); +}); + +test("bundle - side-effect import evaluates the module", async () => { + const { entryModule } = await evaluate( + { + "setup.js": `globalThis.__ono_test_flag = "ready";`, + "index.js": `import "./setup.js";\nexport const flag = globalThis.__ono_test_flag;`, + }, + "index.js", + ); + + assert.strictEqual(entryModule.flag, "ready"); + delete globalThis.__ono_test_flag; +}); - // Button should come before Card (Card depends on Button) - const buttonIndex = result.indexOf("function Button"); - const cardIndex = result.indexOf("function Card"); - const appIndex = result.indexOf("function App"); +test("bundle - default export declaration keeps its binding usable", async () => { + const { entryModule } = await evaluate( + { + "index.js": `export default function App() { return helper(); }\nfunction helper() { return App.name; }`, + }, + "index.js", + ); - assert.ok(buttonIndex < cardIndex, "Button should come before Card"); - assert.ok(cardIndex < appIndex, "Card should come before App"); + assert.strictEqual(entryModule.default(), "App"); }); -test("bundle - export default at the end", async () => { - const entryFile = path.join(fixturesDir, "App.jsx"); - const result = await bundle(entryFile); +test("bundle - exposeEntryFunctions exports unexported entry functions", async () => { + const { entryModule } = await evaluate( + { + "index.js": `function App() { return "rendered"; }\nApp();`, + }, + "index.js", + { exposeEntryFunctions: true }, + ); - // Should have export default at the end - assert.ok(result.includes("export default App")); + assert.strictEqual(entryModule.App(), "rendered"); }); -test("bundle - handles non-existent file", async () => { - const entryFile = path.join(fixturesDir, "NonExistent.jsx"); +test("bundle - onExternal error rejects package imports", async () => { + await assert.rejects( + () => + bundleFiles( + { "index.js": `import React from "react";` }, + "index.js", + { onExternal: "error" }, + ), + /Cannot bundle package import "react"/, + ); +}); +test("bundle - onExternal hoist moves package imports to the bundle top", async () => { + const { code, externalBindings } = await bundleFiles( + { + "index.js": `import { readFile } from "node:fs/promises";\nexport default readFile;`, + }, + "index.js", + { onExternal: "hoist" }, + ); + + assert.ok(code.startsWith(`import { readFile } from "node:fs/promises";`)); + assert.ok(externalBindings.has("readFile")); +}); + +test("bundle - missing module rejects with the loader's error", async () => { await assert.rejects( - async () => await bundle(entryFile), - /Cannot read file/i + () => + bundleFiles( + { "index.js": `import missing from "./missing.js";` }, + "index.js", + ), + /Cannot read file: missing.js/, ); }); + +test("bundle - entryExports lists the entry's export names", async () => { + const { entryExports } = await bundleFiles( + { + "index.js": `export default 1;\nexport const meta = {};\nexport function helper() {}`, + }, + "index.js", + ); + + assert.deepStrictEqual(entryExports, ["default", "meta", "helper"]); +}); diff --git a/packages/ono/test/cli.snapshot.test.js b/packages/ono/test/cli.snapshot.test.js deleted file mode 100644 index fe62bab..0000000 --- a/packages/ono/test/cli.snapshot.test.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Snapshot tests for CLI commands - * These tests capture the actual CLI output and behavior - */ -import { test, beforeEach, afterEach } from "node:test"; -import { spawn } from "node:child_process"; -import { mkdir, rm } from "node:fs/promises"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { matchSnapshot } from "./snapshot-utils.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = resolve(__dirname, "../src/cli.js"); -const TEST_DIR = resolve(__dirname, "../test-tmp"); -const TEST_FILE = "cli.snapshot.test.js"; - -/** - * Helper to run CLI command and capture output - */ -function runCLI(args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn("node", [CLI_PATH, ...args], { - cwd: options.cwd || TEST_DIR, - env: { ...process.env, ...options.env }, - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (data) => { - stdout += data.toString(); - }); - - child.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - resolve({ code, stdout, stderr }); - }); - - child.on("error", (error) => { - reject(error); - }); - - // For dev command, kill after timeout - if (options.timeout) { - setTimeout(() => { - child.kill("SIGTERM"); - }, options.timeout); - } - }); -} - -beforeEach(async () => { - await rm(TEST_DIR, { recursive: true, force: true }); - await mkdir(TEST_DIR, { recursive: true }); -}); - -afterEach(async () => { - await rm(TEST_DIR, { recursive: true, force: true }); -}); - -test("snapshot - help message", async () => { - const { code, stdout, stderr } = await runCLI(["--help"]); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${stderr}`; - - await matchSnapshot(output, TEST_FILE, "help message"); -}); - -test("snapshot - help message short flag", async () => { - const { code, stdout, stderr } = await runCLI(["-h"]); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${stderr}`; - - await matchSnapshot(output, TEST_FILE, "help message short flag"); -}); - -test("snapshot - no command provided", async () => { - const { code, stdout, stderr } = await runCLI([]); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${stderr}`; - - await matchSnapshot(output, TEST_FILE, "no command provided"); -}); - -test("snapshot - unknown command", async () => { - const { code, stdout, stderr } = await runCLI(["unknown-command"]); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${stderr}`; - - await matchSnapshot(output, TEST_FILE, "unknown command"); -}); - -test("snapshot - version flag (not supported)", async () => { - const { code, stdout, stderr } = await runCLI(["--version"]); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${stderr}`; - - await matchSnapshot(output, TEST_FILE, "version flag not supported"); -}); - -test("snapshot - build command help", async () => { - const { code, stdout, stderr } = await runCLI(["build", "--help"]); - - // Normalize file paths to be environment-independent - const normalizedStderr = stderr - .replace(/\/[^\s]+\/test-tmp\//g, "/PATH/test-tmp/"); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${normalizedStderr}`; - - await matchSnapshot(output, TEST_FILE, "build command help"); -}); - -test("snapshot - build non-existent file", async () => { - const { code, stdout, stderr } = await runCLI(["build", "non-existent.jsx"]); - - // Normalize file paths to be environment-independent - const normalizedStderr = stderr - .replace(/\/[^\s]+\/test-tmp\//g, "/PATH/test-tmp/"); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${normalizedStderr}`; - - await matchSnapshot(output, TEST_FILE, "build non-existent file"); -}); - -test("snapshot - build non-existent directory", async () => { - const { code, stdout, stderr } = await runCLI(["build", "non-existent-dir"]); - - // Normalize file paths to be environment-independent - const normalizedStderr = stderr - .replace(/\/[^\s]+\/test-tmp\//g, "/PATH/test-tmp/"); - - const output = `Exit Code: ${code} -STDOUT: -${stdout} -STDERR: -${normalizedStderr}`; - - await matchSnapshot(output, TEST_FILE, "build non-existent directory"); -}); - -test("snapshot - serve command", async () => { - // Create a simple dist directory with an HTML file - await mkdir(resolve(TEST_DIR, "dist"), { recursive: true }); - - // Use a random high port to avoid conflicts - const port = 9000 + Math.floor(Math.random() * 1000); - const { code, stdout, stderr } = await runCLI(["serve", "--port", port.toString()], { timeout: 1000 }); - - // Remove any dynamic content like timestamps, process IDs, and port numbers - const cleanedStdout = stdout - .replace(/Server running at http:\/\/localhost:\d+/g, "Server running at http://localhost:PORT") - .replace(/PID: \d+/g, "PID: XXXXX") - .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, "TIMESTAMP") - .replace(/port \d+/g, "port PORT"); - - const output = `Exit Code: ${code} -STDOUT: -${cleanedStdout} -STDERR: -${stderr}`; - - await matchSnapshot(output, TEST_FILE, "serve command"); -}); - -test("snapshot - watch command on empty directory", async () => { - const { code, stdout, stderr } = await runCLI(["watch"], { timeout: 1000 }); - - // Clean up any dynamic content - const cleanedStdout = stdout - .replace(/Found \d+ page\(s\)/g, "Found N page(s)") - .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, "TIMESTAMP") - .replace(/Watching.*for changes/g, "Watching ... for changes") - .replace(/port \d+/g, "port PORT") - .replace(/Server running at http:\/\/localhost:\d+/g, "Server running at http://localhost:PORT"); - - // Normalize file paths to be environment-independent - const normalizedStderr = stderr - .replace(/\/[^\s]+\/test-tmp\//g, "/PATH/test-tmp/"); - - const output = `Exit Code: ${code} -STDOUT: -${cleanedStdout} -STDERR: -${normalizedStderr}`; - - await matchSnapshot(output, TEST_FILE, "watch command on empty directory"); -}); \ No newline at end of file diff --git a/packages/ono/test/cli.test.js b/packages/ono/test/cli.test.js new file mode 100644 index 0000000..bd3ae8e --- /dev/null +++ b/packages/ono/test/cli.test.js @@ -0,0 +1,100 @@ +/** + * Snapshot tests for CLI commands + * These tests capture the actual CLI output and behavior. + * Update snapshots with: npm run test:update + */ +import { test, beforeEach, afterEach } from "node:test"; +import { spawn } from "node:child_process"; +import { mkdir, rm } from "node:fs/promises"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = resolve(__dirname, "../src/cli.js"); +const TEST_DIR = resolve(__dirname, "../test-tmp"); + +// Keep CLI output readable in the snapshot file +const raw = { serializers: [(value) => value] }; + +/** + * Helper to run CLI command and capture output + */ +function runCLI(args, options = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn("node", [CLI_PATH, ...args], { + cwd: options.cwd || TEST_DIR, + env: { ...process.env, ...options.env }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + resolvePromise({ code, stdout, stderr }); + }); + + child.on("error", rejectPromise); + }); +} + +/** Normalize file paths so snapshots are environment-independent */ +function normalizePaths(output) { + return output.replace(/\/[^\s'"]+\/test-tmp\//g, "/PATH/test-tmp/"); +} + +function formatResult({ code, stdout, stderr }) { + return `Exit Code: ${code} +STDOUT: +${stdout} +STDERR: +${normalizePaths(stderr)}`; +} + +beforeEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }); + await mkdir(TEST_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }); +}); + +test("cli - help message", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["--help"])), raw); +}); + +test("cli - help message short flag", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["-h"])), raw); +}); + +test("cli - no command provided", async (t) => { + t.assert.snapshot(formatResult(await runCLI([])), raw); +}); + +test("cli - unknown command", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["unknown-command"])), raw); +}); + +test("cli - version flag (not supported)", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["--version"])), raw); +}); + +test("cli - build command help", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["build", "--help"])), raw); +}); + +test("cli - build non-existent file", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["build", "non-existent.jsx"])), raw); +}); + +test("cli - build non-existent directory", async (t) => { + t.assert.snapshot(formatResult(await runCLI(["build", "non-existent-dir"])), raw); +}); diff --git a/packages/ono/test/cli.test.js.snapshot b/packages/ono/test/cli.test.js.snapshot new file mode 100644 index 0000000..4288b85 --- /dev/null +++ b/packages/ono/test/cli.test.js.snapshot @@ -0,0 +1,158 @@ +exports[`cli - build command help 1`] = ` +Exit Code: 0 +STDOUT: + +Usage: ono build [input] [options] + +Build JSX files to static HTML + +Arguments: + input File or directory to build (default: pages) + +Options: + --output Output directory (default: dist) + --help Show this help message + +Examples: + ono build Build all pages in pages/ directory + ono build pages/index.jsx Build a single file + ono build --output build Build to build/ directory + + +STDERR: + +`; + +exports[`cli - build non-existent directory 1`] = ` +Exit Code: 1 +STDOUT: + +STDERR: +Error: ENOENT: no such file or directory, stat '/PATH/test-tmp/non-existent-dir' + +`; + +exports[`cli - build non-existent file 1`] = ` +Exit Code: 1 +STDOUT: + +STDERR: +Error: ENOENT: no such file or directory, stat '/PATH/test-tmp/non-existent.jsx' + +`; + +exports[`cli - help message 1`] = ` +Exit Code: 0 +STDOUT: + +Ono - Minimalist SSG Framework + +Usage: + ono [options] + +Commands: + build [input] Build JSX files to static HTML + input: file or directory (default: pages) + + dev [input] Build, watch, and serve with live reload + input: file or directory (default: pages) + +Options: + --output Output directory (default: dist) + --port Server port (default: 3000) + --help Show this help message + +Examples: + ono build Build all pages in pages/ directory + ono build pages/index.jsx Build a single file + ono dev Start dev server with live reload + ono dev --port 8080 Start dev server on port 8080 + + +STDERR: + +`; + +exports[`cli - help message short flag 1`] = ` +Exit Code: 0 +STDOUT: + +Ono - Minimalist SSG Framework + +Usage: + ono [options] + +Commands: + build [input] Build JSX files to static HTML + input: file or directory (default: pages) + + dev [input] Build, watch, and serve with live reload + input: file or directory (default: pages) + +Options: + --output Output directory (default: dist) + --port Server port (default: 3000) + --help Show this help message + +Examples: + ono build Build all pages in pages/ directory + ono build pages/index.jsx Build a single file + ono dev Start dev server with live reload + ono dev --port 8080 Start dev server on port 8080 + + +STDERR: + +`; + +exports[`cli - no command provided 1`] = ` +Exit Code: 0 +STDOUT: + +Ono - Minimalist SSG Framework + +Usage: + ono [options] + +Commands: + build [input] Build JSX files to static HTML + input: file or directory (default: pages) + + dev [input] Build, watch, and serve with live reload + input: file or directory (default: pages) + +Options: + --output Output directory (default: dist) + --port Server port (default: 3000) + --help Show this help message + +Examples: + ono build Build all pages in pages/ directory + ono build pages/index.jsx Build a single file + ono dev Start dev server with live reload + ono dev --port 8080 Start dev server on port 8080 + + +STDERR: + +`; + +exports[`cli - unknown command 1`] = ` +Exit Code: 1 +STDOUT: + +STDERR: +Unknown command: unknown-command +Run "ono --help" for usage information + +`; + +exports[`cli - version flag (not supported) 1`] = ` +Exit Code: 1 +STDOUT: + +STDERR: +Unknown command: --version +Run "ono --help" for usage information + +`; diff --git a/packages/ono/test/compiler.test.js b/packages/ono/test/compiler.test.js new file mode 100644 index 0000000..8e3490e --- /dev/null +++ b/packages/ono/test/compiler.test.js @@ -0,0 +1,87 @@ +/** + * Tests for the browser compiler (REPL path). + * browser/compiler.js has no Node dependencies, so it can be tested here. + */ +import { test } from "node:test"; +import assert from "node:assert"; +import { compileProject } from "../src/browser/compiler.js"; + +test("compileProject - default export page", async () => { + const files = { + "index.jsx": `export default function App() { + return
Hello REPL
; +}`, + }; + + const { html } = await compileProject(files, "index.jsx", { enableUno: false }); + assert.strictEqual(html, `
Hello REPL
`); +}); + +test("compileProject - multi-file project with imports", async () => { + const files = { + "components/Card.jsx": `export function Card(props) { + return
{props.children}
; +}`, + "index.jsx": `import { Card } from './components/Card.jsx'; + +export default function App() { + return

content

; +}`, + }; + + const { html } = await compileProject(files, "index.jsx", { enableUno: false }); + assert.strictEqual(html, `

content

`); +}); + +test("compileProject - REPL style entry without exports", async () => { + const files = { + "index.jsx": `function App() { + return ( + <> +

Title

+

Fragment in the REPL

+ + ); +} + +App()`, + }; + + const { html } = await compileProject(files, "index.jsx", { enableUno: false }); + assert.strictEqual(html, "

Title

Fragment in the REPL

"); +}); + +test("compileProject - generates UnoCSS when enabled", async () => { + const files = { + "index.jsx": `export default function App() { + return
styled
; +}`, + }; + + const { css } = await compileProject(files, "index.jsx"); + assert.ok(css.includes(".text-red-500")); +}); + +test("compileProject - missing import produces a clear error", async () => { + const files = { + "index.jsx": `import Missing from './missing.jsx'; +export default () => ;`, + }; + + await assert.rejects( + () => compileProject(files, "index.jsx", { enableUno: false }), + /Cannot find module '.\/missing.jsx'/, + ); +}); + +test("compileProject - package imports are rejected in the browser", async () => { + const files = { + "index.jsx": `import React from 'react'; +export default () =>
;`, + }; + + await assert.rejects( + () => compileProject(files, "index.jsx", { enableUno: false }), + /Cannot bundle package import "react"/, + ); +}); diff --git a/packages/ono/test/parser.test.js b/packages/ono/test/parser.test.js new file mode 100644 index 0000000..cc3a4f4 --- /dev/null +++ b/packages/ono/test/parser.test.js @@ -0,0 +1,192 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { parseModule, isIdentifierName } from "../src/parser.js"; + +// --- imports --------------------------------------------------------------- + +test("parseModule - default import", () => { + const { imports } = parseModule(`import Button from "./Button.jsx";`); + + assert.strictEqual(imports.length, 1); + assert.strictEqual(imports[0].specifier, "./Button.jsx"); + assert.strictEqual(imports[0].defaultBinding, "Button"); +}); + +test("parseModule - named imports with aliases", () => { + const { imports } = parseModule(`import { foo, bar as baz, default as d } from "./utils.js";`); + + assert.deepStrictEqual(imports[0].named, [ + { imported: "foo", local: "foo" }, + { imported: "bar", local: "baz" }, + { imported: "default", local: "d" }, + ]); +}); + +test("parseModule - namespace import", () => { + const { imports } = parseModule(`import * as utils from "./utils.js";`); + + assert.strictEqual(imports[0].namespace, "utils"); + assert.strictEqual(imports[0].specifier, "./utils.js"); +}); + +test("parseModule - default plus named", () => { + const { imports } = parseModule(`import App, { helper } from "./app.js";`); + + assert.strictEqual(imports[0].defaultBinding, "App"); + assert.deepStrictEqual(imports[0].named, [{ imported: "helper", local: "helper" }]); +}); + +test("parseModule - side-effect import", () => { + const { imports } = parseModule(`import "./setup.js";`); + + assert.strictEqual(imports[0].sideEffect, true); + assert.strictEqual(imports[0].specifier, "./setup.js"); +}); + +test("parseModule - multiline import", () => { + const source = `import { + one, + two as three, +} from "./numbers.js";`; + const { imports } = parseModule(source); + + assert.deepStrictEqual(imports[0].named, [ + { imported: "one", local: "one" }, + { imported: "two", local: "three" }, + ]); + assert.strictEqual(source.slice(imports[0].start, imports[0].end), source); +}); + +test("parseModule - import inside a string is ignored", () => { + const { imports } = parseModule(`const s = 'import fake from "./fake.js"';`); + + assert.strictEqual(imports.length, 0); +}); + +test("parseModule - import inside a template literal is ignored", () => { + const source = "const s = `import fake from \"./fake.js\" ${'import x from \"./y\"'}`;"; + const { imports } = parseModule(source); + + assert.strictEqual(imports.length, 0); +}); + +test("parseModule - import inside comments is ignored", () => { + const source = `// import a from "./a.js" +/* import b from "./b.js" */ +import real from "./real.js";`; + const { imports } = parseModule(source); + + assert.strictEqual(imports.length, 1); + assert.strictEqual(imports[0].specifier, "./real.js"); +}); + +test("parseModule - dynamic import and import.meta are ignored", () => { + const source = `const mod = import("./dynamic.js"); +const url = import.meta.url;`; + const { imports } = parseModule(source); + + assert.strictEqual(imports.length, 0); +}); + +test("parseModule - regex literal containing quotes does not break scanning", () => { + const source = `const re = /['"{]/g; +import real from "./real.js";`; + const { imports } = parseModule(source); + + assert.strictEqual(imports.length, 1); +}); + +// --- exports --------------------------------------------------------------- + +test("parseModule - export default function declaration", () => { + const source = `export default function App() { return h("div"); }`; + const { exports } = parseModule(source); + + assert.strictEqual(exports[0].type, "exportDefaultDeclaration"); + assert.strictEqual(exports[0].name, "App"); +}); + +test("parseModule - export default expression", () => { + const { exports } = parseModule(`export default App;`); + + assert.strictEqual(exports[0].type, "exportDefaultExpression"); +}); + +test("parseModule - export default anonymous function", () => { + const { exports } = parseModule(`export default function () { return 1; }`); + + assert.strictEqual(exports[0].type, "exportDefaultExpression"); +}); + +test("parseModule - export function and class declarations", () => { + const source = `export function foo() {} +export async function bar() {} +export class Baz {}`; + const { exports } = parseModule(source); + + assert.deepStrictEqual( + exports.map((e) => e.names[0]), + ["foo", "bar", "Baz"], + ); + assert.ok(exports.every((e) => e.type === "exportDeclaration")); +}); + +test("parseModule - export const with multiple declarators", () => { + const source = `export const a = fn(1, { b: 2 }), c = [3, 4];`; + const { exports } = parseModule(source); + + assert.deepStrictEqual(exports[0].names, ["a", "c"]); +}); + +test("parseModule - export named list", () => { + const { exports } = parseModule(`const a = 1; export { a, a as b };`); + + assert.strictEqual(exports[0].type, "exportNamed"); + assert.deepStrictEqual(exports[0].named, [ + { local: "a", exported: "a" }, + { local: "a", exported: "b" }, + ]); +}); + +test("parseModule - re-export from (barrel files)", () => { + const source = `export { default as helloWorld, meta as helloWorldMeta } from './blog/hello-world.tsx';`; + const { exports } = parseModule(source); + + assert.strictEqual(exports[0].type, "exportNamedFrom"); + assert.strictEqual(exports[0].specifier, "./blog/hello-world.tsx"); + assert.deepStrictEqual(exports[0].named, [ + { local: "default", exported: "helloWorld" }, + { local: "meta", exported: "helloWorldMeta" }, + ]); +}); + +test("parseModule - export star from", () => { + const { exports } = parseModule(`export * from "./all.js";`); + + assert.strictEqual(exports[0].type, "exportStarFrom"); + assert.strictEqual(exports[0].specifier, "./all.js"); +}); + +test("parseModule - destructuring export is rejected with a clear error", () => { + assert.throws(() => parseModule(`export const { a } = obj;`), /Destructuring/); +}); + +// --- top-level functions ----------------------------------------------------- + +test("parseModule - collects top-level function declarations", () => { + const source = `function App() { return 1; } +async function load() {} +function helper() { function inner() {} } +const expr = function named() {}; +App();`; + const { topLevelFunctions } = parseModule(source); + + assert.deepStrictEqual(topLevelFunctions, ["App", "load", "helper"]); +}); + +test("isIdentifierName", () => { + assert.ok(isIdentifierName("valid_$Name1")); + assert.ok(!isIdentifierName("1bad")); + assert.ok(!isIdentifierName("has-dash")); + assert.ok(!isIdentifierName("")); +}); diff --git a/packages/ono/test/renderer.snapshot.test.js b/packages/ono/test/renderer.snapshot.test.js deleted file mode 100644 index c27a513..0000000 --- a/packages/ono/test/renderer.snapshot.test.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Snapshot tests for HTML renderer - */ -import { test } from "node:test"; -import { createElement } from "../src/jsx-runtime.js"; -import { renderToString } from "../src/renderer.js"; -import { matchHTMLSnapshot } from "./snapshot-utils.js"; - -const TEST_FILE = "renderer.snapshot.test.js"; - -test("snapshot - simple element", async () => { - const vnode = createElement("div"); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "simple element"); -}); - -test("snapshot - element with text", async () => { - const vnode = createElement("div", null, "Hello World"); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "element with text"); -}); - -test("snapshot - element with attributes", async () => { - const vnode = createElement("div", { - className: "container", - id: "main", - "data-testid": "test-element" - }); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "element with attributes"); -}); - -test("snapshot - nested elements", async () => { - const vnode = createElement("div", { className: "container" }, - createElement("h1", null, "Title"), - createElement("p", null, "This is a paragraph with ", - createElement("strong", null, "bold text"), - " and regular text." - ), - createElement("ul", null, - createElement("li", null, "Item 1"), - createElement("li", null, "Item 2"), - createElement("li", null, "Item 3") - ) - ); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "nested elements"); -}); - -test("snapshot - component function", async () => { - function BlogPost({ title, author, children }) { - return createElement("article", { className: "blog-post" }, - createElement("header", null, - createElement("h1", null, title), - createElement("p", { className: "author" }, "By ", author) - ), - createElement("div", { className: "content" }, children) - ); - } - - const vnode = BlogPost({ - title: "My First Post", - author: "John Doe", - children: [ - createElement("p", null, "This is the first paragraph."), - createElement("p", null, "This is the second paragraph.") - ] - }); - - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "component function"); -}); - -test("snapshot - boolean attributes", async () => { - const vnode = createElement("input", { - type: "checkbox", - checked: true, - disabled: false, - required: true - }); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "boolean attributes"); -}); - -test("snapshot - style object", async () => { - const vnode = createElement("div", { - style: { - backgroundColor: "red", - fontSize: "16px", - margin: "10px 20px" - } - }, "Styled content"); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "style object"); -}); - -test("snapshot - complex form", async () => { - const vnode = createElement("form", { className: "contact-form", method: "post" }, - createElement("div", { className: "form-group" }, - createElement("label", { htmlFor: "name" }, "Name:"), - createElement("input", { - type: "text", - id: "name", - name: "name", - required: true, - placeholder: "Enter your name" - }) - ), - createElement("div", { className: "form-group" }, - createElement("label", { htmlFor: "email" }, "Email:"), - createElement("input", { - type: "email", - id: "email", - name: "email", - required: true, - placeholder: "Enter your email" - }) - ), - createElement("div", { className: "form-group" }, - createElement("label", { htmlFor: "message" }, "Message:"), - createElement("textarea", { - id: "message", - name: "message", - rows: 4, - placeholder: "Enter your message" - }) - ), - createElement("button", { type: "submit", className: "btn btn-primary" }, "Send Message") - ); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "complex form"); -}); - -test("snapshot - HTML entities escaping", async () => { - const vnode = createElement("div", { - title: "This has \"quotes\" & ", - "data-value": "" - }, - "Content with HTML & \"special\" characters" - ); - const result = renderToString(vnode); - await matchHTMLSnapshot(result, TEST_FILE, "HTML entities escaping"); -}); \ No newline at end of file diff --git a/packages/ono/test/renderer.test.js b/packages/ono/test/renderer.test.js index ae4c4ce..0ad4cb3 100644 --- a/packages/ono/test/renderer.test.js +++ b/packages/ono/test/renderer.test.js @@ -212,3 +212,93 @@ test("renderToString - Fragment with mixed content", () => { const result = renderToString(vnode); assert.strictEqual(result, "Text beforeboldText after"); }); + +// --- Output snapshots (update with: npm run test:update) --- + +const raw = { serializers: [(value) => value] }; + +test("renderToString snapshot - nested elements", (t) => { + const vnode = createElement("div", { className: "container" }, + createElement("h1", null, "Title"), + createElement("p", null, "This is a paragraph with ", + createElement("strong", null, "bold text"), + " and regular text." + ), + createElement("ul", null, + createElement("li", null, "Item 1"), + createElement("li", null, "Item 2"), + createElement("li", null, "Item 3") + ) + ); + t.assert.snapshot(renderToString(vnode), raw); +}); + +test("renderToString snapshot - component function", (t) => { + function BlogPost({ title, author, children }) { + return createElement("article", { className: "blog-post" }, + createElement("header", null, + createElement("h1", null, title), + createElement("p", { className: "author" }, "By ", author) + ), + createElement("div", { className: "content" }, children) + ); + } + + const vnode = BlogPost({ + title: "My First Post", + author: "John Doe", + children: [ + createElement("p", null, "This is the first paragraph."), + createElement("p", null, "This is the second paragraph.") + ] + }); + + t.assert.snapshot(renderToString(vnode), raw); +}); + +test("renderToString snapshot - style object", (t) => { + const vnode = createElement("div", { + style: { + backgroundColor: "red", + fontSize: "16px", + margin: "10px 20px" + } + }, "Styled content"); + t.assert.snapshot(renderToString(vnode), raw); +}); + +test("renderToString snapshot - complex form", (t) => { + const vnode = createElement("form", { className: "contact-form", method: "post" }, + createElement("div", { className: "form-group" }, + createElement("label", { htmlFor: "name" }, "Name:"), + createElement("input", { + type: "text", + id: "name", + name: "name", + required: true, + placeholder: "Enter your name" + }) + ), + createElement("div", { className: "form-group" }, + createElement("label", { htmlFor: "message" }, "Message:"), + createElement("textarea", { + id: "message", + name: "message", + rows: 4, + placeholder: "Enter your message" + }) + ), + createElement("button", { type: "submit", className: "btn btn-primary" }, "Send Message") + ); + t.assert.snapshot(renderToString(vnode), raw); +}); + +test("renderToString snapshot - HTML entities escaping", (t) => { + const vnode = createElement("div", { + title: "This has \"quotes\" & ", + "data-value": "" + }, + "Content with HTML & \"special\" characters" + ); + t.assert.snapshot(renderToString(vnode), raw); +}); diff --git a/packages/ono/test/renderer.test.js.snapshot b/packages/ono/test/renderer.test.js.snapshot new file mode 100644 index 0000000..ff7c582 --- /dev/null +++ b/packages/ono/test/renderer.test.js.snapshot @@ -0,0 +1,19 @@ +exports[`renderToString snapshot - HTML entities escaping 1`] = ` +
Content with <em>HTML</em> & "special" characters
+`; + +exports[`renderToString snapshot - complex form 1`] = ` +
+`; + +exports[`renderToString snapshot - component function 1`] = ` +

My First Post

By John Doe

This is the first paragraph.

This is the second paragraph.

+`; + +exports[`renderToString snapshot - nested elements 1`] = ` +

Title

This is a paragraph with bold text and regular text.

  • Item 1
  • Item 2
  • Item 3
+`; + +exports[`renderToString snapshot - style object 1`] = ` +
Styled content
+`; diff --git a/packages/ono/test/resolver.test.js b/packages/ono/test/resolver.test.js deleted file mode 100644 index 448857f..0000000 --- a/packages/ono/test/resolver.test.js +++ /dev/null @@ -1,162 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { parseImports, resolveImportPath, collectDependencies } from "../src/resolver.js"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixturesDir = path.join(__dirname, "fixtures"); - -test("parseImports - single default import", () => { - const code = `import Button from "./Button.jsx";`; - const result = parseImports(code); - - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].specifier, "./Button.jsx"); -}); - -test("parseImports - multiple imports", () => { - const code = ` -import Card from "./Card.jsx"; -import Button from "./Button.jsx"; - `.trim(); - - const result = parseImports(code); - - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].specifier, "./Card.jsx"); - assert.strictEqual(result[1].specifier, "./Button.jsx"); -}); - -test("parseImports - no imports", () => { - const code = `function test() { return
; }`; - const result = parseImports(code); - - assert.strictEqual(result.length, 0); -}); - -test("parseImports - named imports", () => { - const code = `import { foo, bar } from "./utils.js";`; - const result = parseImports(code); - - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].specifier, "./utils.js"); -}); - -test("parseImports - mixed imports", () => { - const code = ` -import React from "react"; -import Button from "./Button.jsx"; -import { helper } from "./utils.js"; - `.trim(); - - const result = parseImports(code); - - assert.strictEqual(result.length, 3); -}); - -test("resolveImportPath - relative path same directory", () => { - const fromFile = path.join(fixturesDir, "App.jsx"); - const importPath = "./Button.jsx"; - - const result = resolveImportPath(importPath, fromFile); - const expected = path.join(fixturesDir, "Button.jsx"); - - assert.strictEqual(result, expected); -}); - -test("resolveImportPath - relative path parent directory", () => { - const fromFile = path.join(fixturesDir, "sub", "Component.jsx"); - const importPath = "../Button.jsx"; - - const result = resolveImportPath(importPath, fromFile); - const expected = path.join(fixturesDir, "Button.jsx"); - - assert.strictEqual(result, expected); -}); - -test("resolveImportPath - absolute stays absolute", () => { - const fromFile = path.join(fixturesDir, "App.jsx"); - const importPath = "/absolute/path/Button.jsx"; - - const result = resolveImportPath(importPath, fromFile); - - assert.strictEqual(result, importPath); -}); - -test("collectDependencies - no imports", async () => { - const entryFile = path.join(fixturesDir, "NoImports.jsx"); - const result = await collectDependencies(entryFile); - - assert.strictEqual(result.modules.size, 1); - assert.ok(result.modules.has(entryFile)); -}); - -test("collectDependencies - single level imports", async () => { - const entryFile = path.join(fixturesDir, "Card.jsx"); - const result = await collectDependencies(entryFile); - - // Card.jsx imports Button.jsx - assert.strictEqual(result.modules.size, 2); - assert.ok(result.modules.has(entryFile)); - - const buttonPath = path.join(fixturesDir, "Button.jsx"); - assert.ok(result.modules.has(buttonPath)); -}); - -test("collectDependencies - nested imports", async () => { - const entryFile = path.join(fixturesDir, "App.jsx"); - const result = await collectDependencies(entryFile); - - // App.jsx imports Card.jsx and Button.jsx - // Card.jsx also imports Button.jsx - // Total: App.jsx, Card.jsx, Button.jsx (3 unique files) - assert.strictEqual(result.modules.size, 3); - - const appPath = path.join(fixturesDir, "App.jsx"); - const cardPath = path.join(fixturesDir, "Card.jsx"); - const buttonPath = path.join(fixturesDir, "Button.jsx"); - - assert.ok(result.modules.has(appPath)); - assert.ok(result.modules.has(cardPath)); - assert.ok(result.modules.has(buttonPath)); -}); - -test("collectDependencies - returns dependency graph", async () => { - const entryFile = path.join(fixturesDir, "Card.jsx"); - const result = await collectDependencies(entryFile); - - const cardDeps = result.graph.get(entryFile); - assert.ok(cardDeps); - assert.strictEqual(cardDeps.length, 1); - - const buttonPath = path.join(fixturesDir, "Button.jsx"); - assert.ok(cardDeps.includes(buttonPath)); -}); - -test("collectDependencies - correct evaluation order", async () => { - const entryFile = path.join(fixturesDir, "App.jsx"); - const result = await collectDependencies(entryFile); - - // Button should come before Card (Card depends on Button) - // Card should come before App (App depends on Card) - const buttonPath = path.join(fixturesDir, "Button.jsx"); - const cardPath = path.join(fixturesDir, "Card.jsx"); - const appPath = path.join(fixturesDir, "App.jsx"); - - const buttonIndex = result.order.indexOf(buttonPath); - const cardIndex = result.order.indexOf(cardPath); - const appIndex = result.order.indexOf(appPath); - - assert.ok(buttonIndex < cardIndex, "Button should come before Card"); - assert.ok(cardIndex < appIndex, "Card should come before App"); -}); - -test("collectDependencies - handles non-existent file", async () => { - const entryFile = path.join(fixturesDir, "NonExistent.jsx"); - - await assert.rejects( - async () => await collectDependencies(entryFile), - /ENOENT|no such file/i - ); -}); diff --git a/packages/ono/test/snapshot-utils.js b/packages/ono/test/snapshot-utils.js deleted file mode 100644 index 01cf2a1..0000000 --- a/packages/ono/test/snapshot-utils.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Snapshot testing utilities for Node.js test runner - */ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { existsSync } from "node:fs"; -import assert from "node:assert"; - -const SNAPSHOTS_DIR = "test/__snapshots__"; - -/** - * Normalize content for snapshot comparison - * @param {string} content - Content to normalize - * @returns {string} Normalized content - */ -function normalizeContent(content) { - return content - .trim() - .replace(/\r\n/g, "\n") // Normalize line endings - .replace(/\s+$/gm, ""); // Remove trailing whitespace -} - -/** - * Generate snapshot file path - * @param {string} testFile - Test file name - * @param {string} testName - Test name - * @returns {string} Snapshot file path - */ -function getSnapshotPath(testFile, testName) { - const sanitizedTestName = testName.replace(/[^a-zA-Z0-9-_]/g, "_"); - const sanitizedTestFile = testFile.replace(/\.test\.js$/, ""); - return join(SNAPSHOTS_DIR, `${sanitizedTestFile}.${sanitizedTestName}.snap`); -} - -/** - * Read existing snapshot - * @param {string} snapshotPath - Path to snapshot file - * @returns {Promise} Snapshot content or null if not exists - */ -async function readSnapshot(snapshotPath) { - try { - return await readFile(snapshotPath, "utf-8"); - } catch (error) { - if (error.code === "ENOENT") { - return null; - } - throw error; - } -} - -/** - * Write snapshot to file - * @param {string} snapshotPath - Path to snapshot file - * @param {string} content - Content to write - */ -async function writeSnapshot(snapshotPath, content) { - await mkdir(dirname(snapshotPath), { recursive: true }); - await writeFile(snapshotPath, content, "utf-8"); -} - -/** - * Match content against snapshot - * @param {string} content - Actual content - * @param {string} testFile - Test file name (e.g., "renderer.test.js") - * @param {string} testName - Test name - * @param {object} options - Options - * @param {boolean} options.updateSnapshots - Update snapshots instead of comparing - */ -export async function matchSnapshot(content, testFile, testName, options = {}) { - const { updateSnapshots = process.env.UPDATE_SNAPSHOTS === "true" } = options; - - const normalizedContent = normalizeContent(content); - const snapshotPath = getSnapshotPath(testFile, testName); - - if (updateSnapshots) { - await writeSnapshot(snapshotPath, normalizedContent); - console.log(`📸 Updated snapshot: ${snapshotPath}`); - return; - } - - const existingSnapshot = await readSnapshot(snapshotPath); - - if (existingSnapshot === null) { - // First time running this test, create snapshot - await writeSnapshot(snapshotPath, normalizedContent); - console.log(`📸 Created snapshot: ${snapshotPath}`); - return; - } - - const normalizedSnapshot = normalizeContent(existingSnapshot); - - try { - assert.strictEqual(normalizedContent, normalizedSnapshot); - } catch (error) { - // Provide helpful diff information - console.error(`❌ Snapshot mismatch in ${testName}`); - console.error(`Expected (snapshot):`); - console.error(normalizedSnapshot); - console.error(`Actual:`); - console.error(normalizedContent); - console.error(`\nTo update snapshots, run: UPDATE_SNAPSHOTS=true npm test`); - throw error; - } -} - -/** - * Match HTML snapshot with formatting - * @param {string} html - HTML content - * @param {string} testFile - Test file name - * @param {string} testName - Test name - * @param {object} options - Options - */ -export async function matchHTMLSnapshot(html, testFile, testName, options = {}) { - // Basic HTML formatting for better readability - const formattedHTML = html - .replace(/>\n<") - .replace(/\n\s*\n/g, "\n") - .trim(); - - await matchSnapshot(formattedHTML, testFile, testName, options); -} - -/** - * Match JavaScript code snapshot - * @param {string} code - JavaScript code - * @param {string} testFile - Test file name - * @param {string} testName - Test name - * @param {object} options - Options - */ -export async function matchCodeSnapshot(code, testFile, testName, options = {}) { - await matchSnapshot(code, testFile, testName, options); -} - -/** - * Check if snapshots directory exists - * @returns {boolean} True if snapshots directory exists - */ -export function hasSnapshots() { - return existsSync(SNAPSHOTS_DIR); -} \ No newline at end of file diff --git a/packages/ono/test/transformer.snapshot.test.js b/packages/ono/test/transformer.snapshot.test.js deleted file mode 100644 index e9e1881..0000000 --- a/packages/ono/test/transformer.snapshot.test.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Snapshot tests for JSX transformer - */ -import { test } from "node:test"; -import { transformJSX } from "../src/transformer.js"; -import { matchCodeSnapshot } from "./snapshot-utils.js"; - -const TEST_FILE = "transformer.snapshot.test.js"; - -test("snapshot - simple JSX element", async () => { - const input = `const elem =
Hello
;`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "simple JSX element"); -}); - -test("snapshot - JSX with props", async () => { - const input = `const elem =
Hello
;`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "JSX with props"); -}); - -test("snapshot - nested JSX", async () => { - const input = ` -const elem = ( -
-

Title

-

Paragraph with bold text

-
    -
  • Item 1
  • -
  • Item 2
  • -
-
-);`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "nested JSX"); -}); - -test("snapshot - component function with JSX", async () => { - const input = ` -function BlogPost({ title, children }) { - return ( -
-

{title}

-
- {children} -
-
- ); -}`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "component function with JSX"); -}); - -test("snapshot - JSX with expressions", async () => { - const input = ` -const todos = items.map(item => ( -
  • - toggle(item.id)} - /> - {item.text} - -
  • -));`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "JSX with expressions"); -}); - -test("snapshot - self-closing tags", async () => { - const input = ` -const form = ( -
    - - -
    -
    - Logo -
    -);`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "self-closing tags"); -}); - -test("snapshot - JSX fragments", async () => { - const input = ` -const content = ( - <> -

    Title

    -

    First paragraph

    -

    Second paragraph

    - -);`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "JSX fragments"); -}); - -test("snapshot - complex component with imports", async () => { - const input = ` -import { useState } from 'react'; -import { Header } from './components/Header'; -import { Footer } from './components/Footer'; - -export default function App() { - const [count, setCount] = useState(0); - - return ( -
    -
    -
    -

    Counter: {count}

    - - -
    -
    -
    - ); -}`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "complex component with imports"); -}); - -test("snapshot - style prop object", async () => { - const input = ` -const styledDiv = ( -
    - Styled content -
    -);`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "style prop object"); -}); - -test("snapshot - conditional rendering", async () => { - const input = ` -const content = ( -
    - {isLoggedIn ? ( -
    -

    Welcome back!

    - -
    - ) : ( -
    -

    Please log in

    - -
    - )} - {showMessage &&

    {message}

    } -
    -);`; - const result = transformJSX(input); - await matchCodeSnapshot(result, TEST_FILE, "conditional rendering"); -}); \ No newline at end of file diff --git a/packages/ono/test/transformer.test.js b/packages/ono/test/transformer.test.js index 3e57eb7..0348d4a 100644 --- a/packages/ono/test/transformer.test.js +++ b/packages/ono/test/transformer.test.js @@ -154,3 +154,113 @@ test("transformJSX - nested Fragment", () => { assert.ok(result.includes('h("div"')); assert.ok(result.includes('h("span"')); }); + +// --- Output snapshots (update with: npm run test:update) --- + +const raw = { serializers: [(value) => value] }; + +test("transformJSX snapshot - component function with JSX", (t) => { + const input = ` +function BlogPost({ title, children }) { + return ( +
    +

    {title}

    +
    + {children} +
    +
    + ); +}`; + t.assert.snapshot(transformJSX(input), raw); +}); + +test("transformJSX snapshot - JSX with expressions", (t) => { + const input = ` +const todos = items.map(item => ( +
  • + toggle(item.id)} + /> + {item.text} + +
  • +));`; + t.assert.snapshot(transformJSX(input), raw); +}); + +test("transformJSX snapshot - JSX fragments", (t) => { + const input = ` +const content = ( + <> +

    Title

    +

    First paragraph

    +

    Second paragraph

    + +);`; + t.assert.snapshot(transformJSX(input), raw); +}); + +test("transformJSX snapshot - complex component with imports", (t) => { + const input = ` +import { useState } from 'react'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; + +export default function App() { + const [count, setCount] = useState(0); + + return ( +
    +
    +
    +

    Counter: {count}

    + +
    +
    +
    + ); +}`; + t.assert.snapshot(transformJSX(input), raw); +}); + +test("transformJSX snapshot - style prop object", (t) => { + const input = ` +const styledDiv = ( +
    + Styled content +
    +);`; + t.assert.snapshot(transformJSX(input), raw); +}); + +test("transformJSX snapshot - conditional rendering", (t) => { + const input = ` +const content = ( +
    + {isLoggedIn ? ( +
    +

    Welcome back!

    + +
    + ) : ( +
    +

    Please log in

    + +
    + )} + {showMessage &&

    {message}

    } +
    +);`; + t.assert.snapshot(transformJSX(input), raw); +}); diff --git a/packages/ono/test/transformer.test.js.snapshot b/packages/ono/test/transformer.test.js.snapshot new file mode 100644 index 0000000..0e0d55c --- /dev/null +++ b/packages/ono/test/transformer.test.js.snapshot @@ -0,0 +1,63 @@ +exports[`transformJSX snapshot - JSX fragments 1`] = ` +const content = (h(Fragment, null, + h("h1", null, "Title"), + h("p", null, "First paragraph"), + h("p", null, "Second paragraph"))); + +`; + +exports[`transformJSX snapshot - JSX with expressions 1`] = ` +const todos = items.map(item => (h("li", { key: item.id, className: item.completed ? 'completed' : '' }, + h("input", { type: "checkbox", checked: item.completed, onChange: () => toggle(item.id) }), + h("span", null, item.text), + h("button", { onClick: () => remove(item.id) }, "\\u00D7")))); + +`; + +exports[`transformJSX snapshot - complex component with imports 1`] = ` +import { useState } from 'react'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +export default function App() { + const [count, setCount] = useState(0); + return (h("div", { className: "app" }, + h(Header, { title: "My App" }), + h("main", null, + h("h1", null, + "Counter: ", + count), + h("button", { onClick: () => setCount(count + 1) }, "Increment")), + h(Footer, null))); +} + +`; + +exports[`transformJSX snapshot - component function with JSX 1`] = ` +function BlogPost({ title, children }) { + return (h("article", { className: "blog-post" }, + h("h1", null, title), + h("div", { className: "content" }, children))); +} + +`; + +exports[`transformJSX snapshot - conditional rendering 1`] = ` +const content = (h("div", null, + isLoggedIn ? (h("div", null, + h("h1", null, "Welcome back!"), + h("button", { onClick: logout }, "Logout"))) : (h("div", null, + h("h1", null, "Please log in"), + h("button", { onClick: login }, "Login"))), + showMessage && h("p", { className: "message" }, message))); + +`; + +exports[`transformJSX snapshot - style prop object 1`] = ` +const styledDiv = (h("div", { style: { + backgroundColor: 'red', + fontSize: '16px', + padding: '10px', + border: '1px solid black' + } }, "Styled content")); + +`; diff --git a/packages/ono/test/unocss.snapshot.test.js b/packages/ono/test/unocss.test.js similarity index 66% rename from packages/ono/test/unocss.snapshot.test.js rename to packages/ono/test/unocss.test.js index 69f8104..822df1b 100644 --- a/packages/ono/test/unocss.snapshot.test.js +++ b/packages/ono/test/unocss.test.js @@ -1,13 +1,14 @@ /** * Snapshot tests for UnoCSS generation + * Update snapshots with: npm run test:update */ import { test } from "node:test"; import { generateCSS } from "../src/unocss.js"; -import { matchCodeSnapshot } from "./snapshot-utils.js"; -const TEST_FILE = "unocss.snapshot.test.js"; +// Keep generated CSS readable in the snapshot file +const raw = { serializers: [(value) => value] }; -test("snapshot - basic utility classes", async () => { +test("unocss - basic utility classes", async (t) => { const htmlContent = `

    Title

    @@ -15,11 +16,10 @@ test("snapshot - basic utility classes", async () => {
    `; - const css = await generateCSS(htmlContent); - await matchCodeSnapshot(css, TEST_FILE, "basic utility classes"); + t.assert.snapshot(await generateCSS(htmlContent), raw); }); -test("snapshot - layout classes", async () => { +test("unocss - layout classes", async (t) => { const htmlContent = `
    @@ -33,11 +33,10 @@ test("snapshot - layout classes", async () => {
    `; - const css = await generateCSS(htmlContent); - await matchCodeSnapshot(css, TEST_FILE, "layout classes"); + t.assert.snapshot(await generateCSS(htmlContent), raw); }); -test("snapshot - responsive design", async () => { +test("unocss - responsive design", async (t) => { const htmlContent = `
    @@ -47,11 +46,10 @@ test("snapshot - responsive design", async () => {
    `; - const css = await generateCSS(htmlContent); - await matchCodeSnapshot(css, TEST_FILE, "responsive design"); + t.assert.snapshot(await generateCSS(htmlContent), raw); }); -test("snapshot - color utilities", async () => { +test("unocss - color utilities", async (t) => { const htmlContent = `
    @@ -62,11 +60,10 @@ test("snapshot - color utilities", async () => {
    `; - const css = await generateCSS(htmlContent); - await matchCodeSnapshot(css, TEST_FILE, "color utilities"); + t.assert.snapshot(await generateCSS(htmlContent), raw); }); -test("snapshot - spacing and sizing", async () => { +test("unocss - spacing and sizing", async (t) => { const htmlContent = `
    @@ -78,11 +75,10 @@ test("snapshot - spacing and sizing", async () => {
    `; - const css = await generateCSS(htmlContent); - await matchCodeSnapshot(css, TEST_FILE, "spacing and sizing"); + t.assert.snapshot(await generateCSS(htmlContent), raw); }); -test("snapshot - typography", async () => { +test("unocss - typography", async (t) => { const htmlContent = `

    Main Title

    @@ -98,11 +94,10 @@ test("snapshot - typography", async () => {
    `; - const css = await generateCSS(htmlContent); - await matchCodeSnapshot(css, TEST_FILE, "typography"); + t.assert.snapshot(await generateCSS(htmlContent), raw); }); -test("snapshot - form elements", async () => { +test("unocss - form elements", async (t) => { const htmlContent = `
    @@ -112,13 +107,6 @@ test("snapshot - form elements", async () => { class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
    -
    - - -