@samva/vite 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,88 +1,92 @@
1
- # Samva Vite consumer guide
1
+ # @samva/vite agent guide
2
2
 
3
- Install `@samva/markup`, `@samva/vite`, and the Vite peer in the template project. Add
4
- `samvaEditor()` to `vite.config.ts` for the visual editor. Use `@samva/vite/build` only for
5
- programmatic template builds and `@samva/vite/emit` only for module-preview integration.
3
+ `@samva/vite` is the local authoring toolchain for Samva templates written with `@samva/markup`: a
4
+ Vite plugin that serves the visual editor, and programmatic entry points that build, render, and
5
+ export a template project. It compiles nothing itself; every render goes through
6
+ `@samva/markup/compiler`'s `compileEmailProject`, the same compile step a hosted Samva build runs, so
7
+ a local render does not drift from a published one. Publishing and hosted synchronization are not
8
+ here: they are ordinary Git plus `samva templates publish` from `@samva/cli` (see
9
+ `samva templates --help`).
6
10
 
7
- The CLI loads `@samva/vite/build` from the current project. Keep these authoring dependencies in the
8
- project lockfile so local preview, check, and build use the same compiler version as the project.
11
+ ## Entry points
9
12
 
10
- This package is plain TypeScript like `@samva/markup`, and adds no runtime framework of its own to
11
- a template project. `@samva/vite/build` and the preview surfaces answer with discriminated results
12
- carrying an `ok` field, and `@samva/vite/emit` passes the compiler's better-result `Result`
13
- through. A missing `vite`, or an entry that does not default-export a template definition, throws.
13
+ - `@samva/vite` — `samvaEditor()`, the plugin you add to `vite.config.ts`. Options are typed by
14
+ `SamvaEditorPluginOptions`; the README lists them.
15
+ - `@samva/vite/build` — `buildTemplates()` and `createTemplateBuildSession()` for programmatic
16
+ checks and builds. The `samva` CLI loads this module from your project, not from its own bundle.
17
+ - `@samva/vite/project` — the folder-of-templates surface: `loadEmailCatalog()`,
18
+ `renderEmailFixtures()`, `createEmailProjectSession()`, `exportEmailProject()`.
19
+ - `@samva/vite/email` — `createEmailRenderer()` and `renderEmailProject()` for rendering one email
20
+ project from source.
21
+ - `@samva/vite/emit` — module-preview integration for structured SMS and WhatsApp entries.
14
22
 
15
- Hosted synchronization is ordinary Git. A CLI-created project has a repository-local Samva
16
- credential helper, and `samva templates publish` accepts only a clean exact commit reachable from
17
- the fetched `origin` default branch. Publish creates an immutable receipt; it does not push or
18
- rewrite history.
23
+ Install `@samva/markup`, `@samva/vite`, and the `vite` peer as dev dependencies of the template
24
+ project and keep them in its lockfile. The CLI's `templates check`, `templates build`, and the
25
+ editor all resolve these packages from the project, so the lockfile is what pins the compiler
26
+ version your previews, checks, and builds use.
19
27
 
20
- Template discovery is `.tsx` only. The local email editor writes the canonical TSX entry with a
21
- revision check and a fresh comparison against disk; SMS and WhatsApp stay read-only. A change
22
- published without a client is what the editor reads as a foreign edit, so nothing the editor itself
23
- did may reach it that way. A save and a fixture switch both run in the store's mutation queue, so no
24
- rebuild can straddle one and publish the difference anonymously; the editor's write comes back
25
- through the file watcher like any other edit, and a changed file that still holds the bytes its
26
- entry was built from re-evaluates nothing. Watcher events arrive in bursts and one rebuild re-reads
27
- the whole project, so a rebuild that has not started yet covers every event queued before it. Every discovered entry is imported once through the dev
28
- server's own SSR graph, and a default export that is a template definition takes the email lane —
29
- compiled and rendered through `compileEmailProject`, so a local render cannot drift from a published
30
- one. Email requires an executable definition. Structured SMS/WhatsApp entries emit SML, and
31
- for SMS and WhatsApp an invalid current TSX keeps and visibly labels the last successful emission
32
- while the same file remains discovered; deleting the file removes it.
28
+ ## How you hurt yourself here
33
29
 
