Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/ai-governance.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "pdfnative-react AI Governance Configuration",
"description": "Machine-readable contract governing how AI coding agents may propose issues, contributions, and changes for pdfnative-react. Agents that scan repository configuration on initialization MUST honour this file. See .github/AGENT_RULES.md for the human-and-agent-readable protocol and docs/AI_GOVERNANCE.md for the narrative walk-through. The library ships NO code path that can write to GitHub or make an outbound network call; the shipped guardrail is the local `npm run verify:issue` CLI.",
"version": "1.0.0",
"spec_updated": "2026-07-17",
"version": "1.1.0",
"spec_updated": "2026-07-25",
"applies_to": [
"pdfnative",
"pdfnative-cli",
Expand Down Expand Up @@ -55,14 +55,25 @@
".github/copilot-instructions.md",
".github/AGENT_RULES.md",
"docs/AI_GOVERNANCE.md",
"docs/AGENT_CONTRACT.md",
"docs/KNOWLEDGE_BASE.md",
"docs/RECIPES.md",
"ROADMAP.md",
"SECURITY.md",
"llms.txt"
]
],
"runtime_api": {
"description": "Since 1.1.0 the contract also ships as runtime capability, so an agent working from an installed package (with no repository checkout) can read the rules it must follow.",
"policy": "aiGovernancePolicy()",
"rules": "agentRulesText()",
"validate": "validateIssueDraft(markdown)",
"capabilities": "capabilityManifest()",
"preflight": "doctor()"
}
},
"verification": {
"command": "npm run verify:issue -- .github/drafts/<draft>.md",
"api": "validateIssueDraft(markdown)",
"advisory_in_ci": true,
"blocks_submission_on_failure": true
},
Expand Down
35 changes: 28 additions & 7 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,30 @@ Read [docs/KNOWLEDGE_BASE.md](../docs/KNOWLEDGE_BASE.md) and
imports from there or from `src/types.ts`.
- **Never add a CSS/flexbox layout model.** Map components 1:1 onto pdfnative
blocks (heading, paragraph, list, table, image, link, spacer, pageBreak, toc,
barcode, svg, formField). `<Section>` is the single allowed *composite* (it
resolves to a heading + children, emitting no host tag).
- **`pdfnative` is a peer dependency.** Never move it back to `dependencies`.
barcode, svg, **chart**, formField). `<Section>` is the single allowed
*composite* (it resolves to a heading + children, emitting no host tag).
- **`src/registry.ts` is the single source of truth** for the block grammar, the
component list and the lint rules. `src/spec/schema.ts`, `src/spec/validate.ts`
and `src/manifest.ts` all *derive* from it — never restate a kind, an arity or
a rule in those files. Compile-time `Assert<Equals<…>>` locks mean forgetting
to register something fails `npm run typecheck`. See the 10-step checklist in
[AGENTS.md](../AGENTS.md).
- **`pdfnative` is a peer dependency** (`^1.6.0`; Node ≥ 22). Never move it back
to `dependencies`.
- **Authoring only.** Do not re-export byte-level post-processing (merge/split,
annotations, signing, crypto, font compilation) — point to the engine instead.
- **Document-level `outline`/`pageLabels`** live on `<Document>` props (they
reference post-layout pages), not as content blocks.
form fill/flatten, text extraction, decryption, annotations, signing, crypto,
font compilation) — point to [docs/RECIPES.md](../docs/RECIPES.md) instead.
- **Document-level props on `<Document>`**, not content blocks: `outline` and
`pageLabels` (they reference post-layout pages), plus the layout sugar
`watermark`, `header`, `footer`, `attachments`, `tagged`. The sugar folds into
`layout` via `resolveLayout()`, where an explicit `layout` always wins — and
which must keep returning `undefined`, never `{}`, when nothing is set, or
every existing document changes bytes.
- **Agent-facing surface must stay honest.** `doctor()` must never throw;
`validateSpec()` must never throw and must bound its recursion; `schema()` must
reject unknown subjects with `E_INPUT` (use `Object.hasOwn`, not a truthiness
check); `capabilityManifest()` must list *every* public export, and a test
locks both directions.
- **react-reconciler version contract:** React 19 ↔ `react-reconciler@^0.31` ↔
`@types/react-reconciler@^0.32`. Specifically:
- `getRootHostContext`/`getChildHostContext` must return a **non-null**
Expand All @@ -37,7 +54,11 @@ Read [docs/KNOWLEDGE_BASE.md](../docs/KNOWLEDGE_BASE.md) and
- **Do not run the renderer synchronously inside a React effect/commit.** `usePdf`
defers `renderToBytes` via `queueMicrotask` to avoid reconciler reentrancy
(which deadlocks). Preserve this when editing hooks.
- **Client modules carry `'use client'`** (`hooks.ts`, `viewer.tsx`).
- **Client modules carry `'use client'`** (`hooks.ts`, `viewer.tsx`), and are
re-exported from `src/client.ts`, which is built as the separate
`pdfnative-react/client` subpath so the directive reaches `dist/client.*`.
The root bundle must never carry it — marking it would break every server
usage — and `src/response.ts` is server-side by design.
- **Strict TypeScript, no `any`** (lint-enforced). Use `type`-only imports.
- **AI governance (draftsman, never submitter).** Do not open/submit issues or
PRs autonomously. Draft into `.github/drafts/`, validate with
Expand Down
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ updates:
interval: weekly
day: monday
open-pull-requests-limit: 10
labels:
- dependencies
commit-message:
prefix: 'chore(deps):'
ignore:
- dependency-name: typescript
update-types: ['version-update:semver-major']
Expand All @@ -30,4 +34,10 @@ updates:
directory: '/'
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
labels:
- dependencies
- ci
commit-message:
prefix: 'chore(ci):'
26 changes: 21 additions & 5 deletions .github/instructions/components.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,24 @@ side-effect-free factory that emits a lowercase **host tag** via the typed
- Keep aliases intentional: `Text = Paragraph`, `Toc = TableOfContents`.
- Every exported component and its props interface needs a TSDoc comment.

