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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ body:
- document-mcp
- document-outline.js
- document-schema.js
- documents
- documents.js
- epub-codec
- markdown-codec
Expand All @@ -32,6 +31,7 @@ body:
- pdf-codec
- ppt-codec
- rtf-codec
- web
- wpd-codec
- xls-codec
validations:
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ body:
- document-mcp
- document-outline.js
- document-schema.js
- documents
- documents.js
- epub-codec
- markdown-codec
Expand All @@ -30,6 +29,7 @@ body:
- pdf-codec
- ppt-codec
- rtf-codec
- web
- wpd-codec
- xls-codec
validations:
Expand Down
45 changes: 43 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ jobs:

deploy-site:
name: Build and deploy the web UI to Pages
# After release, so the deploy is built from the release commit the orchestrator just pushed (it bumps packages/documents/package.json and tags it). For a commit that releases nothing, no new tag is created and this builds the current tip of main.
# After release, so the deploy is built from the release commit the orchestrator just pushed (it bumps packages/web/package.json and tags it). For a commit that releases nothing, no new tag is created and this builds the current tip of main.
needs:
[commitlint, lint, typecheck, test, test-workers, test-smoke, release]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Expand All @@ -640,6 +640,8 @@ jobs:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
outputs:
page_url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -666,6 +668,45 @@ jobs:
done
- uses: actions/upload-pages-artifact@v5
with:
path: packages/documents/dist
path: packages/web/dist
- id: deployment
uses: actions/deploy-pages@v5

verify-deployed-site:
name: Verify the deployed site's own assets actually resolve
# A 200 on the page URL alone doesn't prove the deploy works: the HTML can serve fine while every asset it references 404s (e.g. a GitHub Pages project-site base-path mismatch, the exact bug this job exists to catch). Fetch the real deployed HTML and confirm every script/stylesheet/icon/manifest it links to actually resolves.
needs: [deploy-site]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fetch the deployed page and verify its referenced assets resolve
env:
PAGE_URL: ${{ needs.deploy-site.outputs.page_url }}
run: |
set -euo pipefail

if [ -z "$PAGE_URL" ]; then
echo "::error::deploy-site's page_url output was empty -- actions/deploy-pages didn't report a URL, so there is nothing to verify."
exit 1
fi

html="$(curl -sf "$PAGE_URL")"
origin="$(printf '%s' "$PAGE_URL" | sed -E 's#^(https?://[^/]+).*#\1#')"

# Every script src and link href (stylesheet/preload/icon/manifest) ending in one of these extensions -- the asset kinds a Vite PWA build actually emits and references from the page. Extracted paths are absolute (the build's own base config guarantees this), so they resolve against the origin alone, never by concatenating onto PAGE_URL itself -- PAGE_URL already carries the base path, so that would double it. grep exits non-zero when nothing matches; `|| true` stops that from tripping `set -e`/pipefail before the explicit empty-result check below gets a chance to report it properly.
assets="$(printf '%s' "$html" | grep -oE '(src|href)="[^"]+\.(js|css|svg|png|webmanifest)"' | sed -E 's/^(src|href)="//; s/"$//' | sort -u || true)"

if [ -z "$assets" ]; then
echo "::error::No JS/CSS/SVG/PNG/webmanifest asset references were found in the HTML at $PAGE_URL -- asset extraction itself is broken, which hides real broken-asset deploys just as effectively as not checking at all."
exit 1
fi

while IFS= read -r asset; do
[ -z "$asset" ] && continue
asset_url="${origin}${asset}"
if ! curl -sf -o /dev/null "$asset_url"; then
echo "::error::Deployed asset failed to resolve: $asset_url (referenced from $PAGE_URL)"
exit 1
fi
done <<< "$assets"
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Each exposes the conversion engine through a different surface:
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`document-cli`](packages/document-cli/README.md) | CLI and interactive Ink TUI covering every docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/markdown conversion, bridge, and editor as a scriptable command or a terminal app. |
| [`document-mcp`](packages/document-mcp/README.md) | MCP server exposing the conversion, `.odb`, metadata, and font tooling as MCP tools. |
| [`documents`](packages/documents/README.md) | Client-only, statically-built web UI for every conversion and editing tool in the ecosystem, also depending directly on `markdown-codec`. The one package here that is never published: it deploys to GitHub Pages instead. |
| [`web`](packages/web/README.md) | Client-only, statically-built web UI for every conversion and editing tool in the ecosystem, also depending directly on `markdown-codec`. The one package here that is never published: it deploys to GitHub Pages instead. |

## PDF is an equal peer, not a junction