34
- The email document is one executed render of one declared fixture: the fixture names the template
35
- declares, the fixture being viewed, the render stamped with `data-samva-instance`, and the compile
36
- and client findings taken from rendering every declared fixture as it will be delivered. An entry
37
- that does not build keeps its place with a null render and the findings that stopped it. Its
38
- revision is a digest of authored source and what the declared fixtures render, so source locations
39
- cannot stay pinned after a formatting-only edit. It stays put when only the viewed fixture changes. The dev server is not an https origin, so a local
40
- render points at the editor's own asset route by path — every render here is a preview or the input
41
- to a finding, and the base a publication serves from is a build input rather than authored.
30
+ - **Expecting your Vite config to apply to email rendering.** Every email render, in the editor,
31
+ the CLI, and the programmatic entry points, runs in an evaluator this package roots at its own
32
+ directory with no config file, so your `vite.config.ts` aliases and plugins never reach an email
33
+ template. Bare imports resolve from the package root, which is what makes `@samva/markup` resolve
34
+ under both hoisted and per-package installs. Import shared helpers by relative path; an alias that
35
+ works elsewhere in your app fails here.
36
+ - **Side effects at module scope.** Discovery imports every `.tsx` under the templates directory
37
+ and asks whether its default export is a template definition, and a rebuild re-reads the whole
38
+ project. Top-level code in every module, helpers included, runs whenever the project is
39
+ evaluated; keep it free of I/O and mutable global state.
40
+ - **Relying on a file path as template identity.** Identity is the `id` the definition declares.
41
+ Two definitions declaring one id is a reported error, and moving a file does not change its id.
42
+ Helper modules need no marker or exclusion list: a module is a template only if its default export
43
+ is a definition.
44
+ - **Exporting for delivery with the default asset base.** `exportEmailProject` renders with relative
45
+ URLs and writes each page at `templates/<id>/<fixture>.html` beside a content-addressed `assets/`
46
+ directory, so its default base, `DEFAULT_EXPORT_ASSET_BASE`, only works when those files are
47
+ browsed from disk. An inbox has no directory to resolve a relative URL against, so delivery refuses
48
+ one. For an export you will send, pass the https origin that will serve the assets.
49
+ - **Reusing a renderer without a stable namespace.** `createEmailRenderer` keeps one evaluator
50
+ across renders, as a dev server does, and gives each project its own module-id namespace. Omit
51
+ `namespace` and every `load` takes a fresh one, which isolates unrelated projects but grows the
52
+ graph on every reload. A watch loop over one project passes the same `namespace` each time so its
53
+ ids stay stable. Each `load` re-evaluates its own namespace's modules; `invalidate()` also drops
54
+ the modules namespaces share. `close()` releases the evaluator.
55
+ - **Expecting the email editor to overwrite concurrent edits.** The editor writes back to the
56
+ entry's canonical `.tsx` source after checking that the file on disk is still the revision it
57
+ read, so a change you saved elsewhere is not overwritten; reload the entry and retry. SMS and
58
+ WhatsApp entries are read-only in the editor.
59
+ - **Committing or hand-editing `.samva/brands`.** `samva:brand` resolves live first and falls
60
+ back to the snapshot `samva brands pull` writes there, warning when it is older than the live
61
+ brand. The API owns the brand, so the directory is gitignored and refreshed with `pull`; a
62
+ project with neither credentials nor a snapshot fails with `brand-unavailable`.
42
63
 
43
- Object email projects compile through `@samva/vite/email`. It is an adapter: the whole compile step
44
- is `@samva/markup/compiler`'s `compileEmailProject`, the same call the hosted build makes, and this
45
- package owns only module resolution and evaluation. The asset base is an explicit input, so a
46
- build and an export can address the same content from different origins. Both entry points evaluate
47
- the compiler's generated wrapper rather than the authored entry: the render has to happen inside the
48
- graph the project's stylesheets registered into.
64
+ ## Rules
49
65
 
50
- `createEmailRenderer` keeps one evaluator across renders, as a dev server does, and gives each
51
- project its own id namespace so their modules coexist. Anything the compiler rewrote into
52
- JavaScript — a stylesheet, an imported asset — is served under a `.js` id; its authored extension
53
- would be claimed by Vite's CSS or asset pipeline. A caller that reloads the same project passes a
54
- stable `namespace` so ids survive the reload and `invalidate()` is what makes changed source take
55
- effect; omitting it takes a fresh namespace, which is what isolates unrelated projects.
66
+ - **Template entries are `.tsx`.** Discovery ignores every other extension.
67
+ - **Template ids and fixture keys match `TEMPLATE_NAME_PATTERN`** from `@samva/markup/template`,
68
+ because both are written into export paths. Code that builds a path from them imports that
69
+ pattern instead of restating it.
70
+ - **Fixtures are validated against the template's own schema before they render.** A bad fixture is
71
+ a finding on that template, not a runtime surprise at send time.
72
+ - **One failure stays with its template.** A refused render reports against its fixture, and an
73
+ email entry that does not build stays in the catalog with a null render and the findings that
74
+ stopped it. SMS and WhatsApp entries that stop compiling keep their last successful preview,
75
+ labeled stale, until the file compiles again or is deleted.
76
+ - **Results are values.** `@samva/vite/build` and the preview surfaces return discriminated results
77
+ with an `ok` field, and `@samva/vite/emit` passes through the compiler's better-result `Result`. A
78
+ missing `vite` peer, or an entry whose default export is not a template definition where one is
79
+ required, throws.
80
+ - **The asset base is a build input, not authored source.** The same content can be built for one
81
+ origin and exported for another. The dev server is not an https origin, so editor previews
82
+ reference assets through the editor's own route by path.
56
83
 
