@samva/markup 0.15.1 → 0.16.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
@@ -45,8 +45,9 @@ entry?)` is the synchronous structural check: it runs the compiler's lowering wi
45
45
  brand or the files an import names, and returns only findings the source itself owns.
46
46
  `introducedProfileErrors(before, after)` is what a visual edit is refused on. It also holds the
47
47
  compiler's building blocks (`transformProject`, Tailwind, the cascade, CSS), the brand footer and
48
- sample renders (`renderBrandFooter`, `renderBrandSample`), and `TEMPLATE_NAME_PATTERN`. It imports no Vite, esbuild, or Node builtins, so it runs in any
49
- JavaScript host.
48
+ sample renders (`renderBrandFooter`, `renderBrandSample`), and `TEMPLATE_NAME_PATTERN`. It imports
49
+ no Vite, esbuild, or Node builtins, so it runs in Node, Bun and Workers; caniemail's glob
50
+ dependency reads `process` when it loads, so a browser takes `./brand-sample` instead.
50
51
  - `@samva/markup/diagnostics` — source spans and line maps, the `DIAGNOSTIC_CODES` registry with
51
52
  each code's fix and upgrade recipe, and `EmailDiagnostic`/`EmailCompileError`. It does not load
52
53
  the compiler, so a host that only reports imports it.
@@ -70,6 +71,10 @@ entry?)` is the synchronous structural check: it runs the compiler's lowering wi
70
71
  assets (`isAssetPath`, `ASSET_CONTENT_TYPES`, `assetUrl`): which imported files become
71
72
  content-addressed assets. The send path imports it, so its closure holds only the css-tree parser
72
73
  `theme.css` needs; rendering a footer compiles Tailwind and lives in `./compiler`.
74
+ - `@samva/markup/brand-sample` — `renderBrandSample`, the brand preview a dashboard compiles in
75
+ the browser against an unsaved brand. Its closure holds Tailwind, Babel and css-tree but not
76
+ caniemail (`tests/entry-closure.test.ts`), and it loads without `process`
77
+ (`tests/brand-sample-entry.test.ts`).
73
78
 
74
79
  `tests/published-dependencies.test.ts` pins this list. The compiler's libraries (Babel, Tailwind,
75
80
  css-tree, caniemail) are dependencies, because a template project depends on this package alone and
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## @samva/markup@0.16.0
2
+
3
+ ### The brand footer is one line
4
+
5
+ `BrandFooter` renders the company name, social links and the unsubscribe link on one line,
6
+ separated by `·`, with the postal address on the line below. It follows a `text-left` or
7
+ `text-right` `className` instead of always centering. Sends without an unsubscribe group drop only
8
+ the unsubscribe link and its separator, and still strip the row of footers compiled before this
9
+ release.
10
+
11
+ This is an intended change to published output: the email parity digests
12
+ (`packages/markup/tests/support/email-parity.ts`) move with the new footer markup, and Samva's hosted
13
+ runtime must take the same values when it repins to this release.
14
+
15
+ ### Render a brand sample in the browser
16
+
17
+ `@samva/markup/brand-sample` exports `renderBrandSample` without caniemail, whose glob dependency
18
+ reads Node's `process` when it loads. Import it to preview a brand in a browser; `./compiler`
19
+ still exports `renderBrandSample` for Node, Bun and Workers.
20
+
1
21
  ## @samva/markup@0.15.0
2
22
 
3
23
  ### WhatsApp template FLOW buttons
