@pantoken/email 0.1.18 → 0.1.20

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/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @pantoken/email
2
2
 
3
- Instructure design tokens for HTML email. Email clients don't support CSS custom properties and
4
- often strip `<style>`, so tokens are fully resolved to concrete values for inline styling — colours
5
- as hex, dimensions with `px`. Icons are excluded.
3
+ Instructure design tokens and email-safe HTML generation. Email clients don't support CSS custom
4
+ properties and often strip `<style>`, so tokens are resolved to concrete inline values. Generated
5
+ component CSS uses a conservative, configurable email policy that omits interactive states and
6
+ unsupported components by default.
6
7
 
7
8
  ## Install
8
9
 
@@ -24,10 +25,42 @@ const t = emailTokens("light");
24
25
  The maps are keyed by camelCased token name (the `--instui-` prefix dropped): `--instui-color-
25
26
  background-brand` becomes `colorBackgroundBrand`.
26
27
 
27
- ## Output
28
+ Generate email-compatible HTML:
28
29
 
29
- Plain `Record<string, string>` maps of concrete token values, ready to interpolate into inline
30
- `style` attributes. No files are written and there's no CLI target — you import the values.
30
+ ```ts
31
+ import { inlineEmailHtml } from "@pantoken/email";
32
+
33
+ const html = inlineEmailHtml('<button class="instui-button">Save</button>', {
34
+ theme: "canvas",
35
+ client: "gmail",
36
+ });
37
+ ```
38
+
39
+ The `client` profile can be `generic`, `outlook`, `gmail`, or `webkit`. Profiles control which
40
+ fallback rules are retained; they do not reconstruct table or VML layouts. Add or remove component
41
+ classes at the email boundary with `allowComponents` and `denyComponents`. Caller-supplied `extraCss`
42
+ is passed through separately.
43
+
44
+ The compatibility subpath remains available:
45
+
46
+ ```ts
47
+ import { inlineEmailHtml } from "@pantoken/email/inline";
48
+ ```
49
+
50
+ ## CLI
51
+
52
+ ```sh
53
+ pantoken generate email -i page.html -o page.email.html --client gmail
54
+ ```
55
+
56
+ Use `--css <file>` to add caller-supplied CSS and `--theme <rebrand|canvas|canvasHighContrast>`
57
+ to select the token theme. The output is one HTML document; client profiles are filtering
58
+ capabilities, not a complete guarantee of rendering parity.
59
+
60
+ ## Token maps
61
+
62
+ `emailTokens(mode?)`, `light`, and `dark` remain available when callers need to interpolate a
63
+ single token directly into an inline `style` attribute.
31
64
 
32
65
  ## API
33
66
 
@@ -35,6 +68,11 @@ Plain `Record<string, string>` maps of concrete token values, ready to interpola
35
68
  or `"dark"`).
36
69
  - **`light`** — the concrete light-mode token map.
37
70
  - **`dark`** — the concrete dark-mode token map.
71
+ - **`inlineEmailHtml(html, options?): string`** — generate email-compatible HTML with resolved
72
+ pantoken values and filtered component CSS.
73
+ - **`InlineEmailHtmlOptions`** — theme, mode, client profile, component policy, custom colors, and
74
+ caller CSS options.
75
+ - **`EmailClient`** — `generic`, `outlook`, `gmail`, or `webkit`.
38
76
 
39
77
  ## Related
40
78
 