57
- `@samva/vite/project` is the folder-of-templates surface. A project holds many definitions with
58
- shared components and CSS between them; which `.tsx` files are templates is decided by evaluating
59
- the compiler's generated wrapper and asking whether the default export is a template definition, so
60
- an ordinary helper module needs no marker and no exclusion list. Identity is the id the definition
61
- declares, never its path, and two definitions declaring one id is a reported error. Fixtures are
62
- validated against the template's own schema before they render, so a bad fixture is a source defect
63
- the author sees locally.
84
+ ## Proving your setup
64
85
 
65
- `exportEmailProject` renders in the compiler's export mode (`relativeUrls: true`). It writes one
66
- page at `templates/<id>/<fixture>.html` and every asset once under `assets/`, so its default
67
- `../../assets` base is the path from a page's own directory to those bytes. Delivery keeps
68
- refusing a relative reference — an inbox has no directory to resolve one against — so an export
69
- destined for sending names the https origin it will be served from instead. Either way the export
70
- writes each asset under its content address and records the base, so the manifest is the whole
71
- mapping.
72
-
73
- A refused render reports against the one fixture rather than throwing at the caller: one template
74
- that cannot render must not take the rest of the catalog with it.
75
-
76
- The evaluator roots itself at this package's own directory and refuses an ambient Vite config.
77
- Virtual ids have no directory, so bare imports resolve against the server root; rooting here is what
78
- makes `@samva/markup` resolve under a per-package install as well as a hoisted one.
79
-
80
- A golden-digest test in `tests/email-parity.test.ts` pins this adapter's output against the same
81
- value the hosted build asserts in `apps/agents/tests/workerd/email-parity.test.ts`.
82
-
83
- ## Verification
84
-
85
- `bun run --filter @samva/vite test:browser` builds the plugin and embedded editor, then runs the
86
- Chromium source-editing integration tests. `test:browser:cross` runs Chromium, Firefox, and WebKit.
87
- These tests own temporary template projects and plugin servers; the editor library's browser suite
88
- tests its standalone host separately.
86
+ - `samva templates check --dir <templates-dir>` executes every declared fixture and reports
87
+ findings; `--watch` keeps it running while you edit.
88
+ - `vite` with `samvaEditor()` configured serves the editor at the configured `route`; every entry
89
+ should appear with its fixtures.
90
+ - `exportEmailProject({ dir, out })` writes browsable HTML and text for every fixture and a
91
+ `catalog.json` with the asset mapping and findings, without Samva credentials unless the project
92
+ imports `samva:brand` and has no snapshot.
package/README.md CHANGED
@@ -102,7 +102,9 @@ client-specific proof.
102
102
 
103
103
  ## Theme, check, and publish
104
104
 
105
- A root `theme.css` uses Tailwind v4 `@theme` syntax. Theme changes recompile
105
+ A root `theme.css` uses Tailwind v4 `@theme` syntax and may `@import` other project `.css` files
106
+ and `samva:brand`; any other import is a compile error. Changes to the theme or a stylesheet it
107
+ imports recompile
106
108
  local previews while preserving the last valid theme when an edit is temporarily
107
109
  invalid.
108
110
 
@@ -118,7 +120,27 @@ per rebuild and `--json` is one-shot. A successful fixture check does not prove
118
120
  input branch.
119
121
 
120
122
  Imported email assets require `assetBase` in `buildTemplates()` or `--asset-base` in CLI checks
