# @aeriz/wysiwyg

한국어 친화 contentEditable WYSIWYG 에디터.

자세한 동작 사양은 [SPEC.md](./SPEC.md) 를 참고하세요.

## 지원 환경

| 대상 | 진입점 | 방식 |
| --- | --- | --- |
| Vanilla JS | `@aeriz/wysiwyg` | 클래스 직접 사용 |
| Web Component | `@aeriz/wysiwyg/wc` | `<aeriz-editor>` 커스텀 엘리먼트 |
| jQuery | `@aeriz/wysiwyg/jquery` | `$.fn.aerizEditor` 플러그인 |
| React 16.8+ / 17 / 18 / 19 | `@aeriz/wysiwyg/react` | `<AerizEditor />` 컴포넌트 |
| Vue 3 | `@aeriz/wysiwyg/vue` | `v-model` 컴포넌트 |
| Vue 2.6 / 2.7 | `@aeriz/wysiwyg/vue2` | `v-model` 컴포넌트 |
| Svelte 3 / 4 / 5 | `@aeriz/wysiwyg/svelte` | `use:aerizEditor` 액션 |
| Angular 14+ | `@aeriz/wysiwyg/angular` | `[aerizEditor]` 디렉티브 (`ngModel` 지원) |
| Node / Deno / Bun / Cloudflare Workers | `@aeriz/wysiwyg/server` | DOM 없는 렌더러 · 변환기 |