When you add a component, also: add a host tag in `reconciler/nodes.ts`, a case
in `reconciler/serialize.ts`, an export in `src/index.ts`, and a test in
`tests/compile.test.tsx`. If the component adds authoring capability, mirror it
in the `DocSpec` grammar + schema (`src/spec/`) to keep parity. (Composites like
`Section` skip the nodes/serialize steps.)
When you add a component:

1. `reconciler/nodes.ts` — add the host tag.
2. `reconciler/serialize.ts` — add the `case` in `toBlock`. **Compiler-enforced:**
a missing case fails `npm run typecheck` on the `const exhaustive: never` guard.
3. **`src/registry.ts`** — add the `COMPONENT_REGISTRY` entry. **Compiler-enforced:**
`ComponentRegistryIsExhaustive` fails typecheck if a `HostTag` has no component.
4. `src/index.ts` — export the component and its props type.
5. `tests/compile.test.tsx` — a serialization test, plus the ordered list in
`tests/registry.test.ts`.
6. If it adds authoring capability, mirror it in the `DocSpec` grammar + schema
(`src/spec/`) — see `spec.instructions.md` for that checklist — and refresh
`tests/compile-snapshot.test.tsx` deliberately, reading the diff.

Composites like `Section` skip steps 1–3: they emit no host tag, and
`COMPONENT_REGISTRY` records them with `tag: null`. A test asserts `Section` is
the *only* one.

Client-side components (`PDFViewer`, `PDFDownloadLink`, `BlobProvider`) go in
`CLIENT_COMPONENT_REGISTRY` instead, and must be re-exported from
`src/client.ts` so they reach the `pdfnative-react/client` subpath.
39 changes: 33 additions & 6 deletions .github/instructions/spec.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,19 @@ with far fewer tokens than JSX. It is pure, isomorphic, and side-effect-free.
not add layout primitives, and do not introduce props the components lack.
Pure JSX sugar with no new capability (e.g. `<Section>`) is deliberately
**not** given a tuple — agents emit the underlying blocks. Document-level
`outline`/`pageLabels` are top-level `DocSpec` fields (not tuples), mirroring
`<Document>`. Nested list items use `{ text, items }` in the `ul`/`ol` grammar.
`outline`, `pageLabels`, `watermark`, `header`, `footer`, `attachments` and
`tagged` are top-level `DocSpec` fields (not tuples), mirroring `<Document>`.
Nested list items use `{ text, items }` in the `ul`/`ol` grammar.
- **`src/registry.ts` is the single source of truth.** `schema.ts` derives
`$defs.block.oneOf` — including each tuple's kind discriminator, arity and
description — from `BLOCK_REGISTRY`, and `validate.ts` derives its arity and
payload rules from the same table. Never restate any of that in a builder.
Compile-time `Assert<Equals<…>>` locks make omission a `tsc` failure.
- **`validate.ts` is the dependency-free dry run.** `validateSpec(unknown)` must
never throw and must never recurse without a depth bound — it is the gate for
untrusted input. Unknown top-level fields are a *warning*, so a newer spec
meeting an older package degrades gracefully. `KNOWN_FIELDS` is locked to
`keyof DocSpec` at compile time.
- **Reuse component prop types.** Per-block opts types are derived from the
component prop interfaces (via `Pick`/`Omit`) so the spec inherits their type
safety and cannot drift.
Expand All @@ -30,7 +41,23 @@ with far fewer tokens than JSX. It is pure, isomorphic, and side-effect-free.
`TableOfContents`) needs an explicit generic (`createElement<SpacerProps>`),
or TS infers `Attributes` and rejects the extra props (TS2769).