121
- and builds, naming their actual HTTPS serving base. No credentials are needed for local work.
123
+ and builds, naming their actual HTTPS serving base.
124
+
125
+ ## The organization brand
126
+
127
+ A project that imports `samva:brand` (the default brand) or `samva:brand/<slug>` compiles against
128
+ its organization's brand. The editor, `buildTemplates()`, and `exportEmailProject()` resolve it in
129
+ this order:
130
+
131
+ 1. **Live.** With `SAMVA_API_KEY` in the environment or the project's `.env` (and `SAMVA_API_URL`
132
+ for another API), the brand is fetched directly. Without one, the `samva` CLI on `PATH` is asked
133
+ (`samva brands get --json`) and answers with your `samva login` session, which stays in the
134
+ operating-system keyring the CLI owns.
135
+ 2. **Snapshot.** `.samva/brands/<slug>.json`, written by `samva brands pull [slug]`, with a
136
+ `brand-offline` warning.
137
+ 3. **Neither.** The build fails with `brand-unavailable`, naming both fixes.
138
+
139
+ When the live brand's digest differs from the snapshot's, the build warns with
140
+ `brand-snapshot-stale`; run `samva brands pull` again. Warnings are build diagnostics and editor
141
+ log lines; they never fail a build. The editor and `check --watch` reuse a resolution briefly,
142
+ so a brand edited elsewhere arrives within seconds rather than on the next save. Keep `.samva` in
143
+ `.gitignore`. A project that imports no brand needs no credentials for local work.
122
144
 
123
145
  For a hosted project, commit and push the checked TSX tree with ordinary Git, then publish one exact
124
146
  commit:
@@ -145,12 +167,14 @@ publication by default; later source edits do not change that publication.
145
167
  ```ts
146
168
  import { exportEmailProject } from "@samva/vite/project";
147
169
 
148
- const receipt = await exportEmailProject({ dir: ".", out: "dist/email" });
170
+ const receipt = await exportEmailProject({ dir: "emails", root: ".", out: "dist/email" });
149
171
  ```
150
172
 
173
+ `dir` holds the templates and `root` (default `dir`) is the project: its `theme.css` themes the
174
+ export, as in `buildTemplates()`, `theme` names another file or `false` disables it, and
175
+ `samva:brand` resolves as described above.
176
+
151
177
  Export writes `templates/<id>/<fixture>.html` and `.txt`, content-addressed files under `assets/`,
152
178
  and `catalog.json` with the asset mapping and findings. The default `../../assets` base resolves
153
179
  from each exported page. For delivery, supply the actual HTTPS `assetBase` and serve those bytes
154
180
  there; relative URLs are for local export. External HTTPS images stay external references.
155
-
156
- The runnable `examples/email-starter` workspace exercises the authoring setup.
package/dist/build.js CHANGED
@@ -1,7 +1,7 @@
1
- import { n as resolveThemePath, r as discoverTemplateFiles, t as readProjectThemeCss } from "./theme-CBF6Czjm.js";
1
+ import { c as projectThemePath, f as brandDiagnostics, i as isTemplateDefinition, l as readProjectThemeCss, m as loadBrandEnv, o as readEmailProject, p as createBrandResolver, s as renderEmailFixtures, u as resolveThemePath } from "./project-RON2Ei7d.js";
2
+ import { t as discoverTemplateFiles } from "./discovery-D2eR-rzi.js";
2
3
  import { COMPILER_VERSION, emitModulePreview } from "./emit.js";
3
4
  import { createEmailRenderer } from "./email.js";
4
- import { isTemplateDefinition, readEmailProject, renderEmailFixtures } from "./project.js";
5
5
  import { dirname, parse, relative, resolve, sep } from "node:path";
6
6
  //#region src/build.ts
7
7
  /** Execute each email definition's fixtures; compile structured SMS/WhatsApp through their channel compiler. */
