@samva/markup 0.15.0 → 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 +7 -2
- package/CHANGELOG.md +20 -0
- package/dist/assets-BSpgZOSR.js +61 -0
- package/dist/assets-BSuNT9gr.d.ts +39 -0
- package/dist/brand-Ci05p37c.js +132 -0
- package/dist/brand-footer-ClRtWnIc.d.ts +40 -0
- package/dist/brand-footer-DXVGsKE5.js +746 -0
- package/dist/{assets-tISHaQNF.d.ts → brand-jFV6YtSV.d.ts} +2 -40
- package/dist/brand-sample.d.ts +4 -0
- package/dist/brand-sample.js +2 -0
- package/dist/brand.d.ts +2 -1
- package/dist/brand.js +2 -1
- package/dist/{ir-DNL6H7jA.js → cascade-DoLWZ66g.js} +1 -5
- package/dist/compiler.d.ts +5 -38
- package/dist/compiler.js +9 -743
- package/dist/{diagnostic-codes-UXwOO3A-.js → diagnostic-codes-8XDLR9ef.js} +1 -36
- package/dist/diagnostic-model-DJtqbETd.js +36 -0
- package/dist/diagnostics.js +2 -1
- package/dist/edit.js +2 -2
- package/dist/ir-KJLFW08u.js +5 -0
- package/dist/{profile-AatO_SvP.js → profile-BsY4dkan.js} +10 -105
- package/dist/{render-DpYouYYG.js → render-CVnXZMyK.js} +1 -1
- package/dist/{render-BdvewoQt.d.ts → render-CuZej25A.d.ts} +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +2 -2
- package/dist/theme-imports-u_P0ygvQ.js +102 -0
- package/package.json +5 -1
- package/dist/brand-nreeKWxF.js +0 -200
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
|
|
49
|
-
|
|
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 };
|