@casomoltd/tooling 0.32.0 → 0.33.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/README.md +2 -2
- package/buttondown/base-web.css +135 -0
- package/buttondown/email-blocks.mjs +191 -0
- package/buttondown/engine.mjs +82 -22
- package/buttondown/index.d.mts +60 -1
- package/buttondown/index.mjs +2 -0
- package/buttondown/skins.mjs +85 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
|
|
|
15
15
|
| `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
|
|
16
16
|
| `@casomoltd/tooling/jscpd` | Copy-paste detection config |
|
|
17
17
|
| `@casomoltd/tooling/readability` | `scoreText()` — readability scoring for page content |
|
|
18
|
-
| `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command:
|
|
18
|
+
| `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command: the shared email blocks and skins (`createEmailBlocks`, `emailBaseCss`, `composeWebSkin`), `tokenReader()`, `loadSettings()` and `buildPayload()`, and `EMAIL_CHROME_HIDES` ([docs/buttondown.md](docs/buttondown.md)) |
|
|
19
19
|
|
|
20
20
|
### Bin commands
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
|
|
|
33
33
|
| `spec-check` | Check a design spec's anchors, contents, figures and numbering |
|
|
34
34
|
| `check-private-refs` | In a public repo, refuse a private tracker link or a name listed under `privateRefs` in `casomo.config.mjs` |
|
|
35
35
|
| `check-personal-data` | Refuse an email, National Insurance number, UK mobile number, date of birth or locally denylisted name; per-repo allowances under `personalData` in `casomo.config.mjs` |
|
|
36
|
-
| `buttondown` | Keep a Buttondown newsletter as code: `diff` and `apply` its fields (settings, skins and email templates) and tags, `check` the skins, sync their tokens, and create drafts
|
|
36
|
+
| `buttondown` | Keep a Buttondown newsletter as code: `diff` and `apply` its fields (settings, skins and email templates) and tags, `check` the skins, sync their tokens, and create drafts ([docs/buttondown.md](docs/buttondown.md)) |
|
|
37
37
|
|
|
38
38
|
## Install
|
|
39
39
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/* The rules every site's blog skin shares, inserted ahead of the site's own
|
|
2
|
+
rules by composeWebSkin. They restyle Buttondown's markup by role and set
|
|
3
|
+
no value of their own: each comes from a variable the site's TOKENS block
|
|
4
|
+
defines, Buttondown's theme variables where the theme has the role and
|
|
5
|
+
--bd-* where it has not:
|
|
6
|
+
|
|
7
|
+
--bd-font-heading headings and the archive entry's title
|
|
8
|
+
--bd-heading their colour
|
|
9
|
+
--bd-action-bg the one action's ground
|
|
10
|
+
--bd-action-ink its label
|
|
11
|
+
--bd-action-weight its label's weight
|
|
12
|
+
--bd-dialog-bg a dialog's ground
|
|
13
|
+
--bd-focus the focus ring
|
|
14
|
+
--bd-leading an archive entry's line height */
|
|
15
|
+
|
|
16
|
+
body,
|
|
17
|
+
.subscribe,
|
|
18
|
+
.manage-subscription {
|
|
19
|
+
background: var(--color-background);
|
|
20
|
+
color: var(--color-foreground);
|
|
21
|
+
font-family: var(--font-family-sans);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Square and flat everywhere, whatever class Buttondown gives an element,
|
|
25
|
+
its dialogs and their buttons included. A tag switch keeps its round
|
|
26
|
+
track. Removing every shadow removes any focus ring drawn as one, so
|
|
27
|
+
focus is an outline below. */
|
|
28
|
+
*:not(.checkbox-field),
|
|
29
|
+
*::before,
|
|
30
|
+
*::after {
|
|
31
|
+
border-radius: var(--radius) !important;
|
|
32
|
+
box-shadow: none !important;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Important, so a theme utility that removes outlines cannot leave a
|
|
36
|
+
control with no focus indicator. */
|
|
37
|
+
:focus-visible {
|
|
38
|
+
outline: 2px solid var(--bd-focus) !important;
|
|
39
|
+
outline-offset: 2px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
h1, h2, h3,
|
|
43
|
+
.newsletter-name,
|
|
44
|
+
.section-title,
|
|
45
|
+
.modal-title,
|
|
46
|
+
.login-modal-title {
|
|
47
|
+
font-family: var(--bd-font-heading);
|
|
48
|
+
color: var(--bd-heading);
|
|
49
|
+
letter-spacing: -0.01em;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The one action. `.archive__button` is every action button on the
|
|
53
|
+
archive, the subscribe form's and a signed-in reader's "Manage
|
|
54
|
+
subscription" alike; the theme sets it pill-shaped, white and upper
|
|
55
|
+
case with `!important`, so these name the element too to outrank it. */
|
|
56
|
+
a.archive__button,
|
|
57
|
+
button.archive__button,
|
|
58
|
+
button.button.tinted,
|
|
59
|
+
.portal-btn--primary,
|
|
60
|
+
.subscription-success-button {
|
|
61
|
+
background-color: var(--bd-action-bg) !important;
|
|
62
|
+
color: var(--bd-action-ink) !important;
|
|
63
|
+
font-family: var(--font-family-sans) !important;
|
|
64
|
+
font-weight: var(--bd-action-weight) !important;
|
|
65
|
+
text-transform: none !important;
|
|
66
|
+
border-radius: var(--radius) !important;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* 16px, because iOS zooms a focused field below it and the page jumps.
|
|
70
|
+
Buttondown's own rule is `input[type=email].input-field`, so the
|
|
71
|
+
portal's field is named with its type to outrank it. */
|
|
72
|
+
.archive__subscribe-form-input,
|
|
73
|
+
#subscribe-form input[type="email"],
|
|
74
|
+
.subscribe-modal-content input[type="email"],
|
|
75
|
+
.login-modal-content .magic-link-input,
|
|
76
|
+
.manage-subscription input[type="email"].input-field,
|
|
77
|
+
.manage-subscription input[type="text"].input-field {
|
|
78
|
+
font-family: var(--font-family-sans);
|
|
79
|
+
font-size: 16px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.modal-content,
|
|
83
|
+
.buttondown-dialog-root .bg-background {
|
|
84
|
+
background: var(--bd-dialog-bg) !important;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* The log-in dialog renders outside the page body, so it does not
|
|
88
|
+
inherit the page's type. */
|
|
89
|
+
.login-modal-content,
|
|
90
|
+
.login-modal-content * {
|
|
91
|
+
font-family: var(--font-family-sans);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The line after a log-in request: body text, not Buttondown's green. */
|
|
95
|
+
.magic-link-success-message {
|
|
96
|
+
color: var(--color-foreground) !important;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* The theme centres the description, and lines of centred body text are
|
|
100
|
+
harder to read, since each starts in a different place. */
|
|
101
|
+
.newsletter-description,
|
|
102
|
+
.newsletter-description p {
|
|
103
|
+
text-align: left;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* An archive entry, set as an article is: a large left-aligned title and
|
|
107
|
+
justified, hyphenated prose. */
|
|
108
|
+
.email-body .subject {
|
|
109
|
+
font-family: var(--bd-font-heading);
|
|
110
|
+
font-size: 36px;
|
|
111
|
+
font-weight: 700;
|
|
112
|
+
line-height: 1.1;
|
|
113
|
+
color: var(--bd-heading);
|
|
114
|
+
text-align: left;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.email-body .description {
|
|
118
|
+
text-align: left;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.email-body-content {
|
|
122
|
+
margin-top: 1.5rem;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.email-body-content p,
|
|
126
|
+
.email-body-content li {
|
|
127
|
+
font-size: 16px;
|
|
128
|
+
line-height: var(--bd-leading);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.email-body-content p {
|
|
132
|
+
text-align: justify;
|
|
133
|
+
hyphens: auto;
|
|
134
|
+
-webkit-hyphens: auto;
|
|
135
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bands an email is built from, for a repo whose Buttondown emails
|
|
3
|
+
* draw their own header and footer: the title on a ground, white air,
|
|
4
|
+
* paragraphs, one action, a sign-off and the small print. The site
|
|
5
|
+
* supplies its tokens, its mark band and its colophon; this supplies the
|
|
6
|
+
* layout they share.
|
|
7
|
+
*
|
|
8
|
+
* Every rule is an inline `style` attribute, because inline is the only
|
|
9
|
+
* thing every mail client honours, and vertical rhythm is carried on
|
|
10
|
+
* table cell padding, because Outlook drops a margin on a table but
|
|
11
|
+
* always honours padding on a cell. Every block ends with its own
|
|
12
|
+
* trailing newlines, so a template is a plain concatenation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Buttondown re-emits these attributes in its own order when it inlines
|
|
16
|
+
* an email, so the opening tag is written once. */
|
|
17
|
+
export const TABLE =
|
|
18
|
+
'<table role="presentation"\n'
|
|
19
|
+
+ ' cellpadding="0"\n'
|
|
20
|
+
+ ' cellspacing="0"\n'
|
|
21
|
+
+ ' border="0"\n'
|
|
22
|
+
+ ' width="100%"';
|
|
23
|
+
|
|
24
|
+
/** The body text size and a paragraph's spacing, the same in every band
|
|
25
|
+
* and in the shared email stylesheet. */
|
|
26
|
+
export const BODY_SIZE = "17px";
|
|
27
|
+
export const PARAGRAPH_MARGIN = "0 0 16px 0";
|
|
28
|
+
|
|
29
|
+
/** The template variables Buttondown fills with a subscriber's links. */
|
|
30
|
+
const UNSUBSCRIBE = '{{ unsubscribe_url }}';
|
|
31
|
+
const MANAGE = '{{ manage_subscription_url }}';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The names an email theme must give, which `emailBaseCss` and
|
|
35
|
+
* `createEmailBlocks` both read: one value per site, so a font or colour
|
|
36
|
+
* has one producer.
|
|
37
|
+
*/
|
|
38
|
+
const THEME_PATHS = [
|
|
39
|
+
"fonts.body", "fonts.heading", "colours.page", "colours.ink",
|
|
40
|
+
"colours.heading", "leading.body", "leading.prose",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** The paths in `paths` that `value` leaves undefined. */
|
|
44
|
+
export function missingPaths(value, paths) {
|
|
45
|
+
return paths.filter((p) =>
|
|
46
|
+
p.split(".").reduce((v, key) => v?.[key], value) === undefined);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Throws, naming each path missing, so a site states its whole look
|
|
50
|
+
* rather than rendering `undefined` into an email. */
|
|
51
|
+
export function requirePaths(what, value, paths) {
|
|
52
|
+
const missing = missingPaths(value, paths);
|
|
53
|
+
if (missing.length) throw new Error(`${what} needs ${missing.join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Checks an email theme: {fonts: {body, heading}, colours: {page, ink,
|
|
57
|
+
* heading}, leading: {body, prose}}. */
|
|
58
|
+
export function requireTheme(what, theme) {
|
|
59
|
+
requirePaths(what, {theme}, THEME_PATHS.map((p) => `theme.${p}`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The blocks for one site. Every option is required:
|
|
64
|
+
*
|
|
65
|
+
* theme: the site's email theme (see requireTheme): the body and
|
|
66
|
+
* heading fonts, the page, ink and heading colours, and the
|
|
67
|
+
* prose line height
|
|
68
|
+
* colours: {titleGround, action, actionInk, footerGround, footerRule}
|
|
69
|
+
* rhythm: {cardPadding, buttonCell, buttonSize, buttonPadding,
|
|
70
|
+
* footerRuleWidth, footerCells: [top, bottom]}
|
|
71
|
+
* small: the small print's inline style
|
|
72
|
+
* buttonClass: the class the email skin keys the button's rule on
|
|
73
|
+
* smallLink(href, text): a link in the small print
|
|
74
|
+
* colophon: the small print's HTML, before the subscription sentence
|
|
75
|
+
* copyright: the last line's HTML
|
|
76
|
+
* sentenceBreak: what joins the colophon to the subscription sentence
|
|
77
|
+
* signoff: the name a message is signed with
|
|
78
|
+
*/
|
|
79
|
+
export function createEmailBlocks(options = {}) {
|
|
80
|
+
requireTheme("createEmailBlocks", options.theme);
|
|
81
|
+
requirePaths("createEmailBlocks", options, [
|
|
82
|
+
"colours.titleGround", "colours.action", "colours.actionInk",
|
|
83
|
+
"colours.footerGround", "colours.footerRule", "rhythm.cardPadding",
|
|
84
|
+
"rhythm.buttonCell", "rhythm.buttonSize", "rhythm.buttonPadding",
|
|
85
|
+
"rhythm.footerRuleWidth", "rhythm.footerCells.0", "rhythm.footerCells.1",
|
|
86
|
+
"small", "buttonClass", "smallLink", "colophon", "copyright",
|
|
87
|
+
"sentenceBreak", "signoff",
|
|
88
|
+
]);
|
|
89
|
+
const {
|
|
90
|
+
theme, colours, rhythm, small, buttonClass, smallLink, colophon,
|
|
91
|
+
copyright, sentenceBreak, signoff: name,
|
|
92
|
+
} = options;
|
|
93
|
+
|
|
94
|
+
/** A link inside the message: body-coloured and underlined, which
|
|
95
|
+
* leaves the action colour meaning "action". */
|
|
96
|
+
function bodyLink(href, text) {
|
|
97
|
+
return (
|
|
98
|
+
`<a href="${href}" style="color:${theme.colours.ink};`
|
|
99
|
+
+ `text-decoration:underline;">${text}</a>`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the message is, on its own ground. */
|
|
104
|
+
function card(title) {
|
|
105
|
+
return (
|
|
106
|
+
`${TABLE}\n`
|
|
107
|
+
+ ` style="background-color:${colours.titleGround};"><tr><td`
|
|
108
|
+
+ ` style="padding:${rhythm.cardPadding};"><div style="font-family:`
|
|
109
|
+
+ `${theme.fonts.heading};color:${theme.colours.heading};font-size:22px;`
|
|
110
|
+
+ "font-weight:700;line-height:28px;letter-spacing:-0.01em;\">"
|
|
111
|
+
+ `${title}</div></td></tr></table>\n\n`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** White air between bands: a cell with a height, because a margin on
|
|
116
|
+
* the table above would be dropped by several clients. */
|
|
117
|
+
function spacer(height) {
|
|
118
|
+
return (
|
|
119
|
+
`${TABLE}\n`
|
|
120
|
+
+ ` style="background-color:${theme.colours.page};"><tr><td`
|
|
121
|
+
+ ` style="height:${height}px;line-height:${height}px;`
|
|
122
|
+
+ "font-size:0;\"> </td></tr></table>\n\n"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A paragraph of the message itself. */
|
|
127
|
+
function p(html, margin = PARAGRAPH_MARGIN) {
|
|
128
|
+
return (
|
|
129
|
+
`<p style="font-family:${theme.fonts.body};color:${theme.colours.ink};`
|
|
130
|
+
+ `font-size:${BODY_SIZE};line-height:${theme.leading.prose};`
|
|
131
|
+
+ "font-weight:400;"
|
|
132
|
+
+ `margin:${margin};">${html}</p>\n\n`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one action, in its own cell so the space around it does not
|
|
137
|
+
* depend on the paragraph above collapsing its margin. */
|
|
138
|
+
function button(href, label) {
|
|
139
|
+
return (
|
|
140
|
+
`${TABLE}><tr><td style="padding:${rhythm.buttonCell};">`
|
|
141
|
+
+ `<a class="${buttonClass}"\n`
|
|
142
|
+
+ ` href="${href}"\n`
|
|
143
|
+
+ ` style="font-family:${theme.fonts.body};display:inline-block;`
|
|
144
|
+
+ `background-color:${colours.action};`
|
|
145
|
+
+ `color:${colours.actionInk} !important;`
|
|
146
|
+
+ `font-size:${rhythm.buttonSize};font-weight:600;line-height:1.2;`
|
|
147
|
+
+ `padding:${rhythm.buttonPadding};text-decoration:none;">`
|
|
148
|
+
+ `<span style="color:${colours.actionInk} !important;">`
|
|
149
|
+
+ `${label}</span></a></td></tr></table>\n\n`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Signed off, with room above so it reads as the end of the message
|
|
154
|
+
* rather than another paragraph of it. */
|
|
155
|
+
function signoff() {
|
|
156
|
+
return p(name, "24px 0 16px 0");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The small print: the colophon, how to leave, and the copyright line.
|
|
161
|
+
*
|
|
162
|
+
* `subscription` is false on the confirmation and its reminder, where
|
|
163
|
+
* there is no subscription yet to manage or leave. It has no default:
|
|
164
|
+
* a repo hiding Buttondown's own unsubscribe line relies on this one,
|
|
165
|
+
* so leaving it out by mistake would send an email with no way to
|
|
166
|
+
* leave.
|
|
167
|
+
*/
|
|
168
|
+
function footer({subscription} = {}) {
|
|
169
|
+
if (typeof subscription !== "boolean") {
|
|
170
|
+
throw new Error("footer() needs subscription: true or false");
|
|
171
|
+
}
|
|
172
|
+
const [top, bottom] = rhythm.footerCells;
|
|
173
|
+
return (
|
|
174
|
+
`${TABLE}\n`
|
|
175
|
+
+ " style=\"margin-top:40px;"
|
|
176
|
+
+ `background-color:${colours.footerGround};\n`
|
|
177
|
+
+ ` border-top:${rhythm.footerRuleWidth} solid `
|
|
178
|
+
+ `${colours.footerRule};"><tr><td style="padding:${top};">`
|
|
179
|
+
+ `<div style="${small}">${colophon}`
|
|
180
|
+
+ (subscription
|
|
181
|
+
? `${sentenceBreak}You can ${smallLink(UNSUBSCRIBE, "unsubscribe")}`
|
|
182
|
+
+ ` or ${smallLink(MANAGE, "manage your subscription")}.`
|
|
183
|
+
: "")
|
|
184
|
+
+ `</div></td></tr><tr><td style="padding:${bottom};">`
|
|
185
|
+
+ `<div style="${small}">${copyright}</div>`
|
|
186
|
+
+ "</td></tr></table>\n"
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {bodyLink, button, card, footer, p, signoff, spacer};
|
|
191
|
+
}
|
package/buttondown/engine.mjs
CHANGED
|
@@ -64,8 +64,8 @@ const DRAFT = "draft";
|
|
|
64
64
|
|
|
65
65
|
const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
66
66
|
|
|
67
|
-
/** The
|
|
68
|
-
const SKIN = {web: "web_css
|
|
67
|
+
/** The Buttondown fields holding the two skins. */
|
|
68
|
+
const SKIN = {web: "web_css", email: "css"};
|
|
69
69
|
|
|
70
70
|
/** Fields Buttondown stores behind its image proxy. */
|
|
71
71
|
const IMAGE_FIELDS = new Set(["icon", "image"]);
|
|
@@ -101,10 +101,11 @@ export async function loadSettings(root = process.cwd()) {
|
|
|
101
101
|
+ "stylesheet the site's design tokens are declared in");
|
|
102
102
|
}
|
|
103
103
|
const at = (p) => path.resolve(root, p);
|
|
104
|
+
const dir = at(config.dir ?? "buttondown");
|
|
104
105
|
return {
|
|
105
106
|
root,
|
|
106
107
|
newsletter: config.newsletter,
|
|
107
|
-
dir
|
|
108
|
+
dir,
|
|
108
109
|
globals: at(config.globals),
|
|
109
110
|
tokens: config.tokens ?? [],
|
|
110
111
|
extra: config.extra ?? [],
|
|
@@ -112,6 +113,10 @@ export async function loadSettings(root = process.cwd()) {
|
|
|
112
113
|
allowedHides: config.allowedHides ?? {},
|
|
113
114
|
linkClasses: Object.values(config.linkClasses ?? {}),
|
|
114
115
|
ownLinks: [...BUTTONDOWN_LINKS, ...(config.ownLinks ?? [])],
|
|
116
|
+
// The file holding the web skin's TOKENS block, which `tokens`
|
|
117
|
+
// rewrites: the field file itself unless the field is composed.
|
|
118
|
+
webSkin: config.webSkin
|
|
119
|
+
? at(config.webSkin) : path.join(dir, "fields", "web_css.css"),
|
|
115
120
|
paletteFiles: (config.paletteFiles ?? []).map(at),
|
|
116
121
|
checks: (config.checks ?? []).map(at),
|
|
117
122
|
};
|
|
@@ -206,7 +211,7 @@ export function tokensBlock(settings, css) {
|
|
|
206
211
|
/** Rewrites the web skin's TOKENS block from the site's tokens. */
|
|
207
212
|
export async function writeTokens(settings) {
|
|
208
213
|
const css = await readFile(settings.globals, "utf8");
|
|
209
|
-
const file =
|
|
214
|
+
const file = settings.webSkin;
|
|
210
215
|
const web = await readFile(file, "utf8");
|
|
211
216
|
const {s, e} = markers(web);
|
|
212
217
|
await writeFile(
|
|
@@ -217,11 +222,42 @@ export async function writeTokens(settings) {
|
|
|
217
222
|
/** Every problem with the repo's skins and settings; empty when clean. */
|
|
218
223
|
export async function runCheck(settings) {
|
|
219
224
|
const css = await readFile(settings.globals, "utf8");
|
|
220
|
-
const web = await readIf(skinPath(settings, "web"));
|
|
221
|
-
const email = await readIf(skinPath(settings, "email"));
|
|
222
225
|
const payload = await buildPayload(settings);
|
|
226
|
+
// The skins as sent, base rules and site rules together, so what is
|
|
227
|
+
// checked is what Buttondown receives. Absent, there is nothing to
|
|
228
|
+
// check, which is refused rather than passed.
|
|
229
|
+
for (const field of Object.values(SKIN)) {
|
|
230
|
+
if (typeof payload[field] !== "string") {
|
|
231
|
+
throw new Error(`the payload has no ${field} field to check`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const web = payload[SKIN.web];
|
|
235
|
+
const email = payload[SKIN.email];
|
|
223
236
|
const problems = [];
|
|
224
237
|
|
|
238
|
+
// An @import must be a whole statement before any rule: a browser
|
|
239
|
+
// ignores one after a rule, and one whose URL was cut short swallows
|
|
240
|
+
// what follows it. Read past the leading statements as the composer
|
|
241
|
+
// does; any @import left over, outside a string, is one of those.
|
|
242
|
+
const rest = stripComments(web.slice(preludeEnd(web)))
|
|
243
|
+
.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
244
|
+
if (/@import/i.test(rest)) {
|
|
245
|
+
problems.push("the web skin has an @import that is not a complete "
|
|
246
|
+
+ "statement before its first rule, which a browser ignores");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Every --bd-* role the shared rules read must be declared, or the
|
|
250
|
+
// declaration reading it is dropped without an error.
|
|
251
|
+
const declared = new Set([...stripComments(web)
|
|
252
|
+
.matchAll(/(--bd-[\w-]+)\s*:/g)].map((m) => m[1]));
|
|
253
|
+
for (const role of new Set([...stripComments(web)
|
|
254
|
+
.matchAll(/var\((--bd-[\w-]+)/g)].map((m) => m[1]))) {
|
|
255
|
+
if (!declared.has(role)) {
|
|
256
|
+
problems.push(`the web skin reads ${role}, which nothing declares: `
|
|
257
|
+
+ "map it in the TOKENS block");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
225
261
|
if (settings.tokens.length || settings.extra.length) {
|
|
226
262
|
const {s, e} = markers(web);
|
|
227
263
|
if (web.slice(s, e) !== tokensBlock(settings, css)) {
|
|
@@ -574,6 +610,29 @@ export async function emit(settings, dir) {
|
|
|
574
610
|
return written;
|
|
575
611
|
}
|
|
576
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Where a stylesheet's leading statements end: its opening comments and its
|
|
615
|
+
* `@charset`, `@import` and `@layer` statements, which must come before any
|
|
616
|
+
* rule. An @import's URL may hold semicolons (a font request's weights), so
|
|
617
|
+
* it is read whole before its closing `;`; one that isn't a whole statement
|
|
618
|
+
* ends the leading statements there.
|
|
619
|
+
*/
|
|
620
|
+
export function preludeEnd(css) {
|
|
621
|
+
let at = 0;
|
|
622
|
+
for (;;) {
|
|
623
|
+
// A URL never holds a newline or a brace, so one that does is an
|
|
624
|
+
// import cut short, and ends the leading statements there.
|
|
625
|
+
const url = "(?:\"[^\"\\n{}]*\"|'[^'\\n{}]*')";
|
|
626
|
+
const lead = css.slice(at).match(new RegExp(
|
|
627
|
+
"^\\s*(\\/\\*[\\s\\S]*?\\*\\/|@charset\\s+\"[^\"]*\";"
|
|
628
|
+
+ `|@import\\s+(?:url\\((?:${url}|[^)"'\\n{}]*)\\)|${url})[^;{]*;`
|
|
629
|
+
+ "|@layer\\s+[^;{]*;)", "i",
|
|
630
|
+
));
|
|
631
|
+
if (!lead) return at;
|
|
632
|
+
at += lead[0].length;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
577
636
|
/** Comments removed by index rather than a lazy regex, which backtracks
|
|
578
637
|
* between its two literals. */
|
|
579
638
|
export function stripComments(text) {
|
|
@@ -591,16 +650,13 @@ export function stripComments(text) {
|
|
|
591
650
|
|
|
592
651
|
/** Why a call failed: Buttondown's `detail`, else its whole body, which
|
|
593
652
|
* names the fields it refused, else the status. */
|
|
594
|
-
|
|
595
|
-
JSON.stringify(res.data?.detail ?? res.data ?? res.status);
|
|
596
|
-
|
|
597
|
-
const newsletterPath = (newsletter) => `/newsletters/${newsletter.id}`;
|
|
598
|
-
|
|
599
|
-
const skinPath = (settings, kind) =>
|
|
600
|
-
path.join(settings.dir, "fields", SKIN[kind]);
|
|
653
|
+
function detail(res) {
|
|
654
|
+
return JSON.stringify(res.data?.detail ?? res.data ?? res.status);
|
|
655
|
+
}
|
|
601
656
|
|
|
602
|
-
|
|
603
|
-
|
|
657
|
+
function newsletterPath(newsletter) {
|
|
658
|
+
return `/newsletters/${newsletter.id}`;
|
|
659
|
+
}
|
|
604
660
|
|
|
605
661
|
/** Whether `index` sits directly in a top-level `:root` or `@theme` block:
|
|
606
662
|
* `:root.dark`, `:root[data-theme]` and a `:root` nested in an at-rule are
|
|
@@ -613,7 +669,8 @@ function isBaseBlock(css, index) {
|
|
|
613
669
|
return depth === 1 && (head === ":root" || /^@theme(\s|$)/.test(head));
|
|
614
670
|
}
|
|
615
671
|
|
|
616
|
-
/** The head of the block enclosing `index`, or "" at the top level.
|
|
672
|
+
/** The head of the block enclosing `index`, or "" at the top level. A
|
|
673
|
+
* comment before the head is not part of it, even one holding a `;`. */
|
|
617
674
|
function enclosing(css, index) {
|
|
618
675
|
let depth = 0;
|
|
619
676
|
for (let i = index; i >= 0; i -= 1) {
|
|
@@ -621,8 +678,9 @@ function enclosing(css, index) {
|
|
|
621
678
|
if (css[i] === "{") {
|
|
622
679
|
if (depth === 0) {
|
|
623
680
|
const head = css.slice(0, i);
|
|
624
|
-
|
|
625
|
-
head.lastIndexOf(";"), head.lastIndexOf("{")) + 1)
|
|
681
|
+
const tail = head.slice(Math.max(head.lastIndexOf("}"),
|
|
682
|
+
head.lastIndexOf(";"), head.lastIndexOf("{")) + 1);
|
|
683
|
+
return stripComments(tail).replace(/^[\s\S]*\*\//, "").trim();
|
|
626
684
|
}
|
|
627
685
|
depth -= 1;
|
|
628
686
|
}
|
|
@@ -673,12 +731,14 @@ function declarations(text) {
|
|
|
673
731
|
|
|
674
732
|
/** Declaration values only, so a selector such as `a:not(#cafe)` is not
|
|
675
733
|
* read as a colour. */
|
|
676
|
-
|
|
677
|
-
declarations(text).map(([, value]) => value).join(" ");
|
|
734
|
+
function cssValues(text) {
|
|
735
|
+
return declarations(text).map(([, value]) => value).join(" ");
|
|
736
|
+
}
|
|
678
737
|
|
|
679
738
|
/** An SVG's attribute values. */
|
|
680
|
-
|
|
681
|
-
[...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
|
|
739
|
+
function attributeValues(text) {
|
|
740
|
+
return [...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
|
|
741
|
+
}
|
|
682
742
|
|
|
683
743
|
function offPalette(values, palette) {
|
|
684
744
|
return [...new Set(
|
package/buttondown/index.d.mts
CHANGED
|
@@ -26,6 +26,8 @@ export interface Settings {
|
|
|
26
26
|
newsletter: Newsletter;
|
|
27
27
|
dir: string;
|
|
28
28
|
globals: string;
|
|
29
|
+
/** The file holding the web skin's TOKENS block. */
|
|
30
|
+
webSkin: string;
|
|
29
31
|
tokens: [target: string, source: string][];
|
|
30
32
|
extra: [target: string, value: string][];
|
|
31
33
|
asserts: Record<string, string>;
|
|
@@ -44,7 +46,8 @@ export interface CheckContext {
|
|
|
44
46
|
payload: Payload;
|
|
45
47
|
/** The site's tokens stylesheet. */
|
|
46
48
|
css: string;
|
|
47
|
-
/** The web skin and the email skin
|
|
49
|
+
/** The web skin and the email skin as sent: composed, base rules and
|
|
50
|
+
* site rules together. */
|
|
48
51
|
web: string;
|
|
49
52
|
email: string;
|
|
50
53
|
readToken(css: string, name: string): string;
|
|
@@ -64,3 +67,59 @@ export function buildPayload(settings: Settings): Promise<Payload>;
|
|
|
64
67
|
export function tokenReader(
|
|
65
68
|
file: string | URL,
|
|
66
69
|
): Promise<(name: string) => string>;
|
|
70
|
+
|
|
71
|
+
/** The opening tag every band's table shares. */
|
|
72
|
+
export const TABLE: string;
|
|
73
|
+
|
|
74
|
+
/** A site's email theme, read by both `emailBaseCss` and
|
|
75
|
+
* `createEmailBlocks`, so each font and colour has one producer. */
|
|
76
|
+
export interface EmailTheme {
|
|
77
|
+
fonts: {body: string; heading: string};
|
|
78
|
+
colours: {page: string; ink: string; heading: string};
|
|
79
|
+
/** Line heights: of the page, and of prose. */
|
|
80
|
+
leading: {body: string; prose: string};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EmailBlockOptions {
|
|
84
|
+
theme: EmailTheme;
|
|
85
|
+
colours: {
|
|
86
|
+
titleGround: string; action: string; actionInk: string;
|
|
87
|
+
footerGround: string; footerRule: string;
|
|
88
|
+
};
|
|
89
|
+
rhythm: {
|
|
90
|
+
cardPadding: string; buttonCell: string; buttonSize: string;
|
|
91
|
+
buttonPadding: string; footerRuleWidth: string;
|
|
92
|
+
footerCells: [top: string, bottom: string];
|
|
93
|
+
};
|
|
94
|
+
/** The small print's inline style. */
|
|
95
|
+
small: string;
|
|
96
|
+
/** The class the email skin keys the button's rule on. */
|
|
97
|
+
buttonClass: string;
|
|
98
|
+
smallLink(href: string, text: string): string;
|
|
99
|
+
colophon: string;
|
|
100
|
+
copyright: string;
|
|
101
|
+
/** What joins the colophon to the subscription sentence. */
|
|
102
|
+
sentenceBreak: string;
|
|
103
|
+
signoff: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface EmailBlocks {
|
|
107
|
+
bodyLink(href: string, text: string): string;
|
|
108
|
+
button(href: string, label: string): string;
|
|
109
|
+
card(title: string): string;
|
|
110
|
+
footer(options: {subscription: boolean}): string;
|
|
111
|
+
p(html: string, margin?: string): string;
|
|
112
|
+
signoff(): string;
|
|
113
|
+
spacer(height: number): string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The bands an email is built from, in one site's theme. */
|
|
117
|
+
export function createEmailBlocks(options: EmailBlockOptions): EmailBlocks;
|
|
118
|
+
|
|
119
|
+
/** The web skin: the shared rules inserted after the site stylesheet's
|
|
120
|
+
* leading comments and @charset, @import and @layer statements, ahead of
|
|
121
|
+
* its own rules. */
|
|
122
|
+
export function composeWebSkin(site: string): string;
|
|
123
|
+
|
|
124
|
+
/** The shared email rules, in the site's theme, with literal values. */
|
|
125
|
+
export function emailBaseCss(theme: EmailTheme): string;
|
package/buttondown/index.mjs
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules every site's skins share, for a repo whose `fields/web_css.mjs`
|
|
3
|
+
* and `fields/css.mjs` compose a field from them and the site's own rules.
|
|
4
|
+
*/
|
|
5
|
+
import {readFileSync} from "node:fs";
|
|
6
|
+
import {
|
|
7
|
+
BODY_SIZE, PARAGRAPH_MARGIN, requireTheme,
|
|
8
|
+
} from "./email-blocks.mjs";
|
|
9
|
+
import {EMAIL_CHROME_HIDES, preludeEnd} from "./engine.mjs";
|
|
10
|
+
|
|
11
|
+
/** The shared web rules: base-web.css, which reads every value from the
|
|
12
|
+
* variables the site's TOKENS block defines. */
|
|
13
|
+
const WEB_BASE = readFileSync(
|
|
14
|
+
new URL("./base-web.css", import.meta.url), "utf8",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The web skin: the site's stylesheet with the shared rules inserted after
|
|
19
|
+
* its opening comments and `@import`s, which must come before any rule,
|
|
20
|
+
* and ahead of its own rules, so the site's win a tie.
|
|
21
|
+
*/
|
|
22
|
+
export function composeWebSkin(site) {
|
|
23
|
+
const at = preludeEnd(site);
|
|
24
|
+
const head = site.slice(0, at);
|
|
25
|
+
return `${head}${head ? "\n\n" : ""}${WEB_BASE}\n${site.slice(at)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The shared email rules, in the site's email theme (see requireTheme), with
|
|
30
|
+
* literal values: a mail client cannot resolve a CSS custom property, and
|
|
31
|
+
* Buttondown inlines the stylesheet before sending. The site's own
|
|
32
|
+
* stylesheet follows it: fonts it loads, its link colour, its button, its
|
|
33
|
+
* quotations and its small print.
|
|
34
|
+
*/
|
|
35
|
+
export function emailBaseCss(theme) {
|
|
36
|
+
requireTheme("emailBaseCss", theme);
|
|
37
|
+
const {fonts, colours, leading} = theme;
|
|
38
|
+
const hides = Object.keys(EMAIL_CHROME_HIDES)
|
|
39
|
+
.map((selector) => `${selector} { display: none !important; }`);
|
|
40
|
+
return [
|
|
41
|
+
`body, td, div { font-family: ${fonts.body}; color: ${colours.ink}; `
|
|
42
|
+
+ `font-size: ${BODY_SIZE}; line-height: ${leading.body}; }`,
|
|
43
|
+
// Buttondown inlines its own grey; important, so this is the last.
|
|
44
|
+
`body { background-color: ${colours.page} !important; }`,
|
|
45
|
+
// Unscoped, because the template rounds its card, its quotation and
|
|
46
|
+
// whatever it adds next under class names of its own.
|
|
47
|
+
"* { border-radius: 0 !important; }",
|
|
48
|
+
// Forced, because the template styles the paragraph it wraps around
|
|
49
|
+
// the content and wins on specificity.
|
|
50
|
+
`p, li { font-family: ${fonts.body} !important; `
|
|
51
|
+
+ `color: ${colours.ink} !important; font-size: ${BODY_SIZE} !important; `
|
|
52
|
+
+ `line-height: ${leading.prose} !important; `
|
|
53
|
+
+ "font-weight: 400 !important; text-align: justify !important; "
|
|
54
|
+
+ "hyphens: auto; -webkit-hyphens: auto; -ms-hyphens: auto; }",
|
|
55
|
+
`p { margin: ${PARAGRAPH_MARGIN}; }`,
|
|
56
|
+
`ul, ol { margin: ${PARAGRAPH_MARGIN}; padding-left: 24px; `
|
|
57
|
+
+ `color: ${colours.ink}; font-size: ${BODY_SIZE} !important; }`,
|
|
58
|
+
"li { margin: 0 0 6px 0; }",
|
|
59
|
+
`h1, h2, h3, h4 { font-family: ${fonts.heading}; `
|
|
60
|
+
+ `color: ${colours.heading}; line-height: 1.2; `
|
|
61
|
+
+ "letter-spacing: -0.01em; margin: 32px 0 12px 0; }",
|
|
62
|
+
// Smaller than a page's: read in a narrow pane under a subject the
|
|
63
|
+
// reader has already seen.
|
|
64
|
+
"h1 { font-size: 26px; font-weight: 700; line-height: 32px; }",
|
|
65
|
+
"h2 { font-size: 20px; font-weight: 700; line-height: 28px; }",
|
|
66
|
+
"h3 { font-size: 18px; line-height: 26px; }",
|
|
67
|
+
// The template inlines a size and a heavier weight on the subject.
|
|
68
|
+
"h1.subject, .newsletter-masthead h1 { font-size: 26px !important; "
|
|
69
|
+
+ "line-height: 32px !important; font-weight: 700 !important; }",
|
|
70
|
+
`strong, b { font-weight: 600; color: ${colours.ink}; }`,
|
|
71
|
+
"a { text-decoration: underline; text-decoration-thickness: 1px; "
|
|
72
|
+
+ "text-underline-offset: 0.15em; }",
|
|
73
|
+
"hr { border: 0; margin: 32px 0; }",
|
|
74
|
+
"blockquote { background: transparent !important; margin: 24px 0; }",
|
|
75
|
+
// Longhands, because Buttondown emits its own border properties after
|
|
76
|
+
// these and would override the shorthand.
|
|
77
|
+
".email-container { border-width: 0 !important; "
|
|
78
|
+
+ "border-style: none !important; "
|
|
79
|
+
+ `background-color: ${colours.page} !important; }`,
|
|
80
|
+
".newsletter-footer { border: 0 !important; "
|
|
81
|
+
+ "background: transparent !important; padding: 0 !important; "
|
|
82
|
+
+ "margin: 0 !important; }",
|
|
83
|
+
...hides,
|
|
84
|
+
].join("\n") + "\n\n";
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|