@@ -38,17 +38,30 @@ const createTemplateBuildSession = async (dir, options = {}) => {
38
38
  let closed = false;
39
39
  let emailRenderer;
40
40
  let previousTheme;
41
+ let previousBrand;
42
+ const brands = createBrandResolver({
43
+ root,
44
+ env: await loadBrandEnv(root)
45
+ });
41
46
  const build = async () => {
42
47
  server.moduleGraph.invalidateAll();
43
48
  const themeCss = await readProjectThemeCss(themePath);
44
- if (emailRenderer !== void 0 && themeCss !== previousTheme) {
49
+ const files = await discoverTemplateFiles(templatesDir);
50
+ const sourceFiles = await readEmailProject(root);
51
+ const themeFile = themePath === void 0 ? void 0 : projectThemePath(root, themePath);
52
+ const brandResolution = await brands.resolve(themeFile === void 0 || themeCss === void 0 ? sourceFiles : {
53
+ ...sourceFiles,
54
+ [themeFile]: themeCss
55
+ }, themeFile);
56
+ const brand = brandResolution.ok ? brandResolution.brand : void 0;
57
+ const brandFindings = brandDiagnostics(brandResolution);
58
+ if (emailRenderer !== void 0 && (themeCss !== previousTheme || brand?.digest !== previousBrand)) {
45
59
  await emailRenderer.close();
46
60
  emailRenderer = void 0;
47
61
  }
48
62
  previousTheme = themeCss;
63
+ previousBrand = brand?.digest;
49
64
  emailRenderer?.invalidate();
50
- const files = await discoverTemplateFiles(templatesDir);
51
- const sourceFiles = await readEmailProject(root);
52
65
  const entries = [];
53
66
  const emailIds = /* @__PURE__ */ new Map();
54
67
  for (const file of files) {
@@ -84,7 +97,11 @@ const createTemplateBuildSession = async (dir, options = {}) => {
84
97
  const preview = await (async () => {
85
98
  emailRenderer ??= await createEmailRenderer({
86
99
  assetBase: options.assetBase ?? "",
87
- ...themeCss === void 0 ? {} : { tailwind: { css: themeCss } }
100
+ ...themeCss === void 0 || themeFile === void 0 ? {} : { tailwind: {
101
+ css: themeCss,
102
+ cssPath: themeFile
103
+ } },
104
+ ...brand === void 0 ? {} : { brand }
88
105
  });
89
106
  const loaded = await emailRenderer.load(sourceFiles, entryPath, entryPath);
90
107
  const template = loaded.template;
@@ -106,7 +123,7 @@ const createTemplateBuildSession = async (dir, options = {}) => {
106
123
  },
107
124
  diagnostics: fixture.diagnostics
108
125
  }));
109
- const diagnostics = [...loaded.compiled.diagnostics];
126
+ const diagnostics = [...brandFindings, ...loaded.compiled.diagnostics];
110
127
  if (fixtures.length === 0) diagnostics.push(emailFinding("missing-fixtures", `${template.id} must declare at least one fixture for check and build.`));
111
128
  if (loaded.compiled.assets.length > 0 && options.assetBase === void 0) diagnostics.push(emailFinding("missing-asset-base", "Imported email assets require --asset-base with their HTTPS serving origin."));
112
129
  if (loaded.compiled.assets.length > 0 && options.assetBase !== void 0 && !/^https:\/\//i.test(options.assetBase)) diagnostics.push(emailFinding("invalid-asset-base", "Email check and build require an HTTPS --asset-base; relative URLs belong to templates export."));
@@ -126,7 +143,13 @@ const createTemplateBuildSession = async (dir, options = {}) => {
126
143
  fixtures
127
144
  }]
128
145
  };
129
- })().catch(emailFailure);
146
+ })().catch((cause) => {
147
+ const failure = emailFailure(cause);
148
+ return {
149
+ ...failure,
150
+ diagnostics: [...brandFindings, ...failure.diagnostics]
151
+ };
152
+ });
130
153
  entries.push({
131
154
  id: file.id,
132
155
  kind: file.kind,
@@ -1,5 +1,5 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
3
  //#region src/discovery.ts
4
4
  const isTemplateFile = (name) => name.endsWith(".tsx");
5
5
  const toPosix = (value) => sep === "/" ? value : value.split(sep).join("/");
@@ -29,22 +29,7 @@ const discoverTemplateFiles = async (dir) => {
29
29
  await walk(dir);
30
30
  return found.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
31
31
  };
32
- /**
33
- * Resolves the `theme` plugin option to an absolute path, or `undefined` when
34
- * theming is explicitly disabled (`theme: false`). A string is taken relative to
35
- * `root` (absolute strings pass through); the default is `<root>/theme.css`.
36
- */
37
- const resolveThemePath = (root, option) => {
38
- if (option === false) return void 0;
39
- const file = option ?? "theme.css";
40
- return isAbsolute(file) ? file : resolve(root, file);
41
- };
42
- /** Read authored Tailwind CSS verbatim; the email compiler validates its syntax. */
43
- const readProjectThemeCss = async (path) => path === void 0 ? void 0 : readFile(path, "utf8").catch((cause) => {
44
- if (cause.code === "ENOENT") return void 0;
45
- throw cause;
46
- });
47
32
  //#endregion
48
- export { resolveThemePath as n, discoverTemplateFiles as r, readProjectThemeCss as t };
33
+ export { discoverTemplateFiles as t };
49
34
 
50
- //# sourceMappingURL=theme-CBF6Czjm.js.map
35
+ //# sourceMappingURL=discovery-D2eR-rzi.js.map