@pantoken/email 0.1.19 → 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 +44 -6
- package/dist/email-html-CoHCHokB.d.mts +43 -0
- package/dist/email-html-Dz8s-HvN.mjs +194 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +2 -1
- package/dist/inline.d.mts +2 -0
- package/dist/inline.mjs +2 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @pantoken/email
|
|
2
2
|
|
|
3
|
-
Instructure design tokens
|
|
4
|
-
often strip `<style>`, so tokens are
|
|
5
|
-
|
|
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
|
-
|
|
28
|
+
Generate email-compatible HTML:
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
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 };
|
package/dist/inline.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pantoken/email",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Instructure design tokens
|
|
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,9 +25,14 @@
|
|
|
24
25
|
"provenance": true
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
28
|
+
"@pantoken/components": "2.0.0",
|
|
29
|
+
"@pantoken/inline-styles": "0.1.1",
|
|
27
30
|
"@pantoken/model": "0.5.0",
|
|
31
|
+
"@pantoken/plugin-custom-theme-colors": "0.3.1",
|
|
28
32
|
"@pantoken/tokens": "0.7.0",
|
|
29
|
-
"@pantoken/utils": "1.2.0"
|
|
33
|
+
"@pantoken/utils": "1.2.0",
|
|
34
|
+
"cheerio": "^1.2.0",
|
|
35
|
+
"postcss": "^8.5.28"
|
|
30
36
|
},
|
|
31
37
|
"devDependencies": {
|
|
32
38
|
"@types/node": "^26.6.3",
|