@@ -0,0 +1,43 @@
1
+ import { PantokenColorNamespace } from "@pantoken/plugin-custom-theme-colors";
2
+ import { Theme } from "@pantoken/model";
3
+ //#region src/email-css.d.ts
4
+ /** Supported email client capability profiles. */
5
+ type EmailClient = "generic" | "outlook" | "gmail" | "webkit";
6
+ /** Options for filtering component CSS for email output. */
7
+ interface EmailCssOptions {
8
+ /** Client capability profile. Defaults to `generic`. */
9
+ client?: EmailClient;
10
+ /** Component names to allow in addition to the default email-safe set. */
11
+ allowComponents?: readonly string[];
12
+ /** Component names to remove even when they are in the default or custom allowlist. */
13
+ denyComponents?: readonly string[];
14
+ }
15
+ //#endregion
16
+ //#region src/email-html.d.ts
17
+ /** Options for generating email-compatible HTML with pantoken styles. */
18
+ interface InlineEmailHtmlOptions {
19
+ /** Token theme to use. Defaults to `rebrand`. */
20
+ theme?: Theme;
21
+ /** Color scheme to resolve. Defaults to `light`. */
22
+ mode?: "light" | "dark";
23
+ /** Component class prefix. Defaults to `instui`. */
24
+ prefix?: string | null;
25
+ /** Add generated rules for this custom brand color. */
26
+ customColor?: string;
27
+ /** Apply one of the plugin's shipped or custom color scales. */
28
+ color?: PantokenColorNamespace;
29
+ /** Retain pseudo-class and media-query rules in a style element. Defaults to `false`. */
30
+ preserveFallbacks?: boolean;
31
+ /** Email client capability profile. Defaults to `generic`. */
32
+ client?: EmailClient;
33
+ /** Component names to allow in addition to the default email-safe set. */
34
+ allowComponents?: readonly string[];
35
+ /** Component names to remove from the email output. */
36
+ denyComponents?: readonly string[];
37
+ /** Additional caller-supplied CSS. */
38
+ extraCss?: string;
39
+ }
40
+ /** Inline resolved pantoken tokens and component CSS into HTML email markup. */
41
+ declare function inlineEmailHtml(html: string, options?: InlineEmailHtmlOptions): string;
42
+ //#endregion
43
+ export { EmailCssOptions as i, inlineEmailHtml as n, EmailClient as r, InlineEmailHtmlOptions as t };
@@ -0,0 +1,194 @@
1
+ import { byTheme } from "@pantoken/tokens";
2
+ import { resolveTokens } from "@pantoken/utils";
3
+ import { componentsCss } from "@pantoken/components";
4
+ import { load } from "cheerio";
5
+ import { COLOR_KEYS, CUSTOM_COLOR_KEY, customThemeColorsCss } from "@pantoken/plugin-custom-theme-colors";
6
+ import { inlineHtml } from "@pantoken/inline-styles/html";
7
+ import postcss from "postcss";
8
+ //#region src/email-css.ts
9
+ const DEFAULT_COMPONENTS = /* @__PURE__ */ new Set([
10
+ "alert",
11
+ "avatar",
12
+ "badge",
13
+ "billboard",
14
+ "breadcrumb",
15
+ "breadcrumb-link",
16
+ "button",
17
+ "byline",
18
+ "form-field",
19
+ "form-field-group",
20
+ "form-field-messages",
21
+ "heading",
22
+ "img",
23
+ "link",
24
+ "list",
25
+ "list-item",
26
+ "metric",
27
+ "pill",
28
+ "table",
29
+ "table-body",
30
+ "table-cell",
31
+ "table-col-header",
32
+ "table-head",
33
+ "table-row",
34
+ "table-row-header",
35
+ "tag",
36
+ "text"
37
+ ]);
38
+ const STATE_SELECTOR = /:(?:active|checked|disabled|focus|focus-visible|hover|invalid|visited)(?:\b|\()/iu;
39
+ const COMPONENT_CLASS = /(?:^|[\s>+~.(,])\.([A-Za-z0-9_-]+)/gu;
40
+ const clientRules = {
41
+ generic: { preserveMedia: false },
42
+ outlook: { preserveMedia: false },
43
+ gmail: { preserveMedia: false },
44
+ webkit: { preserveMedia: true }
45
+ };
46
+ function componentNames(selector) {
47
+ return [...selector.matchAll(COMPONENT_CLASS)].map((match) => match[1]).filter((name) => name !== void 0).filter((name) => name.startsWith("instui-")).map((name) => name.slice(7));
48
+ }
49
+ function splitSelectors(selectors) {
50
+ const result = [];
51
+ let start = 0;
52
+ let parentheses = 0;
53
+ let brackets = 0;
54
+ let quote = "";
55
+ for (let index = 0; index < selectors.length; index += 1) {
56
+ const character = selectors[index];
57
+ if (quote) {
58
+ if (character === "\\") index += 1;
59
+ else if (character === quote) quote = "";
60
+ continue;
61
+ }
62
+ if (character === "\"" || character === "'") quote = character;
63
+ else if (character === "(") parentheses += 1;
64
+ else if (character === ")") parentheses -= 1;
65
+ else if (character === "[") brackets += 1;
66
+ else if (character === "]") brackets -= 1;
67
+ else if (character === "," && parentheses === 0 && brackets === 0) {
68
+ result.push(selectors.slice(start, index).trim());
69
+ start = index + 1;
70
+ }
71
+ }
72
+ result.push(selectors.slice(start).trim());
73
+ return result.filter(Boolean);
74
+ }
75
+ function selectorAllowed(selector, allowed) {
76
+ if (STATE_SELECTOR.test(selector)) return false;
77
+ const names = componentNames(selector);
78
+ return names.length === 0 || names.some((name) => allowed.has(name));
79
+ }
80
+ function filterContainer(container, allowed, preserveMedia) {
81
+ for (const node of container.nodes?.slice() ?? []) {
82
+ if (node.type === "rule") {
83
+ const rule = node;
84
+ const selectors = splitSelectors(rule.selector).filter((selector) => selectorAllowed(selector, allowed));
85
+ if (selectors.length === 0) node.remove();
86
+ else {
87
+ rule.selector = selectors.join(", ");
88
+ filterContainer(rule, allowed, preserveMedia);
89
+ }
90
+ continue;
91
+ }
92
+ if (node.type === "atrule") {
93
+ const atRule = node;
94
+ const name = atRule.name.toLowerCase();
95
+ if (name === "media" && !preserveMedia) {
96
+ node.remove();
97
+ continue;
98
+ }
99
+ if ([
100
+ "supports",
101
+ "container",
102
+ "layer",
103
+ "keyframes",
104
+ "-webkit-keyframes"
105
+ ].includes(name)) {
106
+ node.remove();
107
+ continue;
108
+ }
109
+ if (atRule.nodes) filterContainer(atRule, allowed, preserveMedia);
110
+ }
111
+ }
112
+ }
113
+ /** Filter generated component CSS to the selected email client capabilities. */
114
+ function filterEmailCss(css, options = {}) {
115
+ const { client = "generic", allowComponents = [], denyComponents = [] } = options;
116
+ const profile = clientRules[client];
117
+ if (!profile) throw new Error(`Unknown email client profile: ${client}`);
118
+ const allowed = new Set(DEFAULT_COMPONENTS);
119
+ for (const component of allowComponents) allowed.add(component.replace(/^instui-/, ""));
120
+ for (const component of denyComponents) allowed.delete(component.replace(/^instui-/, ""));
121
+ const root = postcss.parse(css);
122
+ filterContainer(root, allowed, profile.preserveMedia);
123
+ return root.toString();
124
+ }
125
+ //#endregion
126
+ //#region src/email-html.ts
127
+ function modeValue(value, mode) {
128
+ const start = value.indexOf("light-dark(");
129
+ if (start < 0) return value;
130
+ let depth = 1;
131
+ let comma = -1;
132
+ let end = -1;
133
+ for (let index = start + 11; index < value.length; index += 1) if (value[index] === "(") depth += 1;
134
+ else if (value[index] === ")") {
135
+ depth -= 1;
136
+ if (depth === 0) {
137
+ end = index;
138
+ break;
139
+ }
140
+ } else if (value[index] === "," && depth === 1 && comma < 0) comma = index;
141
+ if (comma < 0 || end < 0) return value;
142
+ const selected = mode === "light" ? value.slice(start + 11, comma) : value.slice(comma + 1, end);
143
+ return `${value.slice(0, start)}${modeValue(selected.trim(), mode)}${value.slice(end + 1)}`;
144
+ }
145
+ function resolvedThemeTokens(theme, mode) {
146
+ const tokens = byTheme(theme).map((token) => ({
147
+ ...token,
148
+ value: modeValue(token.value, mode),
149
+ flatValue: token.flatValue ? modeValue(token.flatValue, mode) : void 0
150
+ }));
151
+ return {
152
+ tokens,
153
+ resolved: resolveTokens(tokens)
154
+ };
155
+ }
156
+ function tokenStylesheet(tokens, resolved) {
157
+ return `:root {\n${tokens.filter((token) => token.meta?.kind !== "icon").map((token) => ` ${token.name}: ${resolved.get(token.name) ?? token.value};`).join("\n")}\n}`;
158
+ }
159
+ function markColorScope(html, color) {
160
+ const $ = load(html, {}, false);
161
+ const documentRoot = $("html").first();
162
+ (documentRoot.length ? documentRoot : $.root().children().filter((_, node) => node.type === "tag")).attr("data-pantoken-color", color);
163
+ return $.html();
164
+ }
165
+ /** Inline resolved pantoken tokens and component CSS into HTML email markup. */
166
+ function inlineEmailHtml(html, options = {}) {
167
+ const { theme = "rebrand", mode = "light", prefix = "instui", customColor, color, preserveFallbacks = false, client = "generic", allowComponents = [], denyComponents = [], extraCss = "" } = options;
168
+ const activeColor = color ?? (customColor ? CUSTOM_COLOR_KEY : void 0);
169
+ if (activeColor && activeColor !== CUSTOM_COLOR_KEY && !COLOR_KEYS.includes(activeColor)) throw new Error(`Unknown pantoken color scale: ${activeColor}`);
170
+ if (activeColor === CUSTOM_COLOR_KEY && !customColor) throw new Error("The \"custom\" color scale requires a customColor hex value.");
171
+ const { tokens, resolved } = resolvedThemeTokens(theme, mode);
172
+ const css = [
173
+ tokenStylesheet(tokens, resolved),
174
+ customThemeColorsCss(resolved, {
175
+ custom: customColor,
176
+ selector: ""
177
+ }),
178
+ filterEmailCss(componentsCss({
179
+ theme,
180
+ prefix
181
+ }), {
182
+ client,
183
+ allowComponents,
184
+ denyComponents
185
+ }),
186
+ extraCss
187
+ ].join("\n");
188
+ return inlineHtml(activeColor ? markColorScope(html, activeColor) : html, css, {
189
+ resolveCSSVariables: true,
190
+ preserveFallbacks
191
+ });
192
+ }
193
+ //#endregion
194
+ export { inlineEmailHtml as t };
package/dist/index.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { i as EmailCssOptions, n as inlineEmailHtml, r as EmailClient, t as InlineEmailHtmlOptions } from "./email-html-CoHCHokB.mjs";
1
2
  //#region src/index.d.ts
2
3
  type Mode = "light" | "dark";
3
4
  /**
@@ -41,4 +42,5 @@ export declare const dark: Record<string, string>;
41
42
  * ```
42
43
  */
43
44
  export declare function emailTokens(mode?: Mode): Record<string, string>;
44
- //#endregion
45
+ //#endregion
46
+ export { type EmailClient, type EmailCssOptions, type InlineEmailHtmlOptions, inlineEmailHtml };
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as inlineEmailHtml } from "./email-html-Dz8s-HvN.mjs";
1
2
  import { tokens } from "@pantoken/tokens";
2
3
  import { camelCase, resolveTokens } from "@pantoken/utils";
3
4
  //#region src/index.ts
@@ -64,4 +65,4 @@ function emailTokens(mode = "light") {
64
65
  return mode === "dark" ? dark : light;
65
66
  }
66
67
  //#endregion
67
- export { dark, emailTokens, light };
68
+ export { dark, emailTokens, inlineEmailHtml, light };
@@ -0,0 +1,2 @@
1
+ import { i as EmailCssOptions, n as inlineEmailHtml, r as EmailClient, t as InlineEmailHtmlOptions } from "./email-html-CoHCHokB.mjs";
2
+ export { type EmailClient, type EmailCssOptions, type InlineEmailHtmlOptions, inlineEmailHtml };
@@ -0,0 +1,2 @@
1
+ import { t as inlineEmailHtml } from "./email-html-Dz8s-HvN.mjs";
2
+ export { inlineEmailHtml };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pantoken/email",
3
- "version": "0.1.18",
4
- "description": "Instructure design tokens for HTML email: concrete, inline-friendly token values (no CSS vars).",
3
+ "version": "0.1.20",
4
+ "description": "Instructure design tokens and email-safe HTML generation with concrete inline values.",
5
5
  "homepage": "https://pantoken.app",
6
6
  "bugs": "https://github.com/thedannywahl/pantoken/issues",
7
7
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  "sideEffects": false,
18
18
  "exports": {
19
19
  ".": "./dist/index.mjs",
20
+ "./inline": "./dist/inline.mjs",
20
21
  "./package.json": "./package.json"
21
22
  },
22
23
  "publishConfig": {
@@ -24,12 +25,17 @@
24
25
  "provenance": true
25
26
  },
26
27
  "dependencies": {
27
- "@pantoken/model": "0.4.0",
28
- "@pantoken/tokens": "0.6.0",
29
- "@pantoken/utils": "1.1.0"
28
+ "@pantoken/components": "2.0.0",
29
+ "@pantoken/inline-styles": "0.1.1",
30
+ "@pantoken/model": "0.5.0",
31
+ "@pantoken/plugin-custom-theme-colors": "0.3.1",
32
+ "@pantoken/tokens": "0.7.0",
33
+ "@pantoken/utils": "1.2.0",
34
+ "cheerio": "^1.2.0",
35
+ "postcss": "^8.5.28"
30
36
  },
31
37
  "devDependencies": {
32
- "@types/node": "^26.6.2",
38
+ "@types/node": "^26.6.3",
33
39
  "typescript": "^7.0.2",
34
40
  "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0-rc.0",
35
41
  "vite-plus": "1.0.0-rc.0"