diff --git a/.changeset/config.json b/.changeset/config.json index f8dfd84df..7766c96ed 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,7 @@ "access": "public", "baseBranch": "master", "updateInternalDependencies": "patch", - "ignore": ["@kitajs/bench-*"], + "ignore": ["@kitajs/bench-*", "@kitajs/example-*"], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true } diff --git a/.changeset/hip-trams-roll.md b/.changeset/hip-trams-roll.md new file mode 100644 index 000000000..c53a8f6dc --- /dev/null +++ b/.changeset/hip-trams-roll.md @@ -0,0 +1,5 @@ +--- +'@kitajs/ts-html-plugin': patch +--- + +Skip xss check for `str &&` cases diff --git a/.changeset/metal-corners-type.md b/.changeset/metal-corners-type.md new file mode 100644 index 000000000..d8675de67 --- /dev/null +++ b/.changeset/metal-corners-type.md @@ -0,0 +1,5 @@ +--- +'@kitajs/ts-html-plugin': patch +--- + +Support for multiline TSServer messages and improvements to XSS Children detection diff --git a/.changeset/metal-hairs-juggle.md b/.changeset/metal-hairs-juggle.md new file mode 100644 index 000000000..111d507e9 --- /dev/null +++ b/.changeset/metal-hairs-juggle.md @@ -0,0 +1,5 @@ +--- +'@kitajs/ts-html-plugin': patch +--- + +Fixed CLI inconsistencies with a dedicated bin js file diff --git a/.changeset/slimy-wolves-buy.md b/.changeset/slimy-wolves-buy.md new file mode 100644 index 000000000..ff4ca0534 --- /dev/null +++ b/.changeset/slimy-wolves-buy.md @@ -0,0 +1,5 @@ +--- +'@kitajs/html': patch +--- + +Broather and more reliable test suite diff --git a/.changeset/stale-shrimps-spend.md b/.changeset/stale-shrimps-spend.md new file mode 100644 index 000000000..2367548f8 --- /dev/null +++ b/.changeset/stale-shrimps-spend.md @@ -0,0 +1,5 @@ +--- +'@kitajs/html': major +--- + +Removed deprecated @kitajs/html/register diff --git a/.changeset/warm-seas-kiss.md b/.changeset/warm-seas-kiss.md new file mode 100644 index 000000000..cedb85b33 --- /dev/null +++ b/.changeset/warm-seas-kiss.md @@ -0,0 +1,5 @@ +--- +'@kitajs/html': major +--- + +Major overhaul diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 265762c35..f1a4b6de0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,16 @@ jobs: - name: Install packages run: pnpm install --frozen-lockfile + - name: Cache turbo build setup + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + - name: Build - run: pnpm build + run: pnpm build-all - name: Test run: pnpm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c2382666..43fa884a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,14 @@ jobs: - name: Install packages run: pnpm install --frozen-lockfile + - name: Cache turbo build setup + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + - name: Build run: pnpm build diff --git a/.gitignore b/.gitignore index 0310715b3..8812c91f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ - -node_modules - -index.tsbuildinfo -dist -coverage - +.DS_Store +.turbo +*.cpuprofile *.log - -benchmarks/runner/profile.cpuprofile \ No newline at end of file +*.tsbuildinfo +*.tsbuildinfo +coverage +dist +node_modules \ No newline at end of file diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..14d86ad62 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/memories/architecture_and_patterns.md b/.serena/memories/architecture_and_patterns.md new file mode 100644 index 000000000..c15bf57bd --- /dev/null +++ b/.serena/memories/architecture_and_patterns.md @@ -0,0 +1,228 @@ +# Architecture and Design Patterns + +## Core Architecture + +### JSX Runtime Model + +The library transforms JSX into function calls that generate HTML strings: + +```tsx +
{content}
; +// Transpiles to: +jsx('div', { class: 'foo', children: content }); +// Returns: '
content
' +``` + +Key architectural points: + +- JSX elements are **always strings or Promises** (never React-like objects) +- No virtual DOM - direct string generation +- Async components propagate up the tree (if child is async, parent becomes async) + +### Monorepo Structure + +- **Workspace-based**: Uses pnpm workspaces with shared catalog for dependencies +- **Independent versioning**: Each package has its own version via changesets +- **Shared configuration**: Root-level TypeScript and Prettier configs + +## Key Design Patterns + +### 1. String Building Pattern + +Core pattern: Efficient string concatenation without intermediate objects + +```javascript +// From index.js +function createElement(tag, attrs, ...children) { + return `<${tag}${attributesToString(attrs)}>${contentsToString(children)}`; +} +``` + +### 2. Async/Await Pattern + +Async components are seamlessly integrated: + +- Sync components return `string` +- Async components return `Promise` +- Type system tracks async propagation via `JSX.Element` type + +### 3. Security by Default Pattern + +- **Attributes**: Auto-escaped by default +- **Children**: Must explicitly use `safe` attribute or `Html.escapeHtml()` +- **TypeScript Plugin**: Catches XSS at compile time + +### 4. Suspense Pattern + +Streaming HTML with fallback content: + +```tsx +} catch={(err) => }> + + +``` + +Uses request IDs (rid) for concurrent request safety. + +### 5. Error Boundary Pattern + +Catch errors in async component trees: + +```tsx + }> + + +``` + +## Module System + +### Exports Pattern + +Each package uses explicit exports in package.json: + +```json +{ + "exports": { + ".": "./dist/index.js", + "./jsx-runtime": "./dist/jsx-runtime.js", + "./suspense": "./dist/suspense.js" + // etc. + } +} +``` + +### CommonJS with ESM Compatibility + +- Main output: CommonJS +- `esModuleInterop` enabled for compatibility +- No ESM build currently (could be added) + +## Type System Patterns + +### 1. Namespace-based Types + +Uses `JSX` namespace for type definitions: + +```typescript +declare namespace JSX { + interface IntrinsicElements { + div: HtmlTag & { + /* div-specific */ + }; + } +} +``` + +### 2. Type Extensions + +Users can extend types globally: + +```typescript +declare global { + namespace JSX { + interface HtmlTag { + 'hx-get'?: string; // HTMX support + } + } +} +``` + +### 3. Conditional Types + +`JSX.Element` is conditionally `string | Promise` based on usage. + +## Testing Patterns + +### 1. Vitest Test Runner + +Uses Vitest with coverage and type checking: + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('component', () => { + it('renders correctly', () => { + expect(
hello
).toBe('
hello
'); + }); +}); +``` + +### 2. JSDOM for DOM Testing + +When DOM testing is needed: + +```typescript +import { JSDOM } from 'jsdom'; +const dom = new JSDOM(htmlString); +``` + +### 3. Vitest Type Testing + +For TypeScript type definitions (using vitest --typecheck): + +```typescript +// Uses vitest's built-in type testing capabilities +import { expectTypeOf } from 'vitest'; +expectTypeOf(
foo
).toEqualTypeOf(); +``` + +## Performance Patterns + +### 1. Minimal Allocations + +- Avoid creating intermediate objects +- Direct string concatenation where possible +- Avoid regex when string methods suffice + +### 2. Void Element Optimization + +Special handling for self-closing tags: + +```javascript +if (isVoidElement(tag)) { + return `<${tag}${attributesToString(attrs)}>`; +} +``` + +### 3. Attribute String Building + +Efficient attribute serialization with kebab-case conversion: + +```javascript +function attributesToString(attrs) { + // Optimized string building +} +``` + +## Guidelines and Best Practices + +### Do's: + +- Use `safe` attribute for all user input +- Prefer composition over prop drilling +- Keep components pure when possible +- Use TypeScript strict mode +- Write tests for new features +- Consider performance for core HTML generation + +### Don'ts: + +- Don't use React-specific patterns (hooks, context, etc.) +- Don't create circular dependencies between packages +- Don't bypass XSS safety features +- Don't use `any` type without strong justification +- Don't mix CommonJS and ESM syntax + +### Async Component Guidelines: + +- Use request IDs (rid) for Suspense components +- Error boundaries for async error handling +- Avoid AsyncLocalStorage (performance penalty) +- Document when a component is async + +### Security Guidelines: + +- **ALWAYS** escape user input +- Use `@kitajs/ts-html-plugin` to catch XSS issues +- Review all changes to escaping logic carefully +- Test with malicious input samples diff --git a/.serena/memories/code_style_and_conventions.md b/.serena/memories/code_style_and_conventions.md new file mode 100644 index 000000000..15e186620 --- /dev/null +++ b/.serena/memories/code_style_and_conventions.md @@ -0,0 +1,84 @@ +# Code Style and Conventions + +## TypeScript Configuration + +The project uses **strict TypeScript settings** with the following key configurations: + +### JSX Settings + +- `jsx`: "react-jsx" +- `jsxImportSource`: "@kitajs/html" +- `plugins`: [{ "name": "@kitajs/ts-html-plugin" }] + +### Module Settings + +- `module`: "CommonJS" +- `moduleResolution`: "node" +- `target`: "ESNext" +- `esModuleInterop`: true + +### Strict Mode Settings (all enabled) + +- `strict`: true +- `noImplicitAny`: true +- `strictNullChecks`: true +- `strictFunctionTypes`: true +- `strictBindCallApply`: true +- `strictPropertyInitialization`: true +- `noImplicitThis`: true +- `useUnknownInCatchVariables`: true +- `alwaysStrict`: true +- `noUnusedLocals`: true +- `noUnusedParameters`: true +- `noImplicitReturns`: true +- `noFallthroughCasesInSwitch`: true +- `noUncheckedIndexedAccess`: true +- `noImplicitOverride`: true + +### Build Settings + +- Source maps and declaration maps enabled +- Output directory: `dist` +- Incremental compilation enabled + +## Formatting + +- **Tool**: Prettier +- **Configuration**: Uses @arthurfiorette/prettier-config +- **Plugins**: + - prettier-plugin-jsdoc + - prettier-plugin-organize-imports + - prettier-plugin-packagejson +- **Pre-commit hook**: Automatically formats staged files before commit + +## Naming Conventions + +Based on the codebase: + +- Functions: camelCase (e.g., `createElement`, `attributesToString`) +- Constants: UPPER_SNAKE_CASE (e.g., `CAMEL_REGEX`, `ESCAPED_REGEX`) +- Variables: camelCase (e.g., `escapeHtml`) +- Files: kebab-case for test files (e.g., `simple-html.test.tsx`) +- Packages: scoped with @kitajs/ prefix + +## JSX Usage + +- No need to import React or Html namespace when using react-jsx transform +- Components can be sync (return string) or async (return Promise) +- Always use `safe` attribute or `Html.escapeHtml()` for user input to prevent XSS +- Attributes are automatically escaped by default +- Children content is NOT escaped by default (requires `safe` attribute) + +## File Organization + +- Source files in `src/` directory +- Tests in `test/` directory with `.test.tsx` or `.test.ts` extension +- Type definitions generated alongside compiled files in `dist/` +- Build output in `dist/` directory +- Additional type definition files (htmx.d.ts, alpine.d.ts, etc.) in package root + +## Documentation + +- Use JSDoc comments for public APIs +- TypeScript types serve as primary documentation +- README.md in each package with comprehensive examples diff --git a/.serena/memories/darwin_system_commands.md b/.serena/memories/darwin_system_commands.md new file mode 100644 index 000000000..a2ee726ef --- /dev/null +++ b/.serena/memories/darwin_system_commands.md @@ -0,0 +1,202 @@ +# macOS (Darwin) System Commands + +The project is running on macOS (Darwin 25.2.0). Here are important system-specific +considerations: + +## Standard Unix Commands Available + +Most standard Unix commands work on macOS: + +- `ls`, `cd`, `pwd`, `mkdir`, `rm`, `cp`, `mv` +- `cat`, `less`, `head`, `tail` +- `grep`, `find`, `sed`, `awk` +- `chmod`, `chown` +- `ps`, `kill`, `top` + +## macOS-Specific Considerations + +### Package Management + +This project uses **pnpm** for Node.js packages, enforced by preinstall hook. + +### File System + +- **Case-insensitive** by default (but case-preserving) +- Be careful with file naming to avoid cross-platform issues +- Use forward slashes (/) in paths, not backslashes + +### Command Differences from Linux + +Some commands have different options or behavior: + +- `sed -i` requires an extension argument: `sed -i '' 's/foo/bar/g'` +- `readlink` doesn't have `-f` flag (use `greadlink` from coreutils if needed) +- `stat` has different syntax than GNU stat +- `xargs` may have different default behavior + +### Git + +Standard git commands work as expected: + +```bash +git status +git add . +git commit -m "message" +git push +git pull +git branch +git checkout +git diff +``` + +### Node.js & pnpm + +```bash +# Node version +node --version # Should be >= 20.13 + +# pnpm commands +pnpm install +pnpm test +pnpm build +pnpm format +``` + +### Common Development Tasks + +#### File Search + +```bash +# Find files by name +find . -name "*.tsx" + +# Find files excluding node_modules +find . -name "*.tsx" -not -path "*/node_modules/*" + +# Using grep for content search +grep -r "searchTerm" packages/ +``` + +#### Process Management + +```bash +# List processes +ps aux | grep node + +# Kill process by PID +kill + +# Kill process by name +pkill -f "process-name" +``` + +#### File Operations + +```bash +# View file contents +cat file.txt +less file.txt + +# View end of log file +tail -f logfile.txt + +# Count lines +wc -l file.txt + +# Find and replace (macOS-specific) +sed -i '' 's/old/new/g' file.txt +``` + +#### Permissions + +```bash +# Make script executable +chmod +x script.sh + +# Fix permission issues +chmod 644 file.txt # rw-r--r-- +chmod 755 file.sh # rwxr-xr-x +``` + +### Environment + +#### Shell + +Default shell on macOS is zsh (as of Catalina+): + +- Shell scripts should use `#!/usr/bin/env bash` or `#!/bin/sh` +- Environment variables work standard way: `export VAR=value` + +#### Paths + +- Home directory: `~` or `$HOME` +- Current directory: `.` +- Parent directory: `..` +- Absolute paths start with `/` + +### Performance Monitoring + +```bash +# CPU and memory usage +top + +# Better alternative (if installed) +htop + +# Disk usage +du -sh * + +# Disk free space +df -h +``` + +### Networking (if needed) + +```bash +# Check port usage +lsof -i :3000 + +# Check network connections +netstat -an | grep LISTEN +``` + +## Project-Specific Commands + +### Most Used Git Operations + +```bash +# Check status +git status + +# View changes +git diff + +# Add all changes +git add . + +# Commit (pre-commit hook will run Prettier automatically) +git commit -m "feat: add new feature" + +# Push +git push + +# Pull with rebase +git pull --rebase +``` + +### Testing Individual Files + +```bash +# Run specific test file (after building) +node --test dist/test/specific-test.test.js +``` + +### Debugging + +```bash +# Run with inspector +node --inspect dist/test/test-file.js + +# Run with more verbose output +NODE_OPTIONS="--trace-warnings" pnpm test +``` diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md new file mode 100644 index 000000000..e5172784e --- /dev/null +++ b/.serena/memories/project_overview.md @@ -0,0 +1,55 @@ +# KitaJS HTML - Project Overview + +## Purpose + +KitaJS HTML is a monorepo containing a super fast JSX runtime library that generates HTML +strings. It's designed to work with any Node.js framework (Express, Fastify, Hono, +AdonisJS, Bun, etc.) and integrates well with HTMX, Alpine.js, and Hotwire Turbo. + +Key features: + +- JSX-based HTML generation that outputs strings +- Type-safe HTML templates using TypeScript +- Built-in XSS protection with TypeScript plugin +- Support for async components with Suspense +- Error boundaries for async error handling +- Performance-focused (benchmarks show 7-41x faster than alternatives) + +## Repository Structure + +This is a pnpm monorepo with the following structure: + +### Main Packages (packages/) + +- **@kitajs/html** - Core JSX runtime for HTML generation +- **@kitajs/ts-html-plugin** - TypeScript LSP plugin for XSS detection and validation +- **@kitajs/fastify-html-plugin** - Fastify integration plugin + +### Additional Directories + +- **benchmarks/** - Performance benchmarks comparing with React, Typed Html, etc. +- **examples/** - Example code demonstrating usage +- **.husky/** - Git hooks configuration +- **.github/** - GitHub workflows and CI/CD +- **.changeset/** - Changesets for version management + +## Tech Stack + +- **Language**: TypeScript 5.9+ +- **Runtime**: Node.js >= 20.13 +- **Package Manager**: pnpm >= 10 (required via preinstall hook) +- **JSX Transform**: react-jsx with jsxImportSource: @kitajs/html +- **Module System**: CommonJS +- **Build Tool**: tsgo (@typescript/native-preview) +- **Testing**: Vitest with @vitest/coverage-v8 +- **Formatting**: Prettier with @arthurfiorette/prettier-config +- **Git Hooks**: Husky +- **Versioning**: Changesets with GitHub changelog integration + +## Key Dependencies + +- csstype (for CSS types) +- fastify-plugin (for Fastify integration) +- TypeScript, tslib (build tools) +- JSDOM (for DOM testing) +- Vitest with v8 coverage diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 000000000..432a06086 --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,141 @@ +# Suggested Development Commands + +## Package Manager + +**Always use pnpm** - The project enforces this via preinstall hook. Requires pnpm >= 10. + +## Main Development Commands + +### Building + +```bash +# Build all packages +pnpm build + +# Build specific package (from root) +pnpm --filter "@kitajs/html" build + +# Build uses tsgo (native TypeScript compiler preview) +# Each package has: pnpm build -> tsgo -p tsconfig.build.json +``` + +### Testing + +```bash +# Run all tests in all packages +pnpm test + +# Run tests in a specific package +pnpm --filter "@kitajs/html" test +cd packages/html && pnpm test + +# Test command runs Vitest with coverage and type checking: +# vitest --coverage --typecheck --run +``` + +### Formatting + +```bash +# Format all files +pnpm format + +# Format specific files (Prettier) +prettier --write +``` + +### Benchmarking + +```bash +# Run performance benchmarks +pnpm bench + +# This builds benchmark packages and runs the benchmark runner +``` + +### Versioning & Publishing + +```bash +# Create a changeset (for version bumps) +pnpm changeset + +# Version packages (CI command) +pnpm ci-version + +# Publish packages (CI command) +pnpm ci-publish +``` + +### Git Hooks + +Git hooks are managed by Husky: + +- **pre-commit**: Automatically formats staged files with Prettier +- Setup: `pnpm prepare` (runs `husky` command) + +## Per-Package Commands + +### @kitajs/html + +```bash +cd packages/html +pnpm build # Build with tsgo +pnpm test # Run vitest with coverage and typecheck +``` + +### @kitajs/ts-html-plugin + +```bash +cd packages/ts-html-plugin +pnpm build # Build with tsgo +pnpm test # Run vitest with coverage and typecheck +``` + +### @kitajs/fastify-html-plugin + +```bash +cd packages/fastify-html-plugin +pnpm build # Build with tsgo +pnpm test # Run vitest with coverage and typecheck +``` + +## Common Workflows + +### After making changes: + +1. Format code: `pnpm format` (or let pre-commit hook handle it) +2. Build: `pnpm build` +3. Run tests: `pnpm test` + +### Before committing: + +- Pre-commit hook automatically runs Prettier on staged files +- No manual action needed + +### Testing a single package: + +```bash +pnpm --filter "@kitajs/html" test +``` + +### Working with workspace: + +```bash +# Install dependencies +pnpm install + +# Run command in all packages +pnpm -r + +# Run command in specific package +pnpm --filter "" + +# Run commands in parallel +pnpm -r --parallel +``` + +### Running examples: + +```bash +npx tsx examples/fastify-htmx.tsx +npx tsx examples/http-server.tsx +``` diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md new file mode 100644 index 000000000..32e613a6d --- /dev/null +++ b/.serena/memories/task_completion_checklist.md @@ -0,0 +1,93 @@ +# Task Completion Checklist + +When completing a coding task in this project, follow these steps: + +## 1. Code Quality + +- [ ] TypeScript compiles without errors (`tsc`) +- [ ] Code follows strict TypeScript settings (no `any`, proper null checks) +- [ ] No unused variables or parameters +- [ ] All functions have proper return types + +## 2. Formatting + +- [ ] Code is formatted with Prettier + - Run: `pnpm format` + - Or let pre-commit hook handle it automatically + +## 3. XSS Safety (Critical for this project!) + +- [ ] All user input uses `safe` attribute or `Html.escapeHtml()` +- [ ] No raw string concatenation with user input +- [ ] TypeScript plugin catches potential XSS vulnerabilities +- [ ] Consider running `xss-scan` if modifying JSX/HTML generation code + +## 4. Testing + +- [ ] Run tests: `pnpm test` (from root or package directory) +- [ ] All tests pass with no failures +- [ ] Code coverage is maintained or improved (Vitest v8 coverage) +- [ ] For new features: Add appropriate tests in `test/` directory +- [ ] Type tests pass: Vitest runs with `--typecheck` flag + +## 5. Build + +- [ ] Build succeeds: `pnpm build` (uses tsgo - native TypeScript compiler) +- [ ] No build warnings or errors +- [ ] Type definitions (.d.ts) are correctly generated in `dist/` + +## 6. Documentation + +- [ ] Update README.md if adding new features or changing APIs +- [ ] Add JSDoc comments for public APIs +- [ ] Update type definitions if needed +- [ ] Consider updating examples if relevant + +## 7. Performance (if applicable) + +- [ ] Consider running benchmarks if changes affect core HTML generation + - Run: `pnpm bench` +- [ ] No performance regressions + +## 8. Version Management (if releasing) + +- [ ] Create changeset if making user-facing changes + - Run: `pnpm changeset` +- [ ] Follow semantic versioning + +## 9. Git + +- [ ] Commit messages are clear and descriptive +- [ ] Pre-commit hook has run (Prettier formatting) +- [ ] No unnecessary files committed +- [ ] Changes are focused and atomic + +## Quick Check Before Committing + +From the root directory: + +```bash +pnpm format # Format code +pnpm build # Build all packages +pnpm test # Run all tests +``` + +If all three succeed, the code is ready to commit! + +## Special Considerations + +### For Core HTML Package (@kitajs/html) + +- XSS safety is CRITICAL - always verify proper escaping +- Performance matters - avoid unnecessary allocations +- Type safety - ensure JSX types are correct + +### For TypeScript Plugin (@kitajs/ts-html-plugin) + +- Test with real TypeScript projects +- Ensure error messages are clear and helpful + +### For Fastify Plugin (@kitajs/fastify-html-plugin) + +- Test integration with Fastify +- Ensure type definitions work correctly (tsd tests) diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..5f1bc809e --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,87 @@ +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp csharp_omnisharp +# dart elixir elm erlang fortran fsharp +# go groovy haskell java julia kotlin +# lua markdown nix pascal perl php +# powershell python python_jedi r rego ruby +# ruby_solargraph rust scala swift terraform toml +# typescript typescript_vts yaml zig +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal / Lazarus, use pascal +# Special requirements: +# - csharp: Requires the presence of a .sln file in the project folder. +# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: + - typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: 'utf-8' + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: '' + +project_name: 'html' +included_optional_tools: [] diff --git a/.vscode/settings.json b/.vscode/settings.json index edc22acec..9a44a6ada 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,9 @@ { - "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, - "cSpell.words": ["KITA"] + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.tsserver.maxTsServerMemory": 4096, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1b4643f79 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,327 @@ +# KitaJS HTML Monorepo - Developer Guide + +## Overview + +KitaJS HTML is a monorepo containing a super-fast JSX runtime that generates HTML strings. +Unlike React, which builds a virtual DOM, this library directly produces HTML strings, +making it ideal for server-side rendering, static site generation, and HTMX-style +applications. + +## Repository Structure + +``` +kitajs/html/ +├── packages/ +│ ├── html/ # Core JSX runtime (@kitajs/html) +│ ├── ts-html-plugin/ # XSS detection TypeScript plugin (@kitajs/ts-html-plugin) +│ └── fastify-html-plugin/# Fastify integration (@kitajs/fastify-html-plugin) +├── benchmarks/ # Performance benchmarks +└── examples/ # Usage examples +``` + +## Package Dependencies + +``` +@kitajs/html (core) + ↑ + ├── @kitajs/ts-html-plugin (peer dependency) + │ + └── @kitajs/fastify-html-plugin (peer dependency) +``` + +## Quick Start + +### Development Commands + +```bash +# Install dependencies (pnpm required) +pnpm install + +# Build all packages +pnpm build + +# Run all tests +pnpm test + +# Format code +pnpm format + +# Run benchmarks +pnpm bench +``` + +### Per-Package Commands + +```bash +# Build specific package +pnpm --filter "@kitajs/html" build + +# Test specific package +pnpm --filter "@kitajs/ts-html-plugin" test + +# Run commands from package directory +cd packages/html && pnpm test +``` + +## Architecture Overview + +### Core Concept: JSX → String + +```tsx +// Input (JSX) +
{name}
; + +// TypeScript transforms to +jsx('div', { class: 'hello', children: name }); + +// Output (string) +('
Arthur
'); +``` + +### Key Architectural Decisions + +1. **No Virtual DOM**: Direct string concatenation for maximum performance +2. **Type as String**: `JSX.Element = string | Promise` +3. **Async Propagation**: Promise children make parent promises +4. **XSS by Default**: Children are NOT escaped unless `safe` attribute is used +5. **Compile-Time Safety**: TypeScript plugin catches XSS at development time + +### Data Flow + +``` +User Code (TSX) + │ + ▼ +TypeScript Compiler + │ (jsx: "react-jsx", jsxImportSource: "@kitajs/html") + ▼ +jsx-runtime.ts (jsx/jsxs functions) + │ + ▼ +index.ts (createElement, attributesToString, contentsToString) + │ + ▼ +HTML String (or Promise for async) +``` + +### XSS Protection Flow + +``` +User writes JSX + │ + ▼ +ts-html-plugin (LSP) ─────► Warnings/Errors in Editor + │ + ▼ +xss-scan (CLI) ────────────► CI/CD Pipeline Check + │ + ▼ +Runtime (safe attribute) ──► Escapes at render time +``` + +## Package Summaries + +### @kitajs/html + +The core JSX runtime. Key files: + +- `src/index.ts`: Escaping, attribute handling, element creation +- `src/jsx-runtime.ts`: Modern JSX transform (`jsx`, `jsxs`, `Fragment`) +- `src/suspense.ts`: Streaming HTML with async components +- `src/error-boundary.ts`: Error handling for async trees + +**See:** [`packages/html/CLAUDE.md`](packages/html/CLAUDE.md) + +### @kitajs/ts-html-plugin + +TypeScript plugin for XSS detection. Key files: + +- `src/index.ts`: Language Service Plugin entry +- `src/cli.ts`: `xss-scan` CLI tool +- `src/util.ts`: Core detection algorithms +- `src/errors.ts`: Error codes (K601-K604) + +**See:** [`packages/ts-html-plugin/CLAUDE.md`](packages/ts-html-plugin/CLAUDE.md) + +### @kitajs/fastify-html-plugin + +Fastify integration. Key file: + +- `src/index.ts`: Plugin registration, `reply.html()`, Suspense streaming + +**See:** +[`packages/fastify-html-plugin/CLAUDE.md`](packages/fastify-html-plugin/CLAUDE.md) + +## Tech Stack + +| Tool | Purpose | +| ------------------- | ------------------------------------ | +| **pnpm** | Package manager (required) | +| **TypeScript 5.9+** | Language | +| **tsgo** | TypeScript compiler (native preview) | +| **Vitest** | Test runner | +| **c8/v8** | Code coverage | +| **Prettier** | Code formatting | +| **Husky** | Git hooks | +| **Changesets** | Version management | + +## Configuration + +### TypeScript (tsconfig.json) + +```json +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", + "plugins": [{ "name": "@kitajs/ts-html-plugin" }], + "strict": true, + "module": "CommonJS", + "target": "ESNext" + } +} +``` + +### VSCode Settings + +```json +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} +``` + +## Performance Patterns + +The codebase uses several optimization patterns: + +1. **Check Before Convert**: Regex test before expensive operations + + ```typescript + if (!CAMEL_REGEX.test(camel)) return camel; + ``` + +2. **Loop vs Regex**: Character loops faster than regex for replacements + + ```typescript + for (; end < length; end++) { + switch (value[end]) { ... } + } + ``` + +3. **Escape Once**: Escape entire result string, not individual pieces + +4. **Void Element Ordering**: Most common tags first in checks + +5. **Bun Detection**: Use native `Bun.escapeHTML` when available + +## Security Model + +### XSS Prevention Layers + +1. **Compile-Time**: `@kitajs/ts-html-plugin` catches unsafe usage +2. **CI/CD**: `xss-scan` CLI fails builds on XSS issues +3. **Runtime**: `safe` attribute escapes content + +### Safe Content Types + +- Numbers, booleans, bigints +- String literals +- `JSX.Element` (already rendered) +- `Html.Children` type +- Variables prefixed with `safe` +- `Html.escapeHtml()` calls + +### Unsafe Content Types + +- `string` (dynamic) +- `any` type +- Objects with `toString()` +- Variables prefixed with `unsafe` + +## Common Patterns + +### Component Definition + +```tsx +import type { PropsWithChildren } from '@kitajs/html'; + +function Card({ title, children }: PropsWithChildren<{ title: string }>) { + return ( +
+

{title}

+ {children} +
+ ); +} +``` + +### Async Component + +```tsx +async function UserProfile({ id }: { id: string }) { + const user = await db.getUser(id); + return
{user.name}
; +} +``` + +### Suspense Usage + +```tsx +function Page({ rid }: { rid: number }) { + return ( + } catch={(e) => }> + + + ); +} + +// With Fastify +app.get('/', (req, reply) => reply.html()); +``` + +### Conditional Classes + +```tsx +
+``` + +## Testing Guidelines + +1. **XSS Safety**: Always test with malicious input samples +2. **Async Handling**: Test both sync and async component paths +3. **Type Coverage**: Use `vitest --typecheck` for type tests +4. **Performance**: Run benchmarks for core changes + +## Contribution Workflow + +1. Fork and clone the repository +2. Install dependencies: `pnpm install` +3. Make changes +4. Format: `pnpm format` +5. Build: `pnpm build` +6. Test: `pnpm test` +7. Create changeset: `pnpm changeset` +8. Submit PR + +## Examples + +The `examples/` directory contains working examples: + +- `fastify-htmx.tsx`: Fastify + HTMX integration with Suspense +- `http-server.tsx`: Plain Node.js HTTP server with streaming + +Run examples: + +```bash +npx tsx examples/fastify-htmx.tsx +npx tsx examples/http-server.tsx +``` + +## Common Gotchas + +1. **Children NOT escaped by default** - Always use `safe` for user input +2. **`JSX.Element` is `string | Promise`** - Handle both cases +3. **Suspense needs `rid`** - Use request ID for concurrent safety +4. **Components need `Html.escapeHtml()`** - `safe` only works on native elements +5. **pnpm required** - npm/yarn will fail on install diff --git a/benchmarks/honojsx/index.tsx b/benchmarks/honojsx/index.tsx index cee3faf27..13b947a63 100644 --- a/benchmarks/honojsx/index.tsx +++ b/benchmarks/honojsx/index.tsx @@ -11,7 +11,7 @@ function Purchase({ name, price, quantity }) { function Layout({ children, head }) { return ( - {head} + {head} {children} ); @@ -19,14 +19,12 @@ function Layout({ children, head }) { function Head({ title }) { return ( -
+ + {title} - - - @@ -35,9 +33,11 @@ function Head({ title }) { - - -
+ + + @@ -35,9 +33,11 @@ function Head({ title }) { - - -
+ + + @@ -37,9 +35,11 @@ function Head({ title }) { + + - + @@ -35,9 +33,11 @@ function Head({ title }) { - - - + + + @@ -37,9 +35,11 @@ function Head({ title }) { - - - + + + @@ -35,9 +33,11 @@ function Head({ title }) { - - - + + + - - - - - - - - - - + + Real World Example + + + + + + + + + + + + + + - - - - - - - - - - - + + Real World Example + + + + + + + + + + + + + + - - - - - - - - - - - + + Real World Example + + + + + + + + + + + + + + - - - - + + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/Jsxte.html b/benchmarks/runner/samples/Jsxte.html index b20e363b1..bc78c39ae 100644 --- a/benchmarks/runner/samples/Jsxte.html +++ b/benchmarks/runner/samples/Jsxte.html @@ -1,28 +1,22 @@ -
- Real World Example - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/KitaJs.html b/benchmarks/runner/samples/KitaJs.html index 1c851f754..794445801 100644 --- a/benchmarks/runner/samples/KitaJs.html +++ b/benchmarks/runner/samples/KitaJs.html @@ -1,28 +1,22 @@ -
- Real World Example - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/Preact.html b/benchmarks/runner/samples/Preact.html index 1c851f754..794445801 100644 --- a/benchmarks/runner/samples/Preact.html +++ b/benchmarks/runner/samples/Preact.html @@ -1,28 +1,22 @@ -
- Real World Example - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/React.html b/benchmarks/runner/samples/React.html index 1c851f754..794445801 100644 --- a/benchmarks/runner/samples/React.html +++ b/benchmarks/runner/samples/React.html @@ -1,28 +1,22 @@ -
- Real World Example - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/ReactJsx.html b/benchmarks/runner/samples/ReactJsx.html index 1c851f754..794445801 100644 --- a/benchmarks/runner/samples/ReactJsx.html +++ b/benchmarks/runner/samples/ReactJsx.html @@ -1,28 +1,22 @@ -
- Real World Example - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/TypedHtml.html b/benchmarks/runner/samples/TypedHtml.html index 8ad8a2c7c..9812e56fa 100644 --- a/benchmarks/runner/samples/TypedHtml.html +++ b/benchmarks/runner/samples/TypedHtml.html @@ -1,24 +1,22 @@ -
- Real World Example - - - - - - - - - - - - - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/runner/samples/vHtml.html b/benchmarks/runner/samples/vHtml.html index b20e363b1..bc78c39ae 100644 --- a/benchmarks/runner/samples/vHtml.html +++ b/benchmarks/runner/samples/vHtml.html @@ -1,28 +1,22 @@ -
- Real World Example - - - - -
+ + Real World Example + + + + + + + + + + + + + + +
diff --git a/benchmarks/templates/ghtml.tsx b/benchmarks/templates/ghtml.ts similarity index 90% rename from benchmarks/templates/ghtml.tsx rename to benchmarks/templates/ghtml.ts index e4fb035ae..61c160c03 100644 --- a/benchmarks/templates/ghtml.tsx +++ b/benchmarks/templates/ghtml.ts @@ -11,9 +11,7 @@ function Purchase(html: Function, { name, price, quantity }) { function Layout(html: Function, { children, head }: any) { return html` - - !${head} - + !${head} !${children} @@ -22,15 +20,13 @@ function Layout(html: Function, { children, head }: any) { } function Head(html: Function, { title }) { - return html` -
+ return html /* html */ ` + + ${title} - - - @@ -39,14 +35,16 @@ function Head(html: Function, { title }) { - - -
+ + + @@ -39,14 +35,16 @@ function Head(html: Function, { title }) { - - -
+ + + @@ -37,9 +35,11 @@ function Head({ title }) { - - -
+ + + @@ -37,9 +35,11 @@ function Head({ title }) { - - -
+ + + + + + + + + + {children} + + + ) + ); +} diff --git a/examples/fastify-htmx/src/templates/components.tsx b/examples/fastify-htmx/src/templates/components.tsx new file mode 100644 index 000000000..1af8ddc14 --- /dev/null +++ b/examples/fastify-htmx/src/templates/components.tsx @@ -0,0 +1,200 @@ +import type { PropsWithChildren } from '@kitajs/html'; + +// Card component +interface CardProps { + title?: string; + icon?: string; +} + +export function Card({ title, icon, children }: PropsWithChildren) { + return ( +
+ {(title || icon) && ( +
+ {icon && ( +
+ {icon} +
+ )} + {title && ( +

+ {title} +

+ )} +
+ )} + {children} +
+ ); +} + +// Stat card +interface StatCardProps { + label: string; + value: string; + change?: string; + positive?: boolean; +} + +export function StatCard({ label, value, change, positive }: StatCardProps) { + return ( +
+
+ {label} +
+
+ {value} +
+ {change && ( +
+ {change} +
+ )} +
+ ); +} + +// Stat skeleton +export function StatSkeleton() { + return ( +
+
+
+
+
+ ); +} + +// List item +interface ListItemProps { + primary: string; + secondary?: string; + badge?: string; + badgeColor?: string; +} + +export function ListItem({ + primary, + secondary, + badge, + badgeColor = 'bg-kita-500/20 text-kita-300' +}: ListItemProps) { + return ( +
+
+
+ {primary} +
+ {secondary && ( +
+ {secondary} +
+ )} +
+ {badge && ( + + {badge} + + )} +
+ ); +} + +// Button +interface ButtonProps { + variant?: 'primary' | 'secondary'; + size?: 'sm' | 'md'; +} + +export function Button({ + variant = 'primary', + size = 'md', + children +}: PropsWithChildren) { + const baseClass = + 'rounded-lg font-medium transition-colors inline-flex items-center gap-2'; + const variantClass = + variant === 'primary' + ? 'bg-kita-500 hover:bg-kita-600 text-white' + : 'bg-stone-800 hover:bg-stone-700 text-stone-200'; + const sizeClass = size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-4 py-2 text-sm'; + + return ; +} + +// Loading spinner +export function Spinner({ size = 'sm' }: { size?: 'sm' | 'md' }) { + const sizeClass = size === 'sm' ? 'w-4 h-4' : 'w-6 h-6'; + return ( + + + + + ); +} + +// Progress bar +interface ProgressProps { + value: number; + label: string; +} + +export function Progress({ value, label }: ProgressProps) { + return ( +
+
+ + {label} + + {value}% +
+
+
+
+
+ ); +} + +// Toast notification +interface ToastProps { + message: string; + type?: 'success' | 'error' | 'info'; +} + +export function Toast({ message, type = 'info' }: ToastProps) { + const colorClass = { + success: 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300', + error: 'bg-red-500/20 border-red-500/50 text-red-300', + info: 'bg-kita-500/20 border-kita-500/50 text-kita-300' + }[type]; + + return ( +
+ {message} +
+ ); +} diff --git a/examples/fastify-htmx/src/templates/pages/Dashboard.tsx b/examples/fastify-htmx/src/templates/pages/Dashboard.tsx new file mode 100644 index 000000000..81b66840b --- /dev/null +++ b/examples/fastify-htmx/src/templates/pages/Dashboard.tsx @@ -0,0 +1,205 @@ +import { TodoItem } from '../../api/todos'; +import { store } from '../../store'; +import { Card, Progress, Spinner, StatSkeleton } from '../components'; +import { Layout } from '../Layout'; + +export function Dashboard() { + return ( + +
+ {/* Header */} +
+
+ KitaJS Logo +
+

+ KitaJS Html + HTMX +

+

Interactive Server-Side Rendering

+
+
+
+ + GitHub + +
+ No page reloads +
+
+
+ + {/* Stats Row - Load on page load with hx-trigger="load" */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {/* Main Content */} +
+ {/* Click Counter */} + +
+
+ {store.clickCount} +
+ +
+
+ + {/* Todo List */} + +
+ + +
+
+ {store.todos.map((todo) => ( + + ))} +
+
+ + {/* Server Time */} + +
+
+ Loading... +
+

+ Updates every second via HTMX polling +

+
+
+
+ + {/* Bottom Section */} +
+ {/* System Metrics */} + +
+
+ +
+
+
+ Refreshing... +
+
+ + {/* Notifications */} + +
+

Click to trigger notifications

+
+
+ + + +
+
+
+ + {/* Footer */} +
+

+ Each component uses HTMX for seamless server interactions without JavaScript +

+

+ Built with{' '} + + KitaJS + {' '} + +{' '} + + HTMX + {' '} + +{' '} + + Fastify + +

+
+
+
+ ); +} diff --git a/examples/fastify-htmx/tsconfig.json b/examples/fastify-htmx/tsconfig.json new file mode 100644 index 000000000..9290e94d3 --- /dev/null +++ b/examples/fastify-htmx/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", + "plugins": [{ "name": "@kitajs/ts-html-plugin" }] + }, + "include": ["src"] +} diff --git a/examples/http-server.tsx b/examples/http-server.tsx deleted file mode 100644 index b259d57c8..000000000 --- a/examples/http-server.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import Html, { type PropsWithChildren } from '@kitajs/html'; -import { Suspense, renderToStream } from '@kitajs/html/suspense'; -import http from 'node:http'; -import { setTimeout } from 'node:timers/promises'; - -async function SleepForMs({ ms, children }: PropsWithChildren<{ ms: number }>) { - await setTimeout(ms * 2); - return Html.contentsToString([children || String(ms)]); -} - -function renderLayout(rid: number | string) { - return ( - -
- {Array.from({ length: 5 }, (_, i) => ( - {i} Fallback Outer!
}> -
Outer {i}!
- - - {i} Fallback Inner!
}> - -
Inner {i}!
-
- - - - ))} -
- - ); -} - -http - .createServer((req, response) => { - // This simple webserver only has a index.html file - if (req.url !== '/' && req.url !== '/index.html') { - response.end(); - return; - } - - // ⚠️ Charset utf8 is important to avoid old browsers utf7 xss attacks - response.setHeader('Content-Type', 'text/html; charset=utf-8'); - - // Creates the html stream - const htmlStream = renderToStream(renderLayout); - - // Pipes it into the response - htmlStream.pipe(response); - - // If it's a fastify server just use - // response.type('text/html; charset=utf-8').send(htmlStream); - }) - .listen(8080, () => { - console.log('Listening to http://localhost:8080'); - }); diff --git a/examples/http-server/package.json b/examples/http-server/package.json new file mode 100644 index 000000000..7d61ed63b --- /dev/null +++ b/examples/http-server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@kitajs/example-http-server", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "tsx --watch src/server.ts", + "test": "xss-scan" + }, + "dependencies": { + "@kitajs/html": "workspace:*", + "@kitajs/ts-html-plugin": "workspace:*", + "@types/node": "catalog:", + "tslib": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/http-server/src/server.ts b/examples/http-server/src/server.ts new file mode 100644 index 000000000..e879f5b6d --- /dev/null +++ b/examples/http-server/src/server.ts @@ -0,0 +1,29 @@ +import { renderToStream } from '@kitajs/html/suspense'; +import http from 'node:http'; +import { Dashboard } from './templates/pages/Dashboard'; + +const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host}`); + + // Only serve the index page + if (url.pathname !== '/' && url.pathname !== '/index.html') { + res.statusCode = 404; + res.end('Not Found'); + return; + } + + // Set proper content type with charset to prevent UTF-7 XSS attacks + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Transfer-Encoding', 'chunked'); + + // Create the HTML stream from the page renderer + const htmlStream = renderToStream(Dashboard); + + // Pipe the stream to the response + htmlStream.pipe(res); +}); + +const port = Number(process.env.PORT) || 32012; +server.listen(port, () => { + console.log(`🚀 Server running at http://localhost:${port}`); +}); diff --git a/examples/http-server/src/templates/Layout.tsx b/examples/http-server/src/templates/Layout.tsx new file mode 100644 index 000000000..3670bea2a --- /dev/null +++ b/examples/http-server/src/templates/Layout.tsx @@ -0,0 +1,63 @@ +import type { PropsWithChildren } from '@kitajs/html'; + +interface LayoutProps { + title: string; +} + +export function Layout({ title, children }: PropsWithChildren) { + return ( + '' + + ( + + + + + + {title} + + + + + + + {children} + + + ) + ); +} diff --git a/examples/http-server/src/templates/components.tsx b/examples/http-server/src/templates/components.tsx new file mode 100644 index 000000000..eba631a8e --- /dev/null +++ b/examples/http-server/src/templates/components.tsx @@ -0,0 +1,182 @@ +import type { PropsWithChildren } from '@kitajs/html'; + +// Card component +interface CardProps { + title?: string; + icon?: string; +} + +export function Card({ title, icon, children }: PropsWithChildren) { + return ( +
+ {(title || icon) && ( +
+ {icon && ( +
+ {icon} +
+ )} + {title && ( +

+ {title} +

+ )} +
+ )} + {children} +
+ ); +} + +// Card skeleton +export function CardSkeleton({ height = 'h-32' }: { height?: string }) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +// Stat card +interface StatCardProps { + label: string; + value: string; + change?: string; + positive?: boolean; +} + +export function StatCard({ label, value, change, positive }: StatCardProps) { + return ( +
+
+ {label} +
+
+ {value} +
+ {change && ( +
+ {change} +
+ )} +
+ ); +} + +// Stat skeleton +export function StatSkeleton() { + return ( +
+
+
+
+
+ ); +} + +// List item +interface ListItemProps { + primary: string; + secondary?: string; + badge?: string; + badgeColor?: string; +} + +export function ListItem({ + primary, + secondary, + badge, + badgeColor = 'bg-kita-500/20 text-kita-300' +}: ListItemProps) { + return ( +
+
+
+ {primary} +
+ {secondary && ( +
+ {secondary} +
+ )} +
+ {badge && ( + + {badge} + + )} +
+ ); +} + +// List skeleton +export function ListSkeleton({ items = 3 }: { items?: number }) { + return ( +
+ {Array.from({ length: items }).map(() => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +// Progress bar +interface ProgressProps { + value: number; + label: string; +} + +export function Progress({ value, label }: ProgressProps) { + return ( +
+
+ + {label} + + {value}% +
+
+
+
+
+ ); +} + +// Progress skeleton +export function ProgressSkeleton({ items = 3 }: { items?: number }) { + return ( +
+ {Array.from({ length: items }).map(() => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/examples/http-server/src/templates/pages/Dashboard.tsx b/examples/http-server/src/templates/pages/Dashboard.tsx new file mode 100644 index 000000000..95e37a54a --- /dev/null +++ b/examples/http-server/src/templates/pages/Dashboard.tsx @@ -0,0 +1,213 @@ +import { Suspense } from '@kitajs/html/suspense'; +import { setTimeout } from 'node:timers/promises'; +import { + Card, + ListItem, + ListSkeleton, + Progress, + ProgressSkeleton, + StatCard, + StatSkeleton +} from '../components'; +import { Layout } from '../Layout'; + +// Random delay helper (base +/- variance) +const randomDelay = (base: number, variance = 300) => + base + Math.floor(Math.random() * variance * 2) - variance; + +// Individual stats with randomized timing +async function fetchRevenue() { + await setTimeout(randomDelay(400, 200)); + return { label: 'Revenue', value: '$12.4k', change: '+14%', positive: true }; +} + +async function fetchUsers() { + await setTimeout(randomDelay(500, 250)); + return { label: 'Users', value: '1,429', change: '+7%', positive: true }; +} + +async function fetchOrders() { + await setTimeout(randomDelay(600, 300)); + return { label: 'Orders', value: '284', change: '-3%', positive: false }; +} + +async function fetchConversion() { + await setTimeout(randomDelay(700, 350)); + return { label: 'Conversion', value: '3.2%', change: '+0.8%', positive: true }; +} + +// Grouped data fetching with randomized timing +async function fetchRecentActivity() { + await setTimeout(randomDelay(1400, 400)); + return [ + { + primary: 'New order #1234', + secondary: '2 min ago', + badge: '$299', + badgeColor: 'bg-emerald-500/20 text-emerald-400' + }, + { + primary: 'User signup', + secondary: '5 min ago', + badge: 'New', + badgeColor: 'bg-kita-500/20 text-kita-300' + }, + { + primary: 'Payment received', + secondary: '12 min ago', + badge: '$149', + badgeColor: 'bg-emerald-500/20 text-emerald-400' + } + ]; +} + +async function fetchSystemStatus() { + await setTimeout(randomDelay(1600, 400)); + return [ + { label: 'CPU', value: 45 }, + { label: 'Memory', value: 72 }, + { label: 'Storage', value: 28 } + ]; +} + +// Individual stat components +async function RevenueStat() { + const data = await fetchRevenue(); + return ; +} + +async function UsersStat() { + const data = await fetchUsers(); + return ; +} + +async function OrdersStat() { + const data = await fetchOrders(); + return ; +} + +async function ConversionStat() { + const data = await fetchConversion(); + return ; +} + +// Grouped section components +async function ActivitySection() { + const items = await fetchRecentActivity(); + return ( + + {items.map((item) => ( + + ))} + + ); +} + +async function SystemSection() { + const metrics = await fetchSystemStatus(); + return ( + + {metrics.map((m) => ( + + ))} + + ); +} + +// Main Dashboard +export function Dashboard(rid: number | string) { + return ( + +
+ {/* Header */} +
+
+ KitaJS Logo +
+

+ KitaJS Html +

+

Suspense Streaming Demo

+
+
+
+ + GitHub + +
+ Refresh to replay +
+
+
+ + {/* Stats Row - Individual Suspense for each */} +
+ }> + + + }> + + + }> + + + }> + + +
+ + {/* Content Grid - Grouped Suspense */} +
+ + + + } + > + + + + + + + } + > + + +
+ + {/* Footer */} +
+