빌드 도구가 없는 페이지도 지원합니다 — 코어뿐 아니라 **어댑터도 스크립트
태그로 로드**할 수 있습니다 ([CDN 절](#cdn--어댑터도-스크립트-태그로) 참고).

프레임워크 패키지는 모두 **optional peerDependency** 입니다. React 프로젝트에
Vue 가 설치될 일은 없습니다.

## 설치

```bash
npm install @aeriz/wysiwyg
# yarn add @aeriz/wysiwyg
# pnpm add @aeriz/wysiwyg
```

스타일시트는 어느 프레임워크를 쓰든 한 번 로드해야 합니다.

```ts
import '@aeriz/wysiwyg/style.css';
```

---

## Vanilla JS

### 번들러 사용

```ts
import { AerizEditor, AerizViewer } from '@aeriz/wysiwyg';
import '@aeriz/wysiwyg/style.css';

const editor = new AerizEditor({
  element: document.querySelector('#editor')!,
  initialHTML: '<p>안녕하세요</p>',
  placeholder: '내용을 입력하세요',
  theme: 'dark',
  onChange: (html) => localStorage.setItem('draft', html),
});

const viewer = new AerizViewer({
  element: document.querySelector('#preview')!,
});
editor.onStateChange(() => viewer.setBlocks(editor.serialize()));
```

### CDN — 스크립트 태그

빌드 단계 없이 바로 사용. 라이브러리는 글로벌 `window.AerizWysiwyg` 와 단축
별칭 `window.AerizEditor` / `window.AerizViewer` 로 노출됩니다.

```html
<!-- ✓ 프로덕션 — 특정 버전 고정 (immutable, 가장 긴 캐시) -->
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.css"
/>
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>

<!-- 1.x 트랙 — 패치만 자동 수신 -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@^1.0.5/dist/aeriz-wysiwyg.min.js"></script>

<!-- 항상 최신 — 프로토타이핑용 (프로덕션 권장 X) -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@latest/dist/aeriz-wysiwyg.min.js"></script>

<!-- unpkg 미러 -->
<script src="https://unpkg.com/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>

<div id="editor"></div>
<script>
  const editor = new AerizEditor({
    element: document.querySelector('#editor'),
    initialHTML: '<p>안녕하세요</p>',
    onChange: (html) => console.log(html),
  });
</script>
```

### CDN — ES Module

```html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.css"
/>
<script type="module">
  import {
    AerizEditor,
    AerizViewer,
  } from 'https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.esm.min.js';

  new AerizEditor({ element: document.querySelector('#editor') });
</script>
```

---

## CDN — 어댑터도 스크립트 태그로

`dist/adapters/*.js` 는 ESM 이라 `<script type="module">` 로 CDN 에서 바로
부르면 bare import 를 해석하지 못합니다. 그래서 **프레임워크 의존이 없는
어댑터는 IIFE 로도 배포**합니다. 코어를 번들에 넣지 않고 전역
`AerizWysiwyg` 를 참조하므로, 코어를 먼저 부르기만 하면 됩니다 (코어가 두 벌
로드되지 않습니다).

| 파일 | 노출 전역 | 역할 |
| --- | --- | --- |
| `dist/aeriz-wysiwyg-wc.min.js` | `AerizWysiwygWC` | `<aeriz-editor>` 자동 등록 |
| `dist/aeriz-wysiwyg-jquery.min.js` | `AerizWysiwygJQuery` | `$.fn.aerizEditor` 자동 등록 |
| `dist/aeriz-wysiwyg-adapter.min.js` | `AerizWysiwygAdapter` | `createEditorController` 등 |

```html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.css"
/>
<!-- 1) 코어 먼저 -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<!-- 2) 필요한 어댑터 -->
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg-wc.min.js"></script>

<aeriz-editor theme="dark" placeholder="내용을 입력하세요"></aeriz-editor>
```

jQuery 는 jQuery 자체를 먼저 로드해야 자동 등록됩니다.

```html
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg-jquery.min.js"></script>
<script>
  $('#editor').aerizEditor({ value: '<p>안녕하세요</p>' });
</script>
```

### React · Vue 를 CDN 으로 쓸 때 — import map

React / Vue 어댑터는 IIFE 로 제공하지 않습니다. 소비자 앱의 프레임워크
인스턴스를 그대로 써야 하는데 전역 하나로 묶으면 인스턴스가 갈라져 훅이
깨지기 때문입니다. CDN 에서는 import map 이 정답입니다.

```html
<script type="importmap">
  {
    "imports": {
      "vue": "https://esm.sh/vue@3",
      "@aeriz/wysiwyg": "https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/aeriz-wysiwyg.esm.min.js",
      "@aeriz/wysiwyg/vue": "https://cdn.jsdelivr.net/npm/@aeriz/wysiwyg@1.0.5/dist/adapters/vue.js"
    }
  }
</script>
<script type="module">
  import { createApp, h, ref } from 'vue';
  import { AerizEditor } from '@aeriz/wysiwyg/vue';

  const html = ref('<p>안녕하세요</p>');
  createApp({
    render: () =>
      h(AerizEditor, {
        modelValue: html.value,
        'onUpdate:modelValue': (v) => (html.value = v),
      }),
  }).mount('#app');
</script>
```

> `@aeriz/wysiwyg` 항목이 반드시 있어야 합니다 — 어댑터가 그 이름으로 코어를
> 가져오기 때문입니다. 빠뜨리면 브라우저가 bare specifier 해석에 실패합니다.

---

## Web Component

프레임워크 의존이 전혀 없는 최소 공통 분모입니다. Shadow DOM 을 쓰지 않으므로
전역 스타일시트가 그대로 적용됩니다.

```html
<link rel="stylesheet" href="/aeriz-wysiwyg.min.css" />
<script type="module">
  import '@aeriz/wysiwyg/wc'; // import 만으로 자동 등록
</script>

<form action="/posts" method="post">
  <!-- name 을 주면 폼 전송에 본문 HTML 이 함께 실립니다 (폼 연동 지원 브라우저) -->
  <aeriz-editor name="body" theme="dark" placeholder="내용을 입력하세요">
  </aeriz-editor>
  <button type="submit">저장</button>
</form>

<script type="module">
  const el = document.querySelector('aeriz-editor');
  el.addEventListener('change', (e) => console.log(e.detail.html));
  el.value = '<p>안녕하세요</p>';
  el.onImageUpload = async (file) => uploadAndGetUrl(file);
</script>
```

| 속성 | 값 |
| --- | --- |
| `value` | 본문 HTML (프로퍼티로도 읽고 쓸 수 있음) |
| `placeholder` | 빈 본문 안내 문구 |
| `theme` | `light` / `dark` |
| `locale` | `en` / `ko` / `ja` / `zh` |
| `toolbar` | `false` · 항목 목록(`"bold italic"`) · JSON 객체 |
| `image-upload-url`, `file-upload-url` | 업로드 API URL |

프로퍼티: `value` · `editor` · `getBlocks()` · `focus()` ·
`onImageUpload` · `onFileUpload`.
이벤트: `input` · `change` (`detail.html`) · `aeriz-ready` (`detail.editor`).

태그 이름이 충돌하면 접두사를 바꿀 수 있습니다.

```ts
import { defineAerizElements } from '@aeriz/wysiwyg/wc';
defineAerizElements('my'); // <my-editor> / <my-viewer>
```

---

## jQuery

```html
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<link rel="stylesheet" href="/aeriz-wysiwyg.min.css" />
<script type="module">
  import '@aeriz/wysiwyg/jquery'; // 전역 jQuery 가 있으면 자동 등록

  $('#editor').aerizEditor({
    value: '<p>안녕하세요</p>',
    theme: 'dark',
    onChange: (html) => console.log(html),
  });

  const html = $('#editor').aerizEditor('getHTML');
  $('#editor').aerizEditor('setHTML', '<p>바꿈</p>');
  $('#editor').aerizEditor('destroy');
</script>
```

번들러 환경에서 전역 jQuery 를 쓰지 않는다면 명시적으로 등록합니다.

```ts
import $ from 'jquery';
import { registerAerizJQuery } from '@aeriz/wysiwyg/jquery';

registerAerizJQuery($);
```

명령: `getHTML` · `getBlocks` · `setHTML` · `update` · `focus` ·
`instance` · `destroy`. `aerizViewer` 도 같은 방식으로 동작합니다.

같은 노드에 두 번 초기화하면 새 인스턴스를 만들지 않고 갱신으로 처리합니다.

---

## React

```tsx
import { useState } from 'react';
import { AerizEditor, AerizViewer } from '@aeriz/wysiwyg/react';
import '@aeriz/wysiwyg/style.css';

export function Compose() {
  const [html, setHtml] = useState('<p>안녕하세요</p>');

  return (
    <>
      <AerizEditor
        value={html}
        onChange={setHtml}
        theme="dark"
        placeholder="내용을 입력하세요"
        className="my-editor"
        imageUploadUrl="/api/upload"
      />
      <AerizViewer html={html} />
    </>
  );
}
```

`value` 를 그대로 되돌려줘도 캐럿이 튀지 않습니다 — 들어온 값이 에디터의 현재
출력과 같으면 본문을 교체하지 않기 때문입니다.

`ref` 로 명령형 API 에 접근합니다.

```tsx
const ref = useRef<AerizEditorHandle>(null);
// ref.current.editor    — 코어 인스턴스 (전체 API)
// ref.current.getHTML() / setHTML(html) / focus()
```

---

## Vue 3

```vue
<script setup>
import { ref } from 'vue';
import { AerizEditor, AerizViewer } from '@aeriz/wysiwyg/vue';
import '@aeriz/wysiwyg/style.css';

const html = ref('<p>안녕하세요</p>');
const editor = ref(null);
</script>

<template>
  <AerizEditor
    ref="editor"
    v-model="html"
    theme="dark"
    placeholder="내용을 입력하세요"
    @state-change="onStateChange"
    @ready="onReady"
  />
  <AerizViewer :html="html" />
</template>
```

전역 등록도 가능합니다.

```ts
import { AerizWysiwygPlugin } from '@aeriz/wysiwyg/vue';
app.use(AerizWysiwygPlugin);
```

이벤트: `update:modelValue` · `change` · `state-change` · `ready`.
`ref` 로 노출되는 것: `editor` · `getHTML()` · `setHTML()` · `focus()`.

## Vue 2 (2.6 / 2.7)

Vue 2 관례에 따라 `v-model` 은 `value` prop + `input` 이벤트를 씁니다.

```js
import { AerizEditor } from '@aeriz/wysiwyg/vue2';
import '@aeriz/wysiwyg/style.css';

export default {
  components: { AerizEditor },
  data: () => ({ html: '<p>안녕하세요</p>' }),
  template: `
    <AerizEditor ref="editor" v-model="html" theme="dark" />
  `,
  methods: {
    focusEditor() {
      this.$refs.editor.focus();
    },
  },
};
```

이 어댑터는 `vue` 를 import 하지 않습니다 (Vue 2 컴포넌트는 순수 옵션 객체).
따라서 소비자 앱의 Vue 인스턴스와 이중화될 여지가 없습니다.

---

## Svelte (3 / 4 / 5)

컴파일된 `.svelte` 컴포넌트가 아니라 **액션**으로 제공합니다. 액션은 순수 함수
계약이라 Svelte 메이저 버전 세 개를 배포본 하나로 만족시킵니다.

```svelte
<script>
  import { aerizEditor, aerizViewer } from '@aeriz/wysiwyg/svelte';
  import '@aeriz/wysiwyg/style.css';

  let html = '<p>안녕하세요</p>';
</script>

<div
  use:aerizEditor={{
    value: html,
    onChange: (next) => (html = next),
    theme: 'dark',
    placeholder: '내용을 입력하세요',
  }}
/>

<div use:aerizViewer={{ html }} />
```

---

## Angular (14+)

Angular 연동은 **TypeScript 소스로 배포**됩니다 — 소비자의 Angular 컴파일러가
직접 컴파일하므로 Angular 14 이상 어느 버전에서도 같은 파일이 동작합니다.
설정 방법(택 1)과 상세 문서는 [angular/README.md](./angular/README.md) 를
참고하세요.

`tsconfig.app.json` 의 `include` 에 한 줄 추가하면 끝입니다.

```jsonc
"include": ["src/**/*.ts", "node_modules/@aeriz/wysiwyg/angular/*.ts"]
```

```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AerizEditorDirective } from '@aeriz/wysiwyg/angular';

@Component({
  standalone: true,
  imports: [FormsModule, AerizEditorDirective],
  template: `
    <div aerizEditor [(ngModel)]="html" theme="dark"></div>
    <div aerizViewer [html]="html"></div>
  `,
})
export class ComposeComponent {
  html = '<p>안녕하세요</p>';
}
```

`[(value)]` · `[(ngModel)]` · `formControlName` 세 가지 바인딩을 모두
지원합니다. 디렉티브는 에디터를 `NgZone` 밖에서 생성하므로 캐럿을 움직일 때마다
변경 감지가 돌지 않습니다.

---

## 서버 / 엣지 런타임 (DOM 없음)

저장된 `BlockSnapshot[]` 을 HTML · 마크다운 · 평문으로 바꿉니다. **CSS 를
import 하지 않고**, import 그래프 전체가 `document` / `window` 를 참조하지
않습니다 — Cloudflare Workers(workerd) · Node · Deno · Bun 에서 그대로
동작합니다.

```ts
import {
  renderBlocksToHTML,
  blocksToMarkdown,
  blocksToPlainText,
} from '@aeriz/wysiwyg/server';

// 발행용 HTML — 같은 입력이면 항상 같은 출력 (결정적 · 멱등)
const html = renderBlocksToHTML(post.blocks, { wrapper: 'content' });

// 포맷 종속을 푸는 탈출구
const markdown = blocksToMarkdown(post.blocks);

// 전문 검색 색인용
const text = blocksToPlainText(post.blocks, { blockSeparator: ' ' });
```

```ts
// Cloudflare Worker
export default {
  async fetch(req, env) {
    const post = await env.DB.get('post:1', 'json');
    return new Response(renderBlocksToHTML(post.blocks, { wrapper: 'content' }), {
      headers: { 'content-type': 'text/html; charset=utf-8' },
    });
  },
};
```

---

## 키보드 단축키

macOS 는 `Ctrl` 대신 `Cmd`. 전체 목록과 근거는
[SPEC.md §7](./SPEC.md#7-키보드-단축키--입력-규칙) 에 있습니다.

| 동작 | 단축키 |
| --- | --- |
| 되돌리기 / 다시 실행 | `Ctrl+Z` / `Ctrl+Shift+Z` · `Ctrl+Y` |
| 굵게 · 기울임 · 밑줄 · 취소선 | `Ctrl+B` · `Ctrl+I` · `Ctrl+U` · `Ctrl+Shift+X` |
| **본문**으로 | `Ctrl+Alt+0` |
| 제목 1~6 | `Ctrl+Alt+1` ~ `Ctrl+Alt+6` |
| 번호 목록 · 글머리 목록 · 체크리스트 | `Ctrl+Alt+7` · `Ctrl+Alt+8` · `Ctrl+Alt+T` |
| 인용구 · 코드 블록 | `Ctrl+Alt+9` · `Ctrl+Alt+C` |
| 블록 이동 / 삭제 | `Ctrl+Shift+↑` `Ctrl+Shift+↓` / `Ctrl+Shift+D` |
| 표: 다음 / 이전 셀 | `Tab` / `Shift+Tab` (마지막 셀에서 `Tab` → 새 행) |
| 목록: 들여쓰기 / 내어쓰기 | `Tab` / `Shift+Tab` |

**마크다운처럼 입력해도 됩니다.** 블록 맨 앞에서 `# ` `- ` `1. ` `> ` `` ``` ``
`---` `- [ ] ` 를 치면 그 자리에서 유형이 바뀝니다.

> 새로 만들어지는 블록의 기본 유형은 언제나 **본문**입니다. 제목 끝에서
> `Enter` 를 눌러도 제목이 복제되지 않고 본문이 이어지며, 빈 제목·인용구·
> 코드 블록에서 `Backspace` 를 누르면 본문으로 돌아옵니다.

## 서버 sanitizer 와 함께 쓰기

`getHTML()` 은 기본적으로 테마 색을 인라인 `style` 에 베이크합니다 —
스타일시트 없이 어디에 붙여도 같은 시각으로 보이지만, `style` 속성을 제거하는
sanitizer 를 통과하면 서식이 통째로 사라집니다.

두 가지 대응이 있습니다.

**1. 클래스 기반 출력으로 전환** — `style` 프로퍼티가 40여 개에서 10개로
줄고, 정렬은 클래스로 표현됩니다.

```ts
new AerizEditor({ element, htmlStyles: 'class' });  // getHTML/onChange 기본값
editor.getHTML({ styles: 'class' });                // 호출 단위 재정의
```

출력은 `aeriz-wysiwyg.min.css` 를 로드한 페이지의
`.aeriz-wysiwyg > .aw-content` 안에 넣어야 기본 서식이 적용됩니다.
`renderBlocksToHTML(blocks, { wrapper: 'content' })` 가 그 컨테이너를 만들어
줍니다.

**2. 허용목록을 출력에 맞추기** — 등장할 수 있는 태그 · 속성 · style
프로퍼티의 확정 목록이
[SPEC.md §9 출력 HTML 허용목록](./SPEC.md#9-출력-html-허용목록) 에 있습니다.
추측이 아니라 실측이며 `npm run allowlist` 로 재생성할 수 있습니다.

> ⚠️ **HTML 가져오기 기능은 sanitizer 가 아닙니다.** 원본 스크립트가 실행되고
> 이벤트 핸들러 속성이 제거되지 않습니다. 저장 전 서버 정화가 필수입니다 —
> [SPEC.md §11](./SPEC.md#11-html-가져오기의-보안-경계) 참고.

## 스냅샷 스키마 버전

`editor.serialize()` 는 각 블록에 `version: 1` 을 기록합니다. 저장된 데이터를
읽는 모든 경로가 `upgradeSnapshots()` 를 통과하므로, 버전이 올라가도 예전
데이터가 그대로 열립니다. 변경 정책은
[SPEC.md §10](./SPEC.md#10-blocksnapshot-스키마-버전-정책) 에 있습니다.

## 직접 어댑터 만들기

목록에 없는 프레임워크(Solid, Qwik, Lit …) 는 프레임워크 중립 컨트롤러 위에
얇게 올리면 됩니다. 값 바인딩 · 캐럿 보존 · 재생성 판단이 이미 들어 있습니다.

```ts
import { createEditorController } from '@aeriz/wysiwyg/adapter';

const controller = createEditorController(hostElement, {
  value: '<p>안녕하세요</p>',
  onChange: (html) => setState(html),
});

controller.update({ value: nextHtml });  // 바뀐 것만 반영
controller.destroy();                    // 언마운트 시
```

## 빌드

```bash
npm install
npm run build        # dist/ 에 IIFE / ESM / CJS / 어댑터 / CSS / .d.ts 생성
npm test             # 서버 렌더러 · getHTML · 어댑터 계약 테스트 (jsdom)
npm run typecheck
npm run allowlist    # 출력 HTML 허용목록 재생성
npm run version:sync # 문서·데모의 CDN 버전 표기를 package.json 에 맞춤
npm run version:check # 뒤처졌으면 실패 (CI 용)
npm run dev          # 데모 페이지 (http://localhost:5173)

npm run release:stage  # npm 업로드용 release/ 조립 + exports 경로 검증
npm run release:verify # release/ 에서 npm pack --dry-run
```

`dist/` 는 데모 서버의 publicDir 을 겸하고 개발용 비압축본·CDN 번들까지
담고 있습니다. 실제로 npm 에 올라가는 것은 `release:stage` 가 `"files"` 만
골라 조립한 `release/` 입니다. 배포 절차는 [RELEASE.md](./RELEASE.md) (붙여넣어
실행하는 런북), 계정·권한·회수 등 배경은 [PUBLISHING.md](./PUBLISHING.md) 를
참고하세요.

데모는 세 페이지입니다.

- `/index.html` — 에디터 기능 데모
- `/docs.html` — **연동 · 직렬화 레퍼런스**. 이 README 의 내용을 사이드바 · 검색 가능한
  API 표 · 라이브 플레이그라운드로 정리한 단일 페이지 문서입니다. 플레이그라운드에서
  같은 문서가 `inline` / `class` HTML · 마크다운 · 평문 · 스냅샷 · 서버 렌더 여섯 가지로
  어떻게 나오는지 실시간 비교할 수 있습니다.
- `/frameworks.html` — 프레임워크 어댑터 데모. React · Vue 3 · Web Component ·
  Svelte 액션 · jQuery 를 한 페이지에 올리고, 로드 직후 자동 점검을 실행합니다.

### 문서 사이트 배포 — Cloudflare Workers

`/docs.html` 을 그대로 정적 사이트로 올릴 수 있습니다. `docs:build` 가
`examples/docs.html` 과 라이브러리 산출물을 `docs/` 에 모으고, 코드가 없는
**정적 자산 전용 Worker** (`wrangler.toml`) 가 그 폴더를 서빙합니다. 별도
Pages 프로젝트가 필요 없습니다.

```bash
npx wrangler login    # 처음 한 번만

npm run docs:build    # docs/ 조립
npm run docs:preview  # 로컬 Workers 런타임으로 확인 (wrangler dev)
npm run docs:deploy   # 배포
```

페이지 HTML 은 데모와 배포본이 같은 파일입니다 — `examples/docs.html` 이
라이브러리를 루트 절대 경로로 부르고, 배포 시에도 산출물을 사이트 루트에 같이
두기 때문에 경로 분기가 없습니다. 캐시·보안 헤더는 `docs/_headers` 로 함께
생성됩니다. `docs/` 는 파생물이라 git 에 추적하지 않습니다.

### 오프라인 단일 파일

문서 페이지를 외부에 공유하거나 오프라인으로 열어야 하면 라이브러리를 인라인한
단일 파일로 만들 수 있습니다 (외부 요청 0건).

```bash
node scripts/build-docs-artifact.mjs docs-standalone.html
```

설치된 Chrome 으로 그 페이지를 실제 검증할 수 있습니다 (playwright-core 는
브라우저를 내려받지 않고 시스템 Chrome 을 그대로 씁니다).

```bash
npm run dev                                    # 다른 터미널에서
npm run test:browser                           # 헤드리스 검증 + 스크린샷
node scripts/browser-check.mjs --headed        # 창을 띄워서 보기
```

5173 이 이미 쓰이는 중이면 데모를 다른 포트로 띄우고 주소를 넘기면 됩니다.

```bash
npx vite --config vite.demo.config.ts --port 5347
node scripts/browser-check.mjs http://localhost:5347/frameworks.html
```

## 라이선스

Apache-2.0