@@ -0,0 +1,61 @@
1
+ //#region src/email/assets.ts
2
+ /** Files a template may import, and what they are served as. */
3
+ const ASSET_CONTENT_TYPES = {
4
+ ".png": "image/png",
5
+ ".jpg": "image/jpeg",
6
+ ".jpeg": "image/jpeg",
7
+ ".gif": "image/gif",
8
+ ".webp": "image/webp",
9
+ ".avif": "image/avif",
10
+ ".svg": "image/svg+xml",
11
+ ".ico": "image/x-icon",
12
+ ".woff": "font/woff",
13
+ ".woff2": "font/woff2",
14
+ ".ttf": "font/ttf",
15
+ ".otf": "font/otf",
16
+ ".pdf": "application/pdf",
17
+ ".txt": "text/plain"
18
+ };
19
+ const extensionOf = (path) => {
20
+ const dot = path.lastIndexOf(".");
21
+ return dot < 0 ? "" : path.slice(dot).toLowerCase();
22
+ };
23
+ /** True when the compiler turns this import into a content-addressed asset. */
24
+ const isAssetPath = (path) => Object.hasOwn(ASSET_CONTENT_TYPES, extensionOf(path));
25
+ const hex = (buffer) => Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
26
+ /** Address one file by its content. */
27
+ const assetEntry = async (path, bytes) => {
28
+ const extension = extensionOf(path);
29
+ const view = new Uint8Array(bytes.byteLength);
30
+ view.set(bytes);
31
+ const digest = hex(await crypto.subtle.digest("SHA-256", view));
32
+ return {
33
+ path,
34
+ digest,
35
+ bytes: bytes.byteLength,
36
+ contentType: ASSET_CONTENT_TYPES[extension] ?? "application/octet-stream",
37
+ fileName: `${digest}${extension}`
38
+ };
39
+ };
40
+ /**
41
+ * The URL an asset has under a base. The base is a build input: publication
42
+ * passes its hosted origin, and a local export passes whatever it will serve
43
+ * the exported files from.
44
+ */
45
+ const assetUrl = (entry, base) => `${base.replace(/\/+$/, "")}/${entry.fileName}`;
46
+ const EXTERNAL_IMAGE = /^https:\/\//i;
47
+ /** Record, but never fetch, a URL the template points at. */
48
+ const isExternalAsset = (reference) => EXTERNAL_IMAGE.test(reference);
49
+ /**
50
+ * Reject an asset base that would produce URLs a client will not load. The
51
+ * relative forms are the ones a page on disk resolves against its own
52
+ * directory, which is what a local export writes.
53
+ */
54
+ const checkAssetBase = (base) => /^(https:\/\/|\.{1,2}\/|\/)/i.test(base) ? [] : [{
55
+ code: "invalid-asset-base",
56
+ severity: "error",
57
+ message: `Asset base ${JSON.stringify(base)} must be an https URL, an absolute path, or a relative path for a local export.`,
58
+ origins: []
59
+ }];
60
+ //#endregion
61
+ export { isAssetPath as a, checkAssetBase as i, assetEntry as n, isExternalAsset as o, assetUrl as r, ASSET_CONTENT_TYPES as t };
@@ -0,0 +1,39 @@
1
+ import { r as EmailDiagnostic } from "./diagnostics-CUYlQ_6x.js";
2
+ //#region src/email/assets.d.ts
3
+ /** Files a template may import, and what they are served as. */
4
+ declare const ASSET_CONTENT_TYPES: Readonly<Record<string, string>>;
5
+ interface AssetEntry {
6
+ /** Project-relative path the template imported. */
7
+ readonly path: string;
8
+ /** SHA-256 of the exact bytes, lowercase hex. */
9
+ readonly digest: string;
10
+ readonly bytes: number;
11
+ readonly contentType: string;
12
+ /** `<digest><ext>` — the name the asset has under any base. */
13
+ readonly fileName: string;
14
+ }
15
+ interface AssetManifest {
16
+ /** Where these assets are served from in this build. */
17
+ readonly base: string;
18
+ readonly assets: readonly AssetEntry[];
19
+ }
20
+ /** True when the compiler turns this import into a content-addressed asset. */
21
+ declare const isAssetPath: (path: string) => boolean;
22
+ /** Address one file by its content. */
23
+ declare const assetEntry: (path: string, bytes: Uint8Array) => Promise<AssetEntry>;
24
+ /**
25
+ * The URL an asset has under a base. The base is a build input: publication
26
+ * passes its hosted origin, and a local export passes whatever it will serve
27
+ * the exported files from.
28
+ */
29
+ declare const assetUrl: (entry: AssetEntry, base: string) => string;
30
+ /** Record, but never fetch, a URL the template points at. */
31
+ declare const isExternalAsset: (reference: string) => boolean;
32
+ /**
33
+ * Reject an asset base that would produce URLs a client will not load. The
34
+ * relative forms are the ones a page on disk resolves against its own
35
+ * directory, which is what a local export writes.
36
+ */
37
+ declare const checkAssetBase: (base: string) => readonly EmailDiagnostic[];
38
+ //#endregion
39
+ export { assetUrl as a, isExternalAsset as c, assetEntry as i, AssetEntry as n, checkAssetBase as o, AssetManifest as r, isAssetPath as s, ASSET_CONTENT_TYPES as t };
@@ -0,0 +1,132 @@
1
+ import { D as samvaBrandPlugin, O as BRAND_FOOTER_MARKER, k as UNSUBSCRIBE_LINK, r as escapeAttribute } from "./html-BvRFepeO.js";
2
+ import { a as jsx, c as rawMarkup, n as Fragment } from "./jsx-runtime-CBlexoeR.js";
3
+ //#region src/email/brand.ts
4
+ /** Every class the brand components use, compiled for any project that imports them. */
5
+ const BRAND_COMPONENT_CLASSES = [
6
+ "dark:hidden!",
7
+ "hidden",
8
+ "dark:block!",
9
+ "text-xs",
10
+ "text-muted-foreground",
11
+ "text-center",
12
+ "font-semibold",
13
+ "underline"
14
+ ];
15
+ const addressLines = (address) => [
16
+ address.street1,
17
+ address.street2,
18
+ [
19
+ address.city,
20
+ address.region,
21
+ address.postalCode
22
+ ].filter((part) => part.trim() !== "").join(", "),
23
+ address.country
24
+ ].filter((line) => line !== void 0 && line.trim() !== "");
25
+ const classes = (...names) => {
26
+ const joined = names.filter((name) => name !== void 0 && name !== "").join(" ");
27
+ return joined === "" ? void 0 : joined;
28
+ };
29
+ const line = (children, last) => jsx("p", {
30
+ style: { margin: last ? "0" : "0 0 8px 0" },
31
+ children
32
+ });
33
+ /** The alignment a footer `className` asks for; the footer centers when it names none. */
34
+ const footerAlignment = (className) => /(?:^|\s)text-(left|center|right)(?:\s|$)/.exec(className ?? "")?.[1] ?? "center";
35
+ /** Bind `BrandLogo` and `BrandFooter` to one brand, marked as the plugin names. */
36
+ const brandComponents = (brand, plugin = samvaBrandPlugin) => {
37
+ const assets = brand.assets ?? {};
38
+ const footer = brand.footer ?? {};
39
+ const BrandLogo = ({ width = 120, alt, align, className }) => {
40
+ if (assets.logoUrl === void 0) return null;
41
+ const label = alt ?? footer.companyName ?? "";
42
+ const image = (src, imageClass, imageAlt) => jsx("img", {
43
+ src,
44
+ alt: imageAlt,
45
+ width,
46
+ border: 0,
47
+ ...align === void 0 ? {} : { align },
48
+ className: classes(imageClass, className),
49
+ style: {
50
+ display: "block",
51
+ outline: "none",
52
+ border: "none",
53
+ height: "auto"
54
+ }
55
+ });
56
+ if (assets.logoDarkUrl === void 0) return image(assets.logoUrl, void 0, label);
57
+ return jsx(Fragment, { children: [image(assets.logoUrl, "dark:hidden!", label), jsx("div", {
58
+ className: "hidden dark:block!",
59
+ style: {
60
+ msoHide: "all",
61
+ maxHeight: 0,
62
+ overflow: "hidden"
63
+ },
64
+ children: image(assets.logoDarkUrl, void 0, "")
65
+ })] });
66
+ };
67
+ const BrandFooter = ({ unsubscribeLabel = "Unsubscribe", className }) => {
68
+ const items = [];
69
+ const separator = () => items.length === 0 ? [] : [" · "];
70
+ if (footer.companyName !== void 0 && footer.companyName.trim() !== "") items.push(jsx("span", {
71
+ className: "font-semibold",
72
+ children: footer.companyName
73
+ }));
74
+ for (const link of footer.socialLinks ?? []) items.push(...separator(), jsx("a", {
75
+ href: link.url,
76
+ className: "text-muted-foreground underline",
77
+ children: link.label
78
+ }));
79
+ items.push(jsx("span", {
80
+ [plugin.unsubscribeRowAttribute]: "",
81
+ [BRAND_FOOTER_MARKER]: true,
82
+ children: [...items.length === 0 ? [] : [rawMarkup(" · ", "\n")], unsubscribeLink(unsubscribeLabel, plugin)]
83
+ }));
84
+ const address = footer.address === void 0 ? [] : addressLines(footer.address);
85
+ const alignment = footerAlignment(className);
86
+ return jsx("table", {
87
+ role: "presentation",
88
+ width: "100%",
89
+ border: 0,
90
+ cellpadding: 0,
91
+ cellspacing: 0,
92
+ [plugin.footerAttribute]: "",
93
+ [BRAND_FOOTER_MARKER]: true,
94
+ children: jsx("tbody", { children: jsx("tr", { children: jsx("td", {
95
+ align: alignment,
96
+ className: classes("text-xs text-muted-foreground", alignment === "center" ? "text-center" : void 0, className),
97
+ style: { padding: "32px 16px" },
98
+ children: [line(items, address.length === 0), ...address.length === 0 ? [] : [line([address.join(", ")], true)]]
99
+ }) }) })
100
+ });
101
+ };
102
+ return {
103
+ BrandLogo,
104
+ BrandFooter
105
+ };
106
+ };
107
+ /**
108
+ * The unsubscribe link. Its href is the placeholder, which no author-written
109
+ * link may carry: the serializer accepts it only on this node, and writes the
110
+ * plugin's no-track marker on it so click tracking never rewrites it.
111
+ */
112
+ const unsubscribeLink = (label, plugin) => jsx("a", {
113
+ href: plugin.unsubscribeUrlPlaceholder,
114
+ className: "text-muted-foreground underline",
115
+ [UNSUBSCRIBE_LINK]: true,
116
+ children: label
117
+ });
118
+ /** Read the plugin's footer markers from rendered HTML. */
119
+ const renderedFooterMarkers = (html, plugin = samvaBrandPlugin) => ({
120
+ footer: html.includes(`${plugin.footerAttribute}=""`),
121
+ unsubscribeLink: html.includes(`href="${escapeAttribute(plugin.unsubscribeUrlPlaceholder)}"`)
122
+ });
123
+ /**
124
+ * Drop the unsubscribe row from a rendered message, for a send that resolved
125
+ * no unsubscribe group: there is no link to put there, so the row goes.
126
+ */
127
+ const withoutUnsubscribeRow = (rendered, plugin = samvaBrandPlugin) => ({
128
+ html: rendered.html?.replace(new RegExp(`<(span|tr) ${plugin.unsubscribeRowAttribute}=""[^>]*>[\\s\\S]*?</\\1>`, "g"), ""),
129
+ text: rendered.text?.split("\n").filter((textLine) => !textLine.includes(plugin.unsubscribeUrlPlaceholder)).join("\n")
130
+ });
131
+ //#endregion
132
+ export { withoutUnsubscribeRow as i, brandComponents as n, renderedFooterMarkers as r, BRAND_COMPONENT_CLASSES as t };
@@ -0,0 +1,40 @@
1
+ import { n as EmailContent } from "./template-B-UDXK8a.js";
2
+ import { t as BrandPlugin } from "./brand-plugin-CHhcei-j.js";
3
+ import { a as EmailBrand, n as BrandComponents } from "./brand-jFV6YtSV.js";
4
+ import { r as RenderedEmail } from "./render-CuZej25A.js";
5
+ //#region src/email/brand-footer.d.ts
6
+ /** A footer block for the send path to append, and its plain-text tail. */
7
+ interface RenderedBrandFooter {
8
+ readonly html: string;
9
+ readonly text: string;
10
+ /**
11
+ * The rules the footer needs in the message's `<head>`: its dark-mode colors and the
12
+ * `dark:` swap of the brand logo. Empty when it needs none.
13
+ */
14
+ readonly headCss: string;
15
+ }
16
+ /**
17
+ * Render `BrandFooter` against a brand on its own, for the send path to
18
+ * append to a message whose template renders no footer of its own. It
19
+ * compiles the same component, styled by the same brand theme, a template
20
+ * would get from its brand import. Without `unsubscribe` the unsubscribe row is
21
+ * left out.
22
+ */
23
+ declare const renderBrandFooter: (brand: EmailBrand, options: {
24
+ readonly unsubscribe: boolean;
25
+ readonly brandPlugin?: BrandPlugin | undefined;
26
+ }) => Promise<RenderedBrandFooter>;
27
+ /**
28
+ * Render content against a brand as a project whose `theme.css` imports only
29
+ * the brand renders it: Tailwind compiles the brand theme for
30
+ * the classes the content names, `BrandLogo` and `BrandFooter` are bound to the
31
+ * brand, and the render inlines the result. A brand preview renders through
32
+ * this, so it shows what a template importing the brand compiles to.
33
+ */
34
+ declare const renderBrandSample: (brand: EmailBrand, sample: {
35
+ /** Every class the content names; nothing is discovered from source here. */
36
+ readonly classes: readonly string[];
37
+ readonly render: (components: BrandComponents) => EmailContent;
38
+ }, brandPlugin?: BrandPlugin) => Promise<RenderedEmail>;
39
+ //#endregion
40
+ export { renderBrandFooter as n, renderBrandSample as r, RenderedBrandFooter as t };