+ Each component loads independently with randomized timing - refresh to see + different patterns +

+

+ Built with{' '} + + KitaJS + {' '} + +{' '} + + Node.js + +

+
+
+
+ ); +} diff --git a/examples/http-server/tsconfig.json b/examples/http-server/tsconfig.json new file mode 100644 index 000000000..9290e94d3 --- /dev/null +++ b/examples/http-server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", + "plugins": [{ "name": "@kitajs/ts-html-plugin" }] + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 7a64400f7..45018b8ae 100644 --- a/package.json +++ b/package.json @@ -13,32 +13,34 @@ "license": "MIT", "author": "Arthur Fiorette ", "scripts": { - "bench": "pnpm -r --filter \"@kitajs/bench-html-*\" build && pnpm --filter \"@kitajs/bench-html-runner\" start", - "build": "pnpm -r --aggregate-output build", - "change": "changeset", + "bench": "turbo run benchmark", + "build": "turbo run build --filter=\"./packages/*\"", + "build-all": "turbo run build", + "changeset": "changeset", "ci-publish": "pnpm publish -r --access public && changeset tag", "ci-version": "changeset version && pnpm install --no-frozen-lockfile", "format": "prettier --write .", "preinstall": "npx only-allow pnpm", "prepare": "husky", - "test": "pnpm -r --no-bail --aggregate-output --parallel test", - "watch": "pnpm -r --parallel --no-bail build --watch" + "test": "turbo run test" }, "devDependencies": { "@arthurfiorette/prettier-config": "^1.0.12", - "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.29.7", + "@changesets/changelog-github": "^0.5.2", + "@changesets/cli": "^2.29.8", "@kitajs/html": "workspace:*", "husky": "^9.1.7", - "prettier": "^3.6.2", - "prettier-plugin-jsdoc": "^1.3.3", - "prettier-plugin-organize-imports": "^4.2.0", - "prettier-plugin-packagejson": "^2.5.19", - "typescript": "^5.9.2" + "prettier": "^3.8.1", + "prettier-plugin-jsdoc": "^1.8.0", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-packagejson": "^3.0.0", + "self-ts-plugin": "link:packages/ts-html-plugin", + "turbo": "^2.7.6", + "typescript": "catalog:" }, - "packageManager": "pnpm@10.15.1", + "packageManager": "pnpm@10.28.1", "engines": { "node": ">=20.13", - "pnpm": ">=9" + "pnpm": ">=10" } } diff --git a/packages/fastify-html-plugin/CLAUDE.md b/packages/fastify-html-plugin/CLAUDE.md new file mode 100644 index 000000000..73a9128a6 --- /dev/null +++ b/packages/fastify-html-plugin/CLAUDE.md @@ -0,0 +1,262 @@ +# @kitajs/fastify-html-plugin - Developer Guide + +## Overview + +`@kitajs/fastify-html-plugin` is a Fastify plugin that integrates `@kitajs/html` with +Fastify, providing a seamless `reply.html()` method for rendering JSX components. It +supports both synchronous and asynchronous components, including Suspense streaming. + +## Architecture + +### Single File Design + +The entire plugin is contained in a single file (`src/index.ts`) as the integration is +straightforward: + +``` +src/ +└── index.ts # Plugin registration, reply decorator, Suspense integration +``` + +### Key Components + +#### Plugin Registration + +```typescript +const plugin: FastifyPluginCallback> = function ( + fastify, + opts, + next +) { + fastify.decorateReply(kAutoDoctype, opts.autoDoctype ?? true); + fastify.decorateReply('html', html); + return next(); +}; +``` + +The plugin: + +1. Decorates replies with `kAutoDoctype` symbol (configurable) +2. Adds the `html()` method to reply instances +3. Uses `fastify-plugin` for proper encapsulation + +#### The `html()` Method + +```typescript +function html( + this: FastifyReply, + htmlStr: H +): H extends Promise ? Promise : void { + if (typeof htmlStr === 'string') { + return handleHtml(htmlStr, this); + } + return handleAsyncHtml(htmlStr, this); +} +``` + +Handles both sync and async JSX elements with proper TypeScript inference. + +#### HTML Processing + +```typescript +function handleHtml(htmlStr: string, reply: R): R { + // 1. Auto-prepend doctype for tags + if (reply[kAutoDoctype] && isTagHtml(htmlStr)) { + htmlStr = `${htmlStr}`; + } + + // 2. Set content type + reply.type('text/html; charset=utf-8'); + + // 3. Check for Suspense usage + const requestData = SUSPENSE_ROOT.requests.get(reply.request.id); + + if (requestData === undefined) { + // No Suspense - send as regular response with Content-Length + return reply + .header('content-length', Buffer.byteLength(htmlStr, 'utf-8')) + .send(htmlStr); + } + + // Suspense detected - stream the response + return reply.send(resolveHtmlStream(htmlStr, requestData)); +} +``` + +### Suspense Integration + +The plugin automatically detects Suspense usage via the global `SUSPENSE_ROOT`: + +1. When `` is used, it registers in `SUSPENSE_ROOT.requests` +2. The plugin checks for this registration using `reply.request.id` +3. If found, it streams the response using `resolveHtmlStream()` +4. No manual stream handling required by the user + +### Configuration + +```typescript +export interface FastifyKitaHtmlOptions { + /** + * Auto-prepend to responses starting with + * + * @default true + */ + autoDoctype: boolean; +} +``` + +The `kAutoDoctype` symbol is exposed so users can disable auto-doctype per-request: + +```typescript +reply[kAutoDoctype] = false; +reply.html(...); // No doctype added +``` + +## Module Augmentation + +The plugin extends Fastify's types: + +```typescript +declare module 'fastify' { + interface FastifyReply { + [kAutoDoctype]: boolean; + html( + this: this, + html: H + ): H extends Promise ? Promise : void; + } +} +``` + +## Export Patterns + +Multiple export styles are supported for maximum compatibility: + +```typescript +// Named export +export const fastifyKitaHtml = Object.assign(plugin_, { kAutoDoctype }); + +// Default export +export default fastifyKitaHtml; + +// Usage examples: +// const fastifyKitaHtml = require('@kitajs/fastify-html-plugin') +// const { fastifyKitaHtml } = require('@kitajs/fastify-html-plugin') +// import fastifyKitaHtml from '@kitajs/fastify-html-plugin' +// import { fastifyKitaHtml } from '@kitajs/fastify-html-plugin' +``` + +## Development + +### Building + +```bash +pnpm build # Compiles TypeScript with tsgo +``` + +### Testing + +```bash +pnpm test # Runs vitest with coverage and type checking +``` + +### Dependencies + +- **`fastify-plugin`**: Enables proper plugin encapsulation +- **Peer deps**: `@kitajs/html`, optionally `@kitajs/ts-html-plugin` + +## Usage Patterns + +### Basic Usage + +```typescript +import fastifyKitaHtml from '@kitajs/fastify-html-plugin'; + +app.register(fastifyKitaHtml); + +app.get('/', (req, reply) => { + reply.html( + + Hello World + + ); +}); +``` + +### With Suspense + +```typescript +app.get('/stream', (req, reply) => { + reply.html( + Loading...
}> + + + ); +}); +``` + +Key point: Use `req.id` as the Suspense `rid` - this is how the plugin matches the request +to its Suspense data. + +### Async Components + +```typescript +app.get('/async', async (req, reply) => { + // Await is optional - html() handles promises + reply.html(); +}); +``` + +### Disabling Auto-Doctype + +```typescript +// Per-request +app.get('/fragment', (req, reply) => { + reply[kAutoDoctype] = false; + reply.html(
Just a fragment
); +}); + +// Globally +app.register(fastifyKitaHtml, { autoDoctype: false }); +``` + +## Key Patterns + +### HTML Tag Detection + +```typescript +const isTagHtml = RegExp.prototype.test.bind(/^\s*, reply: FastifyReply) { + return handleHtml(await promise, reply); +} +``` + +This pattern allows V8 to optimize the sync path without async function overhead. + +### Content-Length Handling + +```typescript +// Without Suspense: set Content-Length for proper HTTP +reply.header('content-length', Buffer.byteLength(htmlStr, 'utf-8')); + +// With Suspense: omit Content-Length (chunked transfer) +// Per RFC 7230 section 3.3.3, connection is closed after response +``` + +## Common Gotchas + +1. **Suspense requires `req.id`**: Must use `rid={req.id}` for Suspense to work +2. **Auto-doctype only for `` tags**: Fragments won't get doctype +3. **Charset is important**: `text/html; charset=utf-8` prevents UTF-7 XSS attacks +4. **Fastify version**: Works with Fastify 4.x and 5.x diff --git a/packages/fastify-html-plugin/LICENSE b/packages/fastify-html-plugin/LICENSE deleted file mode 100644 index 5b296b873..000000000 --- a/packages/fastify-html-plugin/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023-present Kita - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/packages/fastify-html-plugin/LICENSE b/packages/fastify-html-plugin/LICENSE new file mode 120000 index 000000000..30cff7403 --- /dev/null +++ b/packages/fastify-html-plugin/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/fastify-html-plugin/README.md b/packages/fastify-html-plugin/README.md index d2c5ce740..c9d26b4b2 100644 --- a/packages/fastify-html-plugin/README.md +++ b/packages/fastify-html-plugin/README.md @@ -41,11 +41,12 @@ - [Installing](#installing) - [Preview](#preview) -- [Installing](#installing-1) - [Configuration](#configuration) + - [Disabling Auto-Doctype Per Request](#disabling-auto-doctype-per-request) - [Documentation](#documentation) - [API](#api) - [`reply.html()`](#replyhtml) +- [Compatibility](#compatibility) - [License](#license)
@@ -53,25 +54,14 @@ ## Installing +> [!CAUTION] You **must have followed the `@kitajs/html`'s +> [Installing](https://github.com/kitajs/html/tree/master/packages/html#installing) +> guide** before continuing, otherwise you will be vulnerable to XSS attacks. + ```sh npm install @kitajs/fastify-html-plugin ``` -
- -## Preview - -Example of an error thrown by this LSP plugin. - -
- -## Installing - -> [!CAUTION] -> You **must have followed the `@kitajs/html`'s -> [Installing](https://github.com/kitajs/html/tree/master/packages/html#installing) -> guide** before continuing, otherwise you will be vulnerable to XSS attacks. - ```ts import kitaHtmlPlugin from '@kitajs/fastify-html-plugin'; import fastify from 'fastify'; @@ -81,6 +71,12 @@ const app = fastify(); app.register(kitaHtmlPlugin); ``` +
+ +## Preview + +Preview of using @kitajs/fastify-html-plugin with JSX in Fastify. + ## Configuration Every option is well documented through their respective JSDoc comments, below are the @@ -90,6 +86,20 @@ default options. | ------------- | -------------------------------------------------------------------------------------------------- | ------- | | `autoDoctype` | Whether to automatically add `` to a response starting with ``, if not found. | `true` | +### Disabling Auto-Doctype Per Request + +You can disable the auto-doctype feature per request using the exported `kAutoDoctype` +symbol: + +```ts +import { kAutoDoctype } from '@kitajs/fastify-html-plugin'; + +app.get('/fragment', (req, reply) => { + reply[kAutoDoctype] = false; + return reply.html(
Just a fragment, no doctype needed
); +}); +``` +
## Documentation @@ -149,8 +159,14 @@ app
+## Compatibility + +This plugin is compatible with **Fastify 4.x** and **Fastify 5.x**. + +
+ ## License -Licensed under the **MIT**. See [`LICENSE`](LICENSE) for more informations. +Licensed under the **MIT**. See [`LICENSE`](LICENSE) for more information.
diff --git a/packages/fastify-html-plugin/index.js b/packages/fastify-html-plugin/index.js deleted file mode 100644 index a2ac21d42..000000000 --- a/packages/fastify-html-plugin/index.js +++ /dev/null @@ -1,102 +0,0 @@ -/// - -const fp = require('fastify-plugin'); -const { resolveHtmlStream } = require('@kitajs/html/suspense'); - -/** @type {import('./types/index').kAutoDoctype} */ -const kAutoDoctype = Symbol.for('fastify-kita-html.autoDoctype'); - -/** - * Returns true if the string starts with `} - */ -function plugin(fastify, opts, next) { - fastify.decorateReply(kAutoDoctype, opts.autoDoctype ?? true); - fastify.decorateReply('html', html); - return next(); -} - -/** @type {import('fastify').FastifyReply['html']} */ -function html(htmlStr) { - if (typeof htmlStr === 'string') { - // @ts-expect-error - generics break the type inference here - return handleHtml(htmlStr, this); - } - - // @ts-expect-error - generics break the type inference here - return handleAsyncHtml(htmlStr, this); -} - -/** - * Simple helper that can be optimized by the JS engine to avoid having async await in the - * main flow - * - * @template {import('fastify').FastifyReply} R - * @param {Promise} promise - * @param {R} reply - * @returns {Promise} - */ -async function handleAsyncHtml(promise, reply) { - return handleHtml(await promise, reply); -} - -/** - * @template {import('fastify').FastifyReply} R - * @param {string} htmlStr - * @param {R} reply - */ -function handleHtml(htmlStr, reply) { - // @ts-expect-error - prepends doctype if the html is a full html document - if (reply[kAutoDoctype] && isTagHtml(htmlStr)) { - htmlStr = `${htmlStr}`; - } - - reply.type('text/html; charset=utf-8'); - - // If no suspense component was used, this will not be defined. - const requestData = SUSPENSE_ROOT.requests.get(reply.request.id); - - if (requestData === undefined) { - return reply - .header('content-length', Buffer.byteLength(htmlStr, 'utf-8')) - .send(htmlStr); - } - - // Content-length is optional as long as the connection is closed after the response is done - // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.3 - return reply.send( - // htmlStr might resolve after one of its suspense components - resolveHtmlStream(htmlStr, requestData) - ); -} - -const fastifyKitaHtml = fp(plugin, { - fastify: '4.x || 5.x', - name: '@kitajs/fastify-html-plugin' -}); - -/** - * These export configurations enable JS and TS developers to consume - * - * @kitajs/fastify-html-plugin in whatever way best suits their needs. Some examples of - * supported import syntax includes: - * - * - `const fastifyKitaHtml = require('@kitajs/fastify-html-plugin')` - * - `const { fastifyKitaHtml } = require('@kitajs/fastify-html-plugin')` - * - `import * as fastifyKitaHtml from '@kitajs/fastify-html-plugin'` - * - `import { fastifyKitaHtml } from '@kitajs/fastify-html-plugin'` - * - `import fastifyKitaHtml from '@kitajs/fastify-html-plugin'` - */ -module.exports = fastifyKitaHtml; -module.exports.default = fastifyKitaHtml; // supersedes fastifyKitaHtml.default = fastifyKitaHtml -module.exports.fastifyKitaHtml = fastifyKitaHtml; // supersedes fastifyKitaHtml.fastifyKitaHtml = fastifyKitaHtml -module.exports.kAutoDoctype = kAutoDoctype; diff --git a/packages/fastify-html-plugin/package.json b/packages/fastify-html-plugin/package.json index c97105388..b7e70a824 100644 --- a/packages/fastify-html-plugin/package.json +++ b/packages/fastify-html-plugin/package.json @@ -13,30 +13,36 @@ "license": "MIT", "author": "Arthur Fiorette ", "type": "commonjs", - "main": "index.js", - "types": "types/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src", + "!dist/*.tsbuildinfo" + ], "scripts": { - "test": "tsd && c8 --reporter lcov --reporter text node -r @swc-node/register --test test/**/*.test.tsx", - "test:watch": "c8 --reporter lcov --reporter text node -r @swc-node/register --test --watch test/**/*.test.tsx" + "build": "tsgo -p tsconfig.build.json", + "test": "vitest --coverage --typecheck --run" }, "dependencies": { - "fastify-plugin": "^5.0.1" + "fastify-plugin": "catalog:" }, "devDependencies": { - "@fastify/formbody": "^8.0.2", - "@swc-node/register": "^1.11.1", - "@swc/helpers": "^0.5.17", - "@types/jsdom": "^21.1.7", - "@types/node": "^24.5.0", - "c8": "^10.1.3", - "fastify": "^5.6.0", - "jsdom": "^27.0.0", - "tsd": "^0.33.0", - "tslib": "^2.8.1" + "@fastify/formbody": "catalog:", + "@kitajs/html": "workspace:^", + "@types/jsdom": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", + "fastify": "catalog:", + "jsdom": "catalog:", + "tslib": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" }, "peerDependencies": { - "@kitajs/html": "workspace:^4.2.10", - "@kitajs/ts-html-plugin": "workspace:^4.1.3" + "@kitajs/html": "workspace:^", + "@kitajs/ts-html-plugin": "workspace:^" }, "peerDependenciesMeta": { "@kitajs/ts-html-plugin": { diff --git a/packages/fastify-html-plugin/src/index.ts b/packages/fastify-html-plugin/src/index.ts new file mode 100644 index 000000000..6bcba75c4 --- /dev/null +++ b/packages/fastify-html-plugin/src/index.ts @@ -0,0 +1,153 @@ +import { resolveHtmlStream } from '@kitajs/html/suspense'; +import type { FastifyPluginCallback, FastifyReply } from 'fastify'; +import fp from 'fastify-plugin'; + +/** Options for @kitajs/fastify-html-plugin plugin. */ +export interface FastifyKitaHtmlOptions { + /** + * Whether to automatically add `` to a response starting with , if + * not found. + * + * ```tsx + * // With autoDoctype: true you can just return the html + * app.get('/', () => ) + * + * // With autoDoctype: false you must use rep.html + * app.get('/', (req, rep) => rep.html() + * ``` + * + * @default true + */ + autoDoctype: boolean; +} + +/** + * This gets assigned to every reply instance. You can manually change this value to + * `false` if you want to "hand pick" when or when not to add the doctype. + */ +export const kAutoDoctype = Symbol.for('fastify-kita-html.autoDoctype'); + +/** Returns true if the string starts with `>> = + function (fastify, opts, next) { + fastify.decorateReply(kAutoDoctype, opts.autoDoctype ?? true); + fastify.decorateReply('html', html); + return next(); + }; + +function html( + this: FastifyReply, + htmlStr: H +): H extends Promise ? Promise : void { + if (typeof htmlStr === 'string') { + // @ts-expect-error - generics break the type inference here + return handleHtml(htmlStr, this); + } + + // @ts-expect-error - generics break the type inference here + return handleAsyncHtml(htmlStr, this); +} + +/** + * Simple helper that can be optimized by the JS engine to avoid having async await in the + * main flow + */ +async function handleAsyncHtml( + promise: Promise, + reply: R +): Promise { + return handleHtml(await promise, reply); +} + +function handleHtml(htmlStr: string, reply: R): R { + // Prepends doctype if the html is a full html document + if (reply[kAutoDoctype] && isTagHtml(htmlStr)) { + htmlStr = `${htmlStr}`; + } + + reply.type('text/html; charset=utf-8'); + + // If no suspense component was used, this will not be defined. + const requestData = SUSPENSE_ROOT.requests.get(reply.request.id); + + if (requestData === undefined) { + return reply + .header('content-length', Buffer.byteLength(htmlStr, 'utf-8')) + .send(htmlStr) as R; + } + + // Content-length is optional as long as the connection is closed after the response is done + // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.3 + return reply.send( + // htmlStr might resolve after one of its suspense components + resolveHtmlStream(htmlStr, requestData) + ) as R; +} + +const plugin_ = fp(plugin, { + fastify: '4.x || 5.x', + name: '@kitajs/fastify-html-plugin' +}); + +export const fastifyKitaHtml = Object.assign(plugin_, { kAutoDoctype }); + +/** + * These export configurations enable JS and TS developers to consume + * + * @kitajs/fastify-html-plugin in whatever way best suits their needs. Some examples of + * supported import syntax includes: + * + * - `const fastifyKitaHtml = require('@kitajs/fastify-html-plugin')` + * - `const { fastifyKitaHtml } = require('@kitajs/fastify-html-plugin')` + * - `import * as fastifyKitaHtml from '@kitajs/fastify-html-plugin'` + * - `import { fastifyKitaHtml } from '@kitajs/fastify-html-plugin'` + * - `import fastifyKitaHtml from '@kitajs/fastify-html-plugin'` + */ +export default fastifyKitaHtml; + +// Module augmentation for FastifyReply +declare module 'fastify' { + interface FastifyReply { + /** + * This gets assigned to every reply instance. You can manually change this value to + * `false` if you want to "hand pick" when or when not to add the doctype. + */ + [kAutoDoctype]: boolean; + + /** + * **Synchronously** waits for the component tree to resolve and sends it at once to + * the browser. + * + * This method does not support the usage of ``, please use + * {@linkcode streamHtml} instead. + * + * If the HTML does not start with a doctype and `opts.autoDoctype` is enabled, it + * will be added automatically. + * + * The correct `Content-Type` header will also be defined. + * + * @example + * + * ```tsx + * app.get('/', (req, reply) => + * reply.html( + * + * + *

Hello, world!

+ * + * + * ) + * ); + * ``` + * + * @param html The HTML to send. + * @returns The response. + */ + html( + this: this, + html: H + ): H extends Promise ? Promise : void; + } +} diff --git a/packages/fastify-html-plugin/test/auto-detect.test.tsx b/packages/fastify-html-plugin/test/auto-detect.test.tsx index a540d32a6..e0412a61d 100644 --- a/packages/fastify-html-plugin/test/auto-detect.test.tsx +++ b/packages/fastify-html-plugin/test/auto-detect.test.tsx @@ -1,46 +1,57 @@ import fastify from 'fastify'; -import assert from 'node:assert'; -import test from 'node:test'; +import { describe, expect, test } from 'vitest'; import { fastifyKitaHtml } from '../'; -test('opts.autoDoctype', async (t) => { - await using app = fastify(); - app.register(fastifyKitaHtml); +describe('opts.autoDoctype', () => { + test('Default', async () => { + await using app = fastify(); + app.register(fastifyKitaHtml); - app.get('/default', () =>
Not a html root element
); - app.get('/default/root', () => ); - app.get('/html', (_, res) => res.html(
Not a html root element
)); - app.get('/html/root', (_, res) => res.html()); + app.get('/default', () =>
Not a html root element
); - await t.test('Default', async () => { const res = await app.inject({ method: 'GET', url: '/default' }); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8'); - assert.strictEqual(res.body, '
Not a html root element
'); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(res.body).toBe('
Not a html root element
'); }); - await t.test('Default root', async () => { + test('Default root', async () => { + await using app = fastify(); + app.register(fastifyKitaHtml); + + app.get('/default/root', () => ); + const res = await app.inject({ method: 'GET', url: '/default/root' }); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8'); - assert.strictEqual(res.body, ''); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(res.body).toBe(''); }); - await t.test('Html ', async () => { + test('Html', async () => { + await using app = fastify(); + app.register(fastifyKitaHtml); + + app.get('/html', (_, res) => res.html(
Not a html root element
)); + const res = await app.inject({ method: 'GET', url: '/html' }); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); - assert.strictEqual(res.body, '
Not a html root element
'); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.body).toBe('
Not a html root element
'); }); - await t.test('Html root', async () => { + test('Html root', async () => { + await using app = fastify(); + app.register(fastifyKitaHtml); + + app.get('/html/root', (_, res) => res.html()); + const res = await app.inject({ method: 'GET', url: '/html/root' }); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); - assert.strictEqual(res.body, ''); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.body).toBe(''); }); }); diff --git a/packages/fastify-html-plugin/test/index.test.tsx b/packages/fastify-html-plugin/test/index.test.tsx index c0c117a7e..b02d3764e 100644 --- a/packages/fastify-html-plugin/test/index.test.tsx +++ b/packages/fastify-html-plugin/test/index.test.tsx @@ -1,7 +1,6 @@ import fastify from 'fastify'; -import assert from 'node:assert'; -import test, { describe } from 'node:test'; import { setImmediate } from 'node:timers/promises'; +import { describe, expect, test } from 'vitest'; import { fastifyKitaHtml } from '..'; describe('reply.html()', () => { @@ -13,9 +12,9 @@ describe('reply.html()', () => { const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.body, '
Hello from JSX!
'); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); - assert.strictEqual(res.statusCode, 200); + expect(res.body).toBe('
Hello from JSX!
'); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.statusCode).toBe(200); }); test('renders async html', async () => { @@ -23,14 +22,14 @@ describe('reply.html()', () => { app.register(fastifyKitaHtml); app.get('/', (_, res) => - res.html(
{setImmediate('Hello from async JSX!')}
) + res.html(
{setImmediate('Hello from async JSX!')}
) ); const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.body, '
Hello from async JSX!
'); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); - assert.strictEqual(res.statusCode, 200); + expect(res.body).toBe('
Hello from async JSX!
'); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.statusCode).toBe(200); }); test('fails when html is not a string', async () => { @@ -44,9 +43,9 @@ describe('reply.html()', () => { const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8'); - assert.strictEqual(res.statusCode, 500); - assert.deepStrictEqual(res.json(), { + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(res.statusCode).toBe(500); + expect(res.json()).toEqual({ statusCode: 500, code: 'ERR_INVALID_ARG_TYPE', error: 'Internal Server Error', @@ -66,9 +65,9 @@ describe('reply.html()', () => { const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.statusCode, 500); - assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8'); - assert.deepStrictEqual(res.json(), { + expect(res.statusCode).toBe(500); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(res.json()).toEqual({ statusCode: 500, code: 'ERR_INVALID_ARG_TYPE', error: 'Internal Server Error', diff --git a/packages/fastify-html-plugin/test/stream-html.test.tsx b/packages/fastify-html-plugin/test/stream-html.test.tsx index 5d83e9076..6da7cf17c 100644 --- a/packages/fastify-html-plugin/test/stream-html.test.tsx +++ b/packages/fastify-html-plugin/test/stream-html.test.tsx @@ -5,12 +5,11 @@ // This was adapted to work inside a fastify route handler. import Html, { type PropsWithChildren } from '@kitajs/html'; -import { Suspense, SuspenseScript } from '@kitajs/html/suspense'; +import { Suspense, SuspenseScript as safeSuspenseScript } from '@kitajs/html/suspense'; import fastify from 'fastify'; import { JSDOM } from 'jsdom'; -import assert from 'node:assert'; -import { afterEach, describe, test } from 'node:test'; import { setTimeout } from 'node:timers/promises'; +import { afterEach, describe, expect, test } from 'vitest'; import { fastifyKitaHtml } from '..'; async function SleepForMs({ ms, children }: PropsWithChildren<{ ms: number }>) { @@ -21,7 +20,7 @@ async function SleepForMs({ ms, children }: PropsWithChildren<{ ms: number }>) { describe('Suspense', () => { // Detect leaks of pending promises afterEach(() => { - assert.equal(SUSPENSE_ROOT.requests.size, 0, 'Suspense root left pending resources'); + expect(SUSPENSE_ROOT.requests.size).toBe(0); // Reset suspense root SUSPENSE_ROOT.autoScript = true; @@ -37,9 +36,9 @@ describe('Suspense', () => { const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.body, '
'); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); + expect(res.body).toBe('
'); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); }); test('Suspense sync children', async () => { @@ -56,9 +55,9 @@ describe('Suspense', () => { const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.body, '
2
'); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); + expect(res.body).toBe('
2
'); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); }); test('Suspense async children', async () => { @@ -75,16 +74,15 @@ describe('Suspense', () => { const res = await app.inject({ method: 'GET', url: '/' }); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8'); - assert.strictEqual( - res.body, + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.body).toBe( <>
1
- {SuspenseScript} + {safeSuspenseScript}