Expand Down Expand Up @@ -93,7 +93,7 @@ Individual packages set their own build and test configuration, but as a family
- TypeScript with Zod 4 for schema definition and validation.
- MIT licensing.
- Hand-written, dependency-minimal codecs over pulling in heavyweight format libraries — see each package's own README for what it deliberately avoids depending on.
- The foundation and format-codec packages (`byte-codec`, `document-schema.js`, `document-outline.js`, `archive-codec`, `document-compute.js`, `excel-number-format`, `ooxml.js`, `odf.js`, `markdown-codec`, `pdf-codec`, `epub-codec`, `rtf-codec`, `wpd-codec`, `doc-codec`, `xls-codec`, `ppt-codec`, `documents.js`) are Worker-isomorphic: their published `src/` must not import `node:*`/bare Node builtins or use the Node-only `Buffer` global. The `no-restricted-imports`/`no-restricted-globals` ban enforcing that is defined once in the root's `eslint.shared.ts`, which derives the module list from `node:module`'s own `builtinModules` rather than restating it; a package opts in by passing `isomorphic: true` to `packageLintConfig` rather than declaring the rule itself, and a workerd test suite proves it at runtime. The interface packages (`document-cli`, `document-mcp`, `documents`) are not held to this, since they legitimately run under Node or a browser rather than needing Worker portability.
- The foundation and format-codec packages (`byte-codec`, `document-schema.js`, `document-outline.js`, `archive-codec`, `document-compute.js`, `excel-number-format`, `ooxml.js`, `odf.js`, `markdown-codec`, `pdf-codec`, `epub-codec`, `rtf-codec`, `wpd-codec`, `doc-codec`, `xls-codec`, `ppt-codec`, `documents.js`) are Worker-isomorphic: their published `src/` must not import `node:*`/bare Node builtins or use the Node-only `Buffer` global. The `no-restricted-imports`/`no-restricted-globals` ban enforcing that is defined once in the root's `eslint.shared.ts`, which derives the module list from `node:module`'s own `builtinModules` rather than restating it; a package opts in by passing `isomorphic: true` to `packageLintConfig` rather than declaring the rule itself, and a workerd test suite proves it at runtime. The interface packages (`document-cli`, `document-mcp`, `web`) are not held to this, since they legitimately run under Node or a browser rather than needing Worker portability.

## Working in the workspace

Expand All @@ -116,7 +116,7 @@ To scope a run, drive turbo directly rather than adding a filter to the scripts
```sh
pnpm exec turbo run _test --filter=pdf-codec # one package and its dependencies
pnpm exec turbo run _test _build --affected # whatever the current branch changed
pnpm exec turbo run documents#test # the web UI alone
pnpm exec turbo run web#test # the web UI alone
```

Each package also keeps its own scripts, so `pnpm --dir packages/odf.js test:watch` (or running the script from inside that directory) still works for focused work on a single package.
Expand All @@ -125,7 +125,7 @@ Each package also keeps its own scripts, so `pnpm --dir packages/odf.js test:wat

`turbo.json` is the whole story, and two details in it are worth knowing before editing it.

The tasks the root pipeline runs are the underscore-prefixed ones (`_build`, `_lint`, `_test`, …). Each package already used that convention: its public `build` script was `turbo run _build`, and `_build` held the real command. Running the public names from the root would make turbo invoke those wrapper scripts, which invoke turbo again — the recursive-call case Turborepo's own documentation warns about. The root reaches straight past the wrappers to the leaf commands, and the wrappers stay usable inside a single package. The web UI predates the convention and names its scripts plainly, so it gets package-scoped `documents#…` entries instead.
The tasks the root pipeline runs are the underscore-prefixed ones (`_build`, `_lint`, `_test`, …). Each package already used that convention: its public `build` script was `turbo run _build`, and `_build` held the real command. Running the public names from the root would make turbo invoke those wrapper scripts, which invoke turbo again — the recursive-call case Turborepo's own documentation warns about. The root reaches straight past the wrappers to the leaf commands, and the wrappers stay usable inside a single package. The web UI predates the convention and names its scripts plainly, so it gets package-scoped `web#…` entries instead.

Every task depends on `^_build` — its dependencies' builds. In the separate repositories a sibling arrived pre-built from the npm registry, so nothing needed building before a typecheck, lint, or test run. Here a sibling is a symlink into `packages/<name>`, whose `dist/` exists only once that package's own build has run, and `tsc`, type-aware ESLint, and vitest all resolve imports through it.

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ These packages parse untrusted binary and text input by design — OOXML and Ope
- Prototype pollution through a key taken from document content.
- Anything that lets a document's bytes reach the filesystem, network, or a subprocess.

`documents` (the web UI) is client-only and statically built, so its scope is the usual browser surface: XSS from document content rendered into the page, and anything that escapes the worker sandbox.
`web` (the web UI) is client-only and statically built, so its scope is the usual browser surface: XSS from document content rendered into the page, and anything that escapes the worker sandbox.

## What is out of scope

Expand Down
2 changes: 1 addition & 1 deletion knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const config: KnipConfig = {
// The web UI, restated in full rather than extended. knip resolves a `workspaces` key to the single most specific match and uses it verbatim -- a glob entry and an exact entry do not merge -- so every field this needs has to appear here even where it repeats the glob above.
//
// Its entries are genuinely different: it is a private Vite app that publishes nothing and has no exports map, so nothing is reachable through a package subpath. The real roots are the HTML document, the browser Web Worker (reached through `new Worker(new URL(...))`, which is not a static import), and the generated router tree.
"packages/documents": {
"packages/web": {
// Only the Web Worker needs naming. knip's Vite plugin already finds index.html, src/main.tsx, the generated router tree, and vite.config.ts; the worker is reached through `new Worker(new URL(...))`, which is not a static import and so is invisible to it.
entry: ["src/workers/documents.worker.ts"],
project: ["src/**/*.{ts,tsx}"],
Expand Down
2 changes: 1 addition & 1 deletion packages/document-compute.js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Quoting the issue's own scope line directly: **this is deliberately not a CAS in
- Worker-isomorphic (see the [family-wide convention](https://github.com/ExaDev/documents.js/blob/main/README.md#conventions)): runtime `src/` must not import `node:*`, a bare Node builtin, or use the `Buffer` global — enforced by a `no-restricted-imports`/`no-restricted-globals` ESLint rule and exercised in CI by running a test suite inside an actual `workerd` isolate (`pnpm test:workers`). Exact-rational arithmetic is plain `BigInt`, never `node:crypto` or any other Node-only primitive, precisely so this holds.
- Only `src/index.ts` may be named `index.*` — a custom ESLint rule (`local/no-non-barrel-index`) rejects any other module using an `index` basename, since that would be a hidden entry point the `exports` map in `package.json` doesn't advertise.
- Failure is always a thrown, named `Error` subclass (`compute/errors.ts`), never a `{ ok, error }` result wrapper — matching `document-schema.js`'s `schema-io.ts` and `archive-codec`'s own error classes rather than inventing a second convention for this package alone.
- Not wired into the conversion pipeline. This package is a standalone evaluator: it is not a dependency of `documents.js`, `document-cli`, `document-mcp`, or `documents`, and adding it as one is a separate, deliberate decision for whichever of those surfaces first needs a document's formula actually computed.
- Not wired into the conversion pipeline. This package is a standalone evaluator: it is not a dependency of `documents.js`, `document-cli`, `document-mcp`, or `web`, and adding it as one is a separate, deliberate decision for whichever of those surfaces first needs a document's formula actually computed.
- Release, CI, and commit-message conventions are all workspace-wide, not package-local — see the [monorepo root README](../../README.md#releases) for the mechanism (topological per-package `semantic-release` via `@exadev/semantic-release-workspace`, OIDC trusted npm publishing, and the post-release republish/attestation jobs).

## Install
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
6 changes: 3 additions & 3 deletions packages/documents/README.md → packages/web/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# documents
# web

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/documents) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)
[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/web) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)

> A client-only, statically-built web UI for every conversion and editing tool in the [documents.js ecosystem](../../README.md) — convert and edit docx, pptx, xlsx, odt, odp, ods, odg, csv, svg, pdf, and markdown documents entirely in the browser, with no server component.

Expand Down Expand Up @@ -51,7 +51,7 @@ The app is split into a main-thread UI and a Web Worker that holds the only code

## Gotchas

- The production build is served from `/documents/` on GitHub Pages (set via `base` in `vite.config.ts` when `CI` is set) but from `/` in local dev — a build produced locally with `CI` unset will have the wrong base path if deployed as-is.
- The production build is served from `/<repo-name>/` on GitHub Pages (`/documents.js/` today) but from `/` in local dev — `base` in `vite.config.ts` derives the path segment from `GITHUB_REPOSITORY` when `CI` is set, falling back to the name parsed from the git remote, so it never needs a hand-maintained literal and can't silently drift from the actual deploying repository. A build produced locally with `CI` unset will have the wrong base path if deployed as-is.
- This is a PWA (`vite-plugin-pwa`, `autoUpdate`). The worker bundle (by far the largest built asset) is deliberately excluded from the Workbox precache list and instead cached at runtime on first use via a `CacheFirst` rule, so it doesn't block install or blow the default precache size budget.
- `src/workers/documents.worker.ts` is a browser Web Worker, unrelated to Cloudflare Workers — this repo has no `wrangler` config and doesn't deploy to Cloudflare, unlike some sibling packages in the ecosystem.

Expand Down
File renamed without changes.
File renamed without changes.
6 changes: 3 additions & 3 deletions packages/documents/package.json → packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"name": "documents",
"name": "web",
"private": true,
"version": "2.3.2",
"type": "module",
"description": "A client-only, statically-built web UI for every conversion and editing tool in the documents.js ecosystem.",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/documents.js.git",
"directory": "packages/documents"
"directory": "packages/web"
},
"homepage": "https://github.com/ExaDev/documents.js/tree/main/packages/documents",
"homepage": "https://github.com/ExaDev/documents.js/tree/main/packages/web",
"bugs": {
"url": "https://github.com/ExaDev/documents.js/issues"
},
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading