spora-ai / spora-plugin-typst-frontend
Pre-built Vue SPA for the Spora Typst admin panel. Delivered as type `spora-plugin-frontend`; the host SPA lazy-loads it via /plugins/typst/main.js (the parent plugin's `plugin.json#slug`, not this package's name).
Package info
github.com/spora-ai/spora-plugin-typst-frontend
Language:TypeScript
Type:spora-plugin-frontend
pkg:composer/spora-ai/spora-plugin-typst-frontend
Requires
- php: ^8.4.1
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 23:52:55 UTC
README
Vue 3 admin SPA for spora-plugin-typst. Built and shipped as Composer type spora-plugin-frontend; the host SPA (spora-frontend) lazy-loads it via /plugins/typst/main.js — the parent plugin's plugin.json#slug, not this package's name.
What's in the box
Five tabs behind the plugin's /apps/typst admin slot:
| Tab | Purpose | Backend endpoints consumed |
|---|---|---|
| Fonts | Drag-drop upload + table view of the principal's font library. Skill-shipped Inter OFL is read-only; principal uploads can be deleted. | GET /typst/fonts, POST /typst/fonts, DELETE /typst/fonts/{name} |
| Templates | Reusable .typ template upload + card view with inline source preview. Skill-shipped vs principal distinction as fonts. |
GET /typst/templates, POST /typst/templates, DELETE /typst/templates/{name} |
| Examples | .typ example upload + card view with inline source preview. Same skill-shipped vs principal distinction as fonts. |
GET /typst/examples, POST /typst/examples, DELETE /typst/examples/{name} |
| Images | Image upload + thumbnail grid. Each row carries the canonical /api/v1/assets/<uuid>.<ext> URL — copyable into Typst #image("…") source. |
GET /typst/images, POST /typst/images, DELETE /typst/images/{name} |
| Playground | Single-shot Typst editor with format selector. POST /api/v1/typst/compile is always available; failures surface in the in-page error banner. |
POST /api/v1/typst/compile, GET /api/v1/typst/sources[?principal_id=N&kind=…] |
Layout
.
├── composer.json # type: spora-plugin-frontend
├── frontend/ # build output (gitignored except for the archive step)
│ ├── main.js
│ └── style.css
├── src/
│ ├── App.vue # entry component (single page)
│ ├── main.ts # IIFE bundle; exposes window.SporaAppTypst
│ ├── dev-main.ts # standalone `npm run dev` mount
│ ├── style.css # @tailwind components/utilities (no preflight — host owns the reset)
│ ├── shims.d.ts # PluginHostContext contract
│ ├── types.ts # wire shapes for the resource kinds
│ ├── i18n/
│ │ └── index.ts # the plugin's own vue-i18n instance
│ ├── locales/
│ │ ├── en.json # generated identity map (npm run i18n:extract)
│ │ └── de.json # the German catalogue
│ ├── api/
│ │ ├── client.ts # setApi/getApi/ApiError — bridge to host's typed REST client
│ │ ├── fonts.ts
│ │ ├── templates.ts
│ │ ├── examples.ts
│ │ ├── images.ts
│ │ ├── sources.ts
│ │ ├── compile.ts
│ │ ├── principals.ts
│ │ └── media-archive.ts
│ ├── stores/
│ │ ├── resources.ts # fonts + templates + examples (tier-1 + tier-2)
│ │ ├── images.ts # per-principal image library
│ │ ├── sources.ts # playground source rows
│ │ └── principals.ts # principal chip row
│ ├── composables/
│ │ └── useResourceCardList.ts # shared card-list logic for templates + examples
│ ├── components/
│ │ ├── AlertBanner.vue
│ │ ├── FontUploader.vue
│ │ ├── FontList.vue
│ │ ├── TemplateUploader.vue
│ │ ├── TemplateList.vue
│ │ ├── ExampleUploader.vue
│ │ ├── ExampleList.vue
│ │ ├── ImageUploader.vue
│ │ ├── ImageList.vue
│ │ ├── PrincipalChipRow.vue
│ │ ├── OpenPickerModal.vue
│ │ ├── ResourceCardList.vue
│ │ └── CompileForm.vue
│ └── pages/
│ └── TypstPage.vue # 5-tab page (single route)
├── scripts/
│ ├── smoke.js # static-analysis guard on the IIFE bundle + stylesheet scope
│ └── clean.js
├── tests/
│ ├── api/
│ │ ├── client.test.ts
│ │ ├── compile.test.ts
│ │ └── templates.test.ts
│ ├── stores/
│ │ ├── images.test.ts
│ │ ├── resources.test.ts
│ │ ├── resources-examples.test.ts
│ │ ├── sources.test.ts
│ │ └── sources-principal.test.ts
│ ├── components/
│ │ ├── FontList.test.ts
│ │ └── OpenPickerModal.test.ts
│ ├── composables/
│ │ └── useResourceCardList.test.ts
│ └── pages/
├── .github/workflows/ci.yml # lint + test + build + smoke + size-budget
└── index.html # `npm run dev` mount target
Architectural invariants
Plugin boundary
The bundle declares important: '#spora-plugin-typst' in Tailwind config — every generated utility is scoped beneath that selector so plugin classes can't leak into the host SPA or another plugin slot. The smoke check in CI asserts this guard is in place (rejects unscoped .text-typst-* utilities) so a future Tailwind config drift fails the build.
corePlugins.preflight: false — Tailwind's global reset is omitted because the host SPA owns document-level resets. The plugin's stylesheet sits inside the slot and inherits the host's cascade.
Host contract
The bundle is mounted by spora-frontend's PluginAppPage.vue, which passes a PluginHostContext to mount(). The context exposes api (typed REST client with CSRF + envelope unwrap), pinia (host's Pinia — we don't setActivePinia(host.pinia) because that would collide with the host's stores), theme, and router. The plugin installs a local Pinia for plugin-only state (the resource store, the image store, the sources store, the principals store).
api/client.ts → setApi(...) is called once per mount(). The rest of the plugin reads through getApi() at call-time, so the host's CSRF token / base URL / envelope unwrap is preserved without re-implementation.
Architectural distinction from fonts/templates/examples
Fonts, templates, examples, and images are all plugin-private filesystem files under <storage>/typst/<kind>/<principal>/<name>. The Playground tab also surfaces the cross-plugin media archive (GET /api/v1/media?types=image) for operators who want to pull in images uploaded by other plugins.
Quality gates
npm run lint ESLint over src/, tests/, *.ts, *.vue
npm test Vitest (~30+ cases, parallel: --parallel --processes=auto)
npm run build vue-tsc --noEmit + vite build → frontend/main.js + frontend/style.css
npm run smoke Static-analysis guard on the IIFE bundle + stylesheet scope
npm run i18n:check Catalogue gate — see "Languages" below
The CI pipeline runs all of them on every push and PR. A size-budget job fails if main.js + style.css exceed 280 KB pre-gzip.
Languages
The panel ships its own vue-i18n instance (src/i18n/index.ts) built from its own catalogue — the host does not publish its instance. The locale comes from the host through PluginHostContext.locale; the dev sandbox reads it from ?locale=de.
src/locales/en.json generated identity map — every key maps to itself
src/locales/de.json the German catalogue
The catalogue key is the English source string — t('Save & Render'), not a slug. That means a copy edit orphans the German entry, which is what the extractor's orphan rule catches.
npm run i18n:extract regenerate en.json from the source and report what is untranslated
npm run i18n:check the CI gate: en.json drift, a locale's key set, untranslated values, and copy outside t()
npm run i18n:check fails on an operator-facing string that is not wrapped in t(). The strings that stay unwrapped are the ones that read the same in every language — Typst, Principal, PPI, URL, Esc — plus the technical identifiers (file names, URL paths, Typst snippets) that are held in script constants.
Depends on
spora-ai/spora-plugin-typst — the backend plugin must be merged first; the frontend consumes:
GET /typst/fonts,POST /typst/fonts,DELETE /typst/fonts/{name}GET /typst/templates,POST /typst/templates,DELETE /typst/templates/{name}GET /typst/examples,POST /typst/examples,DELETE /typst/examples/{name}GET /typst/images,POST /typst/images,DELETE /typst/images/{name}GET /api/v1/typst/sources[?principal_id=N&kind=…]POST /api/v1/typst/sources[?principal_id=N]GET/PUT/DELETE /api/v1/typst/sources/{id}[?principal_id=N]POST /api/v1/typst/compile
Until then, the operator uses Copy source to drop the source into the chat composer for the agent to render via typst_render.
Local development
npm install npm run dev # serves on http://localhost:5176 — needs the host SPA's plugin dev-proxy # (`SPORA_PLUGIN_DEV_PORTS=typst:5176 npm run dev` in spora-frontend)
Or stand up the host (spora-local or spora-fgrassl) with spora-plugin-typst installed and visit /apps/typst — the host lazy-loads frontend/main.js from the typst plugin's public/plugins/typst/ directory.
Releasing
- Build the IIFE bundle:
npm run build - Create a tarball:
composer archive --file=spora-plugin-typst-frontend-v0.1.0.tar.gz spora-plugin-typst-frontend(uses thearchive.excludelist incomposer.json) - Tag + push:
git tag v0.1.0 && git push --tags - Attach the tarball to the GitHub release. The
dist.urlincomposer.jsonmust match the uploaded asset URL.
SporaPluginFrontendInstaller reads composer.json#extra.spora-plugin-slug (must equal 'typst' — same as spora-plugin-typst/plugin.json#slug) and the dist URL to know where to pull the bundle from.
Authoring guidelines
Framework-level conventions — which classes are plugin-stable, what's framework-internal, schema versioning, deprecation policy — live in the Spora docs → Plugin system. This plugin's PHP twin is the source of truth for the wire contracts the SPA talks to; any breaking change there propagates here as a composer update + npm run build cycle.