@casomoltd/tooling 0.32.1 → 0.33.1
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 +92 -20
- 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
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* stylesheet is read verbatim, and an `.mjs` file's default export is the
|
|
30
30
|
* field's value.
|
|
31
31
|
*/
|
|
32
|
-
import {existsSync} from "node:fs";
|
|
32
|
+
import {existsSync, readFileSync} from "node:fs";
|
|
33
33
|
import {mkdir, readdir, readFile, writeFile} from "node:fs/promises";
|
|
34
34
|
import os from "node:os";
|
|
35
35
|
import path from "node:path";
|
|
@@ -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"]);
|
|
@@ -77,6 +77,14 @@ const IDENTITY = ["id", "publicId", "username"];
|
|
|
77
77
|
/** Hrefs Buttondown draws itself, which no socials entry names. */
|
|
78
78
|
const BUTTONDOWN_LINKS = ["buttondown.com"];
|
|
79
79
|
|
|
80
|
+
/** The shared web rules' opening line, which marks a composed skin, and
|
|
81
|
+
* the variables those rules read, which such a skin must declare. */
|
|
82
|
+
const BASE_WEB = readFileSync(new URL("./base-web.css", import.meta.url), "utf8");
|
|
83
|
+
const BASE_MARK = BASE_WEB.slice(0, BASE_WEB.indexOf("\n"));
|
|
84
|
+
const BASE_VARS = [...new Set(
|
|
85
|
+
[...BASE_WEB.matchAll(/var\((--[\w-]+)/g)].map((m) => m[1]),
|
|
86
|
+
)];
|
|
87
|
+
|
|
80
88
|
/** Values of `content:` that remove text rather than inject it. */
|
|
81
89
|
const NO_CONTENT = new Set(["none", "normal", '""', "''"]);
|
|
82
90
|
|
|
@@ -101,10 +109,11 @@ export async function loadSettings(root = process.cwd()) {
|
|
|
101
109
|
+ "stylesheet the site's design tokens are declared in");
|
|
102
110
|
}
|
|
103
111
|
const at = (p) => path.resolve(root, p);
|
|
112
|
+
const dir = at(config.dir ?? "buttondown");
|
|
104
113
|
return {
|
|
105
114
|
root,
|
|
106
115
|
newsletter: config.newsletter,
|
|
107
|
-
dir
|
|
116
|
+
dir,
|
|
108
117
|
globals: at(config.globals),
|
|
109
118
|
tokens: config.tokens ?? [],
|
|
110
119
|
extra: config.extra ?? [],
|
|
@@ -112,6 +121,10 @@ export async function loadSettings(root = process.cwd()) {
|
|
|
112
121
|
allowedHides: config.allowedHides ?? {},
|
|
113
122
|
linkClasses: Object.values(config.linkClasses ?? {}),
|
|
114
123
|
ownLinks: [...BUTTONDOWN_LINKS, ...(config.ownLinks ?? [])],
|
|
124
|
+
// The file holding the web skin's TOKENS block, which `tokens`
|
|
125
|
+
// rewrites: the field file itself unless the field is composed.
|
|
126
|
+
webSkin: config.webSkin
|
|
127
|
+
? at(config.webSkin) : path.join(dir, "fields", "web_css.css"),
|
|
115
128
|
paletteFiles: (config.paletteFiles ?? []).map(at),
|
|
116
129
|
checks: (config.checks ?? []).map(at),
|
|
117
130
|
};
|
|
@@ -206,7 +219,7 @@ export function tokensBlock(settings, css) {
|
|
|
206
219
|
/** Rewrites the web skin's TOKENS block from the site's tokens. */
|
|
207
220
|
export async function writeTokens(settings) {
|
|
208
221
|
const css = await readFile(settings.globals, "utf8");
|
|
209
|
-
const file =
|
|
222
|
+
const file = settings.webSkin;
|
|
210
223
|
const web = await readFile(file, "utf8");
|
|
211
224
|
const {s, e} = markers(web);
|
|
212
225
|
await writeFile(
|
|
@@ -217,11 +230,48 @@ export async function writeTokens(settings) {
|
|
|
217
230
|
/** Every problem with the repo's skins and settings; empty when clean. */
|
|
218
231
|
export async function runCheck(settings) {
|
|
219
232
|
const css = await readFile(settings.globals, "utf8");
|
|
220
|
-
const web = await readIf(skinPath(settings, "web"));
|
|
221
|
-
const email = await readIf(skinPath(settings, "email"));
|
|
222
233
|
const payload = await buildPayload(settings);
|
|
234
|
+
// The skins as sent, base rules and site rules together, so what is
|
|
235
|
+
// checked is what Buttondown receives. Absent, there is nothing to
|
|
236
|
+
// check, which is refused rather than passed.
|
|
237
|
+
for (const field of Object.values(SKIN)) {
|
|
238
|
+
if (typeof payload[field] !== "string") {
|
|
239
|
+
throw new Error(`the payload has no ${field} field to check`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const web = payload[SKIN.web];
|
|
243
|
+
const email = payload[SKIN.email];
|
|
223
244
|
const problems = [];
|
|
224
245
|
|
|
246
|
+
// An @import must be a whole statement before any rule: a browser
|
|
247
|
+
// ignores one after a rule, and one whose URL was cut short swallows
|
|
248
|
+
// what follows it. Read past the leading statements as the composer
|
|
249
|
+
// does; any @import left over, outside a string, is one of those.
|
|
250
|
+
const rest = stripComments(web.slice(preludeEnd(web)))
|
|
251
|
+
.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
252
|
+
if (/@import/i.test(rest)) {
|
|
253
|
+
problems.push("the web skin has an @import that is not a complete "
|
|
254
|
+
+ "statement before its first rule, which a browser ignores");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Every variable the shared rules read, and every --bd-* role read
|
|
258
|
+
// anywhere, must be declared, or the declaration reading it is dropped
|
|
259
|
+
// without an error.
|
|
260
|
+
const plain = stripComments(web);
|
|
261
|
+
const declared = new Set(
|
|
262
|
+
[...plain.matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1]),
|
|
263
|
+
);
|
|
264
|
+
const read = new Set([
|
|
265
|
+
...web.includes(BASE_MARK) ? BASE_VARS : [],
|
|
266
|
+
...[...plain.matchAll(/var\((--bd-[\w-]+)/g)].map((m) => m[1]),
|
|
267
|
+
]);
|
|
268
|
+
for (const name of read) {
|
|
269
|
+
if (!declared.has(name)) {
|
|
270
|
+
problems.push(`the web skin reads ${name}, which nothing declares: `
|
|
271
|
+
+ "map it in the TOKENS block");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
225
275
|
if (settings.tokens.length || settings.extra.length) {
|
|
226
276
|
const {s, e} = markers(web);
|
|
227
277
|
if (web.slice(s, e) !== tokensBlock(settings, css)) {
|
|
@@ -574,6 +624,29 @@ export async function emit(settings, dir) {
|
|
|
574
624
|
return written;
|
|
575
625
|
}
|
|
576
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Where a stylesheet's leading statements end: its opening comments and its
|
|
629
|
+
* `@charset`, `@import` and `@layer` statements, which must come before any
|
|
630
|
+
* rule. An @import's URL may hold semicolons (a font request's weights), so
|
|
631
|
+
* it is read whole before its closing `;`; one that isn't a whole statement
|
|
632
|
+
* ends the leading statements there.
|
|
633
|
+
*/
|
|
634
|
+
export function preludeEnd(css) {
|
|
635
|
+
let at = 0;
|
|
636
|
+
for (;;) {
|
|
637
|
+
// A URL never holds a newline or a brace, so one that does is an
|
|
638
|
+
// import cut short, and ends the leading statements there.
|
|
639
|
+
const url = "(?:\"[^\"\\n{}]*\"|'[^'\\n{}]*')";
|
|
640
|
+
const lead = css.slice(at).match(new RegExp(
|
|
641
|
+
"^\\s*(\\/\\*[\\s\\S]*?\\*\\/|@charset\\s+\"[^\"]*\";"
|
|
642
|
+
+ `|@import\\s+(?:url\\((?:${url}|[^)"'\\n{}]*)\\)|${url})[^;{]*;`
|
|
643
|
+
+ "|@layer\\s+[^;{]*;)", "i",
|
|
644
|
+
));
|
|
645
|
+
if (!lead) return at;
|
|
646
|
+
at += lead[0].length;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
577
650
|
/** Comments removed by index rather than a lazy regex, which backtracks
|
|
578
651
|
* between its two literals. */
|
|
579
652
|
export function stripComments(text) {
|
|
@@ -591,16 +664,13 @@ export function stripComments(text) {
|
|
|
591
664
|
|
|
592
665
|
/** Why a call failed: Buttondown's `detail`, else its whole body, which
|
|
593
666
|
* 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]);
|
|
667
|
+
function detail(res) {
|
|
668
|
+
return JSON.stringify(res.data?.detail ?? res.data ?? res.status);
|
|
669
|
+
}
|
|
601
670
|
|
|
602
|
-
|
|
603
|
-
|
|
671
|
+
function newsletterPath(newsletter) {
|
|
672
|
+
return `/newsletters/${newsletter.id}`;
|
|
673
|
+
}
|
|
604
674
|
|
|
605
675
|
/** Whether `index` sits directly in a top-level `:root` or `@theme` block:
|
|
606
676
|
* `:root.dark`, `:root[data-theme]` and a `:root` nested in an at-rule are
|
|
@@ -675,12 +745,14 @@ function declarations(text) {
|
|
|
675
745
|
|
|
676
746
|
/** Declaration values only, so a selector such as `a:not(#cafe)` is not
|
|
677
747
|
* read as a colour. */
|
|
678
|
-
|
|
679
|
-
declarations(text).map(([, value]) => value).join(" ");
|
|
748
|
+
function cssValues(text) {
|
|
749
|
+
return declarations(text).map(([, value]) => value).join(" ");
|
|
750
|
+
}
|
|
680
751
|
|
|
681
752
|
/** An SVG's attribute values. */
|
|
682
|
-
|
|
683
|
-
[...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
|
|
753
|
+
function attributeValues(text) {
|
|
754
|
+
return [...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
|
|
755
|
+
}
|
|
684
756
|
|
|
685
757
|
function offPalette(values, palette) {
|
|
686
758
|
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.1",
|
|
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": {
|