When you add a block kind: add the tuple type in `types.ts`, a `case` in
`compile.ts`, a per-block schema builder in `schema.ts`, an export in
`src/spec/index.ts` (and `src/index.ts` if public), and a test in
`tests/spec.test.tsx`.
When you add a block kind, all ten steps are required. Steps **1, 3, 4, 5, 6 and
7** are enforced by the compiler — skipping any of them fails
`npm run typecheck`; the rest are caught by tests:

1. `src/reconciler/nodes.ts` — the host tag.
2. `src/components.tsx` — the component and its props.
3. `src/reconciler/serialize.ts` — the `case` in `toBlock`.
4. `src/spec/types.ts` — the tuple type, added to the `BlockSpec` union.
5. **`src/registry.ts`** — the `BLOCK_REGISTRY` and `COMPONENT_REGISTRY` entries.
6. `src/spec/compile.ts` — the `case` (the `never` guard will demand it).
7. `src/spec/schema.ts` — the builder, registered in `BLOCK_SCHEMAS`.
8. `src/spec/index.ts` and `src/index.ts` — export the new types.
9. `tests/` — a serialization test **and** a `compileSpec` ↔ JSX parity test,
plus the ordered list in `tests/registry.test.ts`.
10. `samples/`, `samples/README.md`, `llms.txt`, `README.md`, `CHANGELOG.md`.

The same discipline applies to a lint rule: add it to `LINT_RULES` in
`src/registry.ts`, implement it in `src/lint.ts`, list it in
`EMITTED_LINT_RULES`, and add a test — the registry alone cannot catch a rule
that is declared but never emitted.
79 changes: 71 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,69 @@ on:
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/*.md'
- '.github/FUNDING.yml'
- 'LICENSE'
- '.editorconfig'
- '.gitignore'
pull_request:
branches: [main, master]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/*.md'
- '.github/FUNDING.yml'
- 'LICENSE'
- '.editorconfig'
- '.gitignore'

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
ci:
name: Lint · Typecheck · Test · Build
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
fail-fast: false
matrix:
node-version: [20, 22, 24]
# Node 22 is the floor: the pdfnative engine requires it as of 1.6.0.
node-version: [22, 24]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
cache: npm

- name: Install dependencies
run: npm ci

- name: Audit (high severity)
run: npm audit --audit-level=high
# Blocking on what actually ships. The runtime tree is a single dependency
# (react-reconciler); anything high-severity in there is a real
# supply-chain problem and must stop the build.
- name: Audit runtime dependencies (blocking)
run: npm audit --omit=dev --audit-level=high

# Advisory on the dev tree. It is dominated by transitive pins we do not
# control — eslint still ships minimatch@3 — so a blocking gate here would
# sit red on an issue no consumer is exposed to. Reported, not enforced.
- name: Audit dev dependencies (advisory)
continue-on-error: true
run: npm audit --audit-level=high

- name: Typecheck (src + tests + samples)
- name: Type check
run: npm run typecheck:all

- name: Lint
Expand All @@ -50,12 +77,48 @@ jobs:
- name: Test with coverage
run: npm run test:coverage

# Also runs scripts/postbuild.mjs, which repairs and then verifies the
# published artifacts: the `node:` prefix on the dynamic fs import, the
# `'use client'` directive on the client entry only, and that importing
# pure data does not drag in the React reconciler.
- name: Build
run: npm run build

- name: Verify build artifacts
- name: Verify dist output
run: |
test -f dist/index.js
test -f dist/index.cjs
test -f dist/index.d.ts
test -f dist/index.d.cts
test -f dist/client.js
test -f dist/client.cjs
test -f dist/client.d.ts
test -f dist/client.d.cts

# `renderToResponse` advertises Deno, Bun, Edge and Cloudflare Workers.
# A Node `require` cannot catch a specifier a non-Node bundler refuses to
# resolve, so bundle the artifacts the way those runtimes would.
- name: Bundler resolution smoke test
run: |
npx --yes esbuild --bundle --platform=browser --format=esm --outfile=/dev/null \
--external:react --external:react-dom --external:react-reconciler \
--external:pdfnative --external:node:fs/promises dist/index.js
npx --yes esbuild --bundle --platform=browser --format=esm --outfile=/dev/null \
--external:react --external:react-dom --external:react-reconciler \
--external:pdfnative dist/client.js

# `.github/ai-governance.json` declares `advisory_in_ci: true`. Validate any
# AI-authored draft staged for human review. Advisory: it reports, never blocks.
- name: Verify AI-authored drafts (advisory)
continue-on-error: true
run: |
shopt -s nullglob
drafts=(.github/drafts/issue-*.md .github/drafts/pr-*.md release-notes/draft/*.md)
if [ ${#drafts[@]} -eq 0 ]; then
echo "No drafts staged; nothing to verify."
exit 0
fi
for draft in "${drafts[@]}"; do
echo "── $draft"
node scripts/verify-issue.mjs "$draft" || echo " (advisory failure)"
done
Loading
Loading