@dataimago/css-neutral 0.1.0-alpha.12 → 0.1.0-alpha.13

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.
Files changed (60) hide show
  1. package/README.md +106 -0
  2. package/dist/bin/dataimago-sync.mjs +281 -19
  3. package/dist/dataviz/dataimago-dataviz.css +1 -1
  4. package/dist/dataviz/dataimago-dataviz.json +2 -2
  5. package/dist/dataviz/dataimago-dataviz.tex +1 -1
  6. package/dist/dataviz/dataimago_dataviz.R +1 -1
  7. package/dist/fonts/cyrillic/OFL-NotoSans.txt +93 -0
  8. package/dist/fonts/cyrillic/OFL-NotoSansMono.txt +93 -0
  9. package/dist/fonts/cyrillic/fonts.css +27 -0
  10. package/dist/fonts/cyrillic/noto-sans-italic.woff2 +0 -0
  11. package/dist/fonts/cyrillic/noto-sans-mono.woff2 +0 -0
  12. package/dist/fonts/cyrillic/noto-sans.woff2 +0 -0
  13. package/dist/fonts/cyrillic-ext/OFL-NotoSans.txt +93 -0
  14. package/dist/fonts/cyrillic-ext/OFL-NotoSansMono.txt +93 -0
  15. package/dist/fonts/cyrillic-ext/fonts.css +27 -0
  16. package/dist/fonts/cyrillic-ext/noto-sans-italic.woff2 +0 -0
  17. package/dist/fonts/cyrillic-ext/noto-sans-mono.woff2 +0 -0
  18. package/dist/fonts/cyrillic-ext/noto-sans.woff2 +0 -0
  19. package/dist/fonts/greek/OFL-NotoSans.txt +93 -0
  20. package/dist/fonts/greek/OFL-NotoSansMono.txt +93 -0
  21. package/dist/fonts/greek/fonts.css +27 -0
  22. package/dist/fonts/greek/noto-sans-italic.woff2 +0 -0
  23. package/dist/fonts/greek/noto-sans-mono.woff2 +0 -0
  24. package/dist/fonts/greek/noto-sans.woff2 +0 -0
  25. package/dist/fonts/greek-ext/OFL-NotoSans.txt +93 -0
  26. package/dist/fonts/greek-ext/OFL-NotoSansMono.txt +93 -0
  27. package/dist/fonts/greek-ext/fonts.css +27 -0
  28. package/dist/fonts/greek-ext/noto-sans-italic.woff2 +0 -0
  29. package/dist/fonts/greek-ext/noto-sans-mono.woff2 +0 -0
  30. package/dist/fonts/greek-ext/noto-sans.woff2 +0 -0
  31. package/dist/fonts/lang.css +181 -0
  32. package/dist/fonts/latin/OFL-NotoSans.txt +93 -0
  33. package/dist/fonts/latin/OFL-NotoSansMono.txt +93 -0
  34. package/dist/fonts/latin/fonts.css +27 -0
  35. package/dist/fonts/latin/noto-sans-italic.woff2 +0 -0
  36. package/dist/fonts/latin/noto-sans-mono.woff2 +0 -0
  37. package/dist/fonts/latin/noto-sans.woff2 +0 -0
  38. package/dist/fonts/latin-ext/OFL-NotoSans.txt +93 -0
  39. package/dist/fonts/latin-ext/OFL-NotoSansMono.txt +93 -0
  40. package/dist/fonts/latin-ext/fonts.css +27 -0
  41. package/dist/fonts/latin-ext/noto-sans-italic.woff2 +0 -0
  42. package/dist/fonts/latin-ext/noto-sans-mono.woff2 +0 -0
  43. package/dist/fonts/latin-ext/noto-sans.woff2 +0 -0
  44. package/dist/fonts/math/OFL-NotoSansMath.txt +93 -0
  45. package/dist/fonts/math/fonts.css +8 -0
  46. package/dist/fonts/math/noto-sans-math.woff2 +0 -0
  47. package/dist/fonts/vietnamese/OFL-NotoSans.txt +93 -0
  48. package/dist/fonts/vietnamese/OFL-NotoSansMono.txt +93 -0
  49. package/dist/fonts/vietnamese/fonts.css +27 -0
  50. package/dist/fonts/vietnamese/noto-sans-italic.woff2 +0 -0
  51. package/dist/fonts/vietnamese/noto-sans-mono.woff2 +0 -0
  52. package/dist/fonts/vietnamese/noto-sans.woff2 +0 -0
  53. package/dist/latex/dataimago-tokens.sty +14 -2
  54. package/dist/outputs.json +54 -8
  55. package/dist/quarto/neutral-dark.scss +14 -4
  56. package/dist/quarto/neutral-light.scss +14 -4
  57. package/dist/tailwind-preset.js +2 -4
  58. package/dist/tokens.css +2 -2
  59. package/dist/tokens.scss +2 -2
  60. package/package.json +3 -2
package/README.md CHANGED
@@ -67,6 +67,112 @@ in CI: it fails on any drift. The shipped outputs are listed in `dist/outputs.js
67
67
  (e.g. `https://unpkg.com/@dataimago/css-neutral@<version>/dist/outputs.json`). See
68
68
  [`wiki/patterns/new-consumer-checklist.md`](../../wiki/patterns/new-consumer-checklist.md).
69
69
 
70
+ ## Fonts
71
+
72
+ The package ships the token fonts as WOFF2 files, so a site or document does not depend on Google's CDN. Everything is under `dist/fonts/`, one directory per script, each with a `fonts.css` of `@font-face` rules and the licence text:
73
+
74
+ - Noto Sans, upright and italic, variable in weight (100 to 900), width pinned at 100.
75
+ - Noto Sans Mono, upright only, variable in weight (100 to 900).
76
+ - Noto Sans Math, one static weight (400), for equations.
77
+
78
+ Directories: latin, latin-ext, greek, greek-ext, cyrillic, cyrillic-ext, vietnamese, math. Each `@font-face` uses `font-display: swap`, a weight range read from the font file, and a `unicode-range` on the subset files, so a browser downloads only the scripts a page renders. There is no `local()` source and no preload. Subsets keep every layout feature the kept text uses; `lnum` and `tnum` work in Noto Sans. Noto Sans Mono's digits are monospaced and lining by design, and it has no figure features. `dist/fonts/lang.css` holds the language rules (below). The licence is OFL-1.1; `dist/fonts/` carries each licence file.
79
+
80
+ This package omits Josefin Sans: its tokens do not name it. `@dataimago/css` ships it.
81
+
82
+ Combining marks: the `latin` and `latin-ext` files also carry the whole combining-diacritics block (U+0300-036F), a documented addition to Google's ranges, so decomposed accents (for example e + U+0301, or o + U+031B) draw from the same file as their base letter.
83
+
84
+ ### Using them
85
+
86
+ `dist/fonts/**` is listed in `dist/outputs.json`, so `dataimago-sync` copies it. A key ending in `/` maps a whole directory to a destination ending in `/`.
87
+
88
+ **A web page.** Map every script you ship and `fonts/lang.css`, then link the CSS files. The browser fetches only what the page renders.
89
+
90
+ ```json
91
+ { "package": "@dataimago/css-neutral", "version": "<exact version>",
92
+ "outputs": { "fonts/latin/": "assets/fonts/latin/", "fonts/latin-ext/": "assets/fonts/latin-ext/",
93
+ "fonts/greek/": "assets/fonts/greek/", "fonts/greek-ext/": "assets/fonts/greek-ext/",
94
+ "fonts/cyrillic/": "assets/fonts/cyrillic/", "fonts/cyrillic-ext/": "assets/fonts/cyrillic-ext/",
95
+ "fonts/vietnamese/": "assets/fonts/vietnamese/",
96
+ "fonts/math/": "assets/fonts/math/", "fonts/lang.css": "assets/fonts/" } }
97
+ ```
98
+
99
+ **A self-contained Quarto output** (`embed-resources: true`). Map only the scripts the work's language needs, for example `fonts/latin/` and `fonts/latin-ext/` (add `fonts/vietnamese/` for Vietnamese text), plus `fonts/math/` if the work has equations and `fonts/lang.css`. List each CSS file under `css:` in `_quarto.yml` and set `html-math-method: mathml`. Quarto then embeds just the files those rules reach.
100
+
101
+ ```yaml
102
+ format:
103
+ html:
104
+ embed-resources: true
105
+ html-math-method: mathml
106
+ css:
107
+ - assets/fonts/latin/fonts.css
108
+ - assets/fonts/latin-ext/fonts.css
109
+ - assets/fonts/vietnamese/fonts.css
110
+ - assets/fonts/math/fonts.css
111
+ - assets/fonts/lang.css
112
+ ```
113
+
114
+ **A translated work in a script the package does not ship** (Arabic, Hebrew, Devanagari, and so on). `lang.css` already names the family. Load it from Google Fonts on the web, or make the files yourself (see "Making a script the package does not ship") and keep them in the work's own repository.
115
+
116
+ ### Other languages
117
+
118
+ Text in another language must carry a `lang` attribute, or the browser applies its own fallback. In Quarto write `[text]{lang=ar}` for a span, a fenced div `::: {lang=ar}` for a block, or `lang: ar` in the front matter for a whole work. `lang.css` maps each language to a family by `:lang()` and rebinds the display variables on headings and navigation only; the brand mark, code and maths are left alone. The tags `zh-CN`, `zh-SG` and bare `zh` map to Noto Sans SC (bare `zh` is treated as Simplified); `zh-TW`, `zh-HK` and `zh-MO` map to Noto Sans TC. Tag Traditional text `zh-Hant` or a Traditional region.
119
+
120
+ ### Maths
121
+
122
+ Equations use native MathML in `Noto Sans Math` (`font.family.math` is `Noto Sans Math, math`, so a reader without the font gets the browser's maths face). Set `html-math-method: mathml`. MathJax and KaTeX bring their own fonts, and with `embed-resources` Quarto fetches them from the network unless `self-contained-math` is set. A property that chooses one of them is outside this contract.
123
+
124
+ ### Synthesised oblique
125
+
126
+ Noto Sans Mono ships upright only. Italic text in them is the browser's synthesised oblique. Noto Sans has a true italic.
127
+
128
+ ### Preloading
129
+
130
+ Preloading is yours to opt into; the package does not. For the Latin body file, in the page head:
131
+
132
+ ```html
133
+ <link rel="preload" as="font" type="font/woff2" href="/assets/fonts/latin/noto-sans.woff2" crossorigin>
134
+ ```
135
+
136
+ `crossorigin` is required even for a same-origin font, or the browser fetches it twice.
137
+
138
+ ### Caching
139
+
140
+ File names are stable and change only when a family changes, so serve them with revalidation. That is the default on the hosts these properties use (confirmed 2026-10-31): GitHub Pages sends `cache-control: max-age=600` with an ETag, and Vercel serves static files with `public, max-age=0, must-revalidate`. Do not set a long immutable lifetime.
141
+
142
+ ### The Google alternative
143
+
144
+ A project that does not sync files can link Google's CDN instead. The house query is shown for reference; css-neutral ships no Josefin Sans, so drop the `family=Josefin+Sans:wght@100..700&` part:
145
+
146
+ ```
147
+ https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@100..700&family=Noto+Sans:ital,wdth,wght@0,100,100..900;1,100,100..900&family=Noto+Sans+Math&family=Noto+Sans+Mono:wght@100..900&display=swap
148
+ ```
149
+
150
+ Add `lang.css` as well. This loads full variable files from Google rather than the subsets, so a self-contained Quarto output becomes much larger. The Google route serves Google's own ranges, so decomposed accents using U+0302, U+0306 or U+031B fall back there (and on sites built from the Quarto website template, which uses that route); the shipped files carry the combining marks in the Latin files.
151
+
152
+ ### Making a script the package does not ship
153
+
154
+ `subset.mjs` in the dataimago-design repository has an out mode that fetches a pinned upstream file, checks its digest, subsets it and writes `<script>/<family>.woff2`, `<script>/fonts.css` and the licence into a directory outside `packages/`. It refuses a family whose licence reserves the font name.
155
+
156
+ ```bash
157
+ node tools/fonts/subset.mjs --out ../my-work/fonts \
158
+ --upstream <url pinned to a tag or commit> --sha256 <hex> --script arabic \
159
+ [--unicode-range "U+0600-06FF, U+200C-200E"] [--style normal|italic] \
160
+ --licence-url <url> --licence-sha256 <hex> \
161
+ [--member <path in archive> --member-sha256 <hex>] \
162
+ [--licence-member <path> --licence-member-sha256 <hex>]
163
+ ```
164
+
165
+ Without the tool, fontTools (the repository pins Python 3.13 and `tools/fonts/requirements.txt`) does the same in two steps. Pin the width axis if the font has one, then subset keeping every layout feature:
166
+
167
+ ```bash
168
+ fonttools varLib.instancer NotoSansArabic[wdth,wght].ttf wdth=100 --no-recalc-timestamp -o instanced.ttf
169
+ pyftsubset instanced.ttf --unicodes="U+0600-06FF,U+200C-200E" --layout-features='*' \
170
+ --name-IDs='*' --name-languages='*' --notdef-outline --no-recalc-timestamp \
171
+ --flavor=woff2 --output-file=noto-sans-arabic.woff2
172
+ ```
173
+
174
+ To wrap a file as WOFF2 without subsetting: `fonttools ttLib.woff2 compress -o out.woff2 in.ttf`. (The repository's tool also turns off table transforms so the tables stay byte-identical; the command line does not, so the bytes can differ while the font is the same.) Write the `@font-face` rule with `font-display: swap`, the font's own weight range and `unicode-range`, and no `local()`.
175
+
70
176
  ## Architectural notes
71
177
 
72
178
  Variant A of the two-tier identity pattern — see [`wiki/decisions/tokens-neutral-sibling.md`](../../wiki/decisions/tokens-neutral-sibling.md) for the full rationale, including the iteration-4 deprecation target (Variant B: neutral-as-base + `@dataimago/dataimago-theme` overlay).
@@ -27,6 +27,10 @@ import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
 
29
29
  const LOCAL = 'local-unreleased';
30
+ // Extensions whose files carry a stamp (mirrors kindOf in tools/formats/build-outputs.mjs; the
31
+ // self-test pins the two together). Everything else is binary: copied byte for byte, never stamped.
32
+ const TEXT_EXT = ['.scss', '.css', '.sty', '.tex', '.r', '.json', '.md', '.qmd', '.yml', '.js', '.lua', '.template'];
33
+ const isText = (distPath) => TEXT_EXT.includes(path.extname(distPath.replace(/\.template$/, '')).toLowerCase());
30
34
 
31
35
  class ConfigError extends Error {}
32
36
 
@@ -46,6 +50,11 @@ const USAGE = `dataimago-sync — copy a pinned @dataimago release's design file
46
50
  dataimago-sync --config <file> read design.json from <file>
47
51
  dataimago-sync --help this text
48
52
 
53
+ An outputs key ending in "/" copies every file the release lists under it into a destination
54
+ that also ends in "/". That directory is owned by the output; files in it the release does not
55
+ list are reported (--check fails on them) but never deleted. Binary files (fonts, licence text)
56
+ are copied byte for byte and carry no stamp.
57
+
49
58
  Run it from the pinned release: npx -p @dataimago/css@<version> dataimago-sync`;
50
59
 
51
60
  const FLAGS = new Set(['--check', '--help', '--self-test']);
@@ -117,60 +126,158 @@ function plan(args) {
117
126
 
118
127
  const manifest = readJson(path.join(pkgRoot, 'dist', 'outputs.json'), 'outputs.json (is the package built?)');
119
128
  const available = Object.keys(manifest.files ?? {});
129
+ const dirKeys = [...new Set(available.flatMap((k) => k.split('/').slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join('/') + '/')))].sort();
130
+ const realRoot = fs.realpathSync(consumerRoot);
120
131
  const steps = [];
132
+ const dirs = [];
121
133
  const problems = [];
134
+
135
+ /** Resolve a consumer-relative spec to an absolute path inside the root, or record why not. */
136
+ const inside = (key, destSpec, asDir) => {
137
+ if (typeof destSpec !== 'string' || destSpec === '' || path.isAbsolute(destSpec)) {
138
+ problems.push(`destination for "${key}" must be a relative path inside the consumer, got ${JSON.stringify(destSpec)}`);
139
+ return null;
140
+ }
141
+ const abs = path.resolve(consumerRoot, destSpec);
142
+ const rel = path.relative(consumerRoot, abs);
143
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
144
+ problems.push(`destination for "${key}" escapes the consumer root: ${destSpec}`);
145
+ return null;
146
+ }
147
+ if (rel === '' && asDir) {
148
+ problems.push(`destination for "${key}" is the consumer root; give a sub-directory`);
149
+ return null;
150
+ }
151
+ return { abs, rel };
152
+ };
153
+
122
154
  for (const [key, destSpec] of Object.entries(design.outputs)) {
155
+ if (key.endsWith('/')) {
156
+ const members = available.filter((k) => k.startsWith(key));
157
+ if (!members.length) {
158
+ problems.push(`${design.package} does not ship "${key}". Available outputs:\n ${[...dirKeys, ...available].join('\n ')}`);
159
+ continue;
160
+ }
161
+ if (typeof destSpec !== 'string' || !destSpec.endsWith('/')) {
162
+ problems.push(`destination for directory output "${key}" must end in "/", got ${JSON.stringify(destSpec)}`);
163
+ continue;
164
+ }
165
+ const d = inside(key, destSpec, true);
166
+ if (!d) continue;
167
+ dirs.push({ key, dest: d.abs, rel: d.rel });
168
+ for (const m of members) {
169
+ const dest = path.join(d.abs, m.slice(key.length));
170
+ steps.push({ key: m, src: path.join(pkgRoot, 'dist', m), dest, rel: path.relative(consumerRoot, dest), dirKey: key });
171
+ }
172
+ continue;
173
+ }
123
174
  if (!available.includes(key)) {
124
- problems.push(`${design.package} does not ship "${key}". Available outputs:\n ${available.join('\n ')}`);
175
+ problems.push(`${design.package} does not ship "${key}". Available outputs:\n ${[...dirKeys, ...available].join('\n ')}`);
125
176
  continue;
126
177
  }
127
178
  if (typeof destSpec !== 'string' || destSpec === '' || path.isAbsolute(destSpec)) {
128
179
  problems.push(`destination for "${key}" must be a relative path inside the consumer, got ${JSON.stringify(destSpec)}`);
129
180
  continue;
130
181
  }
131
- const dest = path.resolve(consumerRoot, destSpec.endsWith('/') ? path.join(destSpec, path.basename(key)) : destSpec);
132
- const rel = path.relative(consumerRoot, dest);
133
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
134
- problems.push(`destination for "${key}" escapes the consumer root: ${destSpec}`);
135
- continue;
136
- }
137
- if (rel === '' || (fs.existsSync(dest) && fs.statSync(dest).isDirectory())) {
182
+ const d = inside(key, destSpec.endsWith('/') ? path.join(destSpec, path.basename(key)) : destSpec, false);
183
+ if (!d) continue;
184
+ if (d.rel === '' || (fs.existsSync(d.abs) && fs.statSync(d.abs).isDirectory())) {
138
185
  problems.push(`destination for "${key}" is a directory: write "${destSpec.replace(/\/?$/, '/')}" to put the file inside it`);
139
186
  continue;
140
187
  }
141
- if (steps.some((s) => s.dest === dest)) {
142
- problems.push(`two outputs are mapped to the same destination: ${rel}`);
143
- continue;
188
+ steps.push({ key, src: path.join(pkgRoot, 'dist', key), dest: d.abs, rel: d.rel });
189
+ }
190
+
191
+ const exists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
192
+ // A directory output owns its destination.
193
+ const within = (child, parent) => child === parent || child.startsWith(parent + path.sep);
194
+ for (let i = 0; i < dirs.length; i++) {
195
+ for (let j = i + 1; j < dirs.length; j++) {
196
+ if (within(dirs[i].dest, dirs[j].dest) || within(dirs[j].dest, dirs[i].dest)) {
197
+ problems.push(`directory outputs "${dirs[i].key}" and "${dirs[j].key}" share or nest destinations (${dirs[i].rel}, ${dirs[j].rel}); a directory owns its destination`);
198
+ }
144
199
  }
145
- steps.push({ key, src: path.join(pkgRoot, 'dist', key), dest, rel });
200
+ }
201
+ for (const s of steps) {
202
+ if (s.dirKey) continue;
203
+ const owner = dirs.find((d) => within(s.dest, d.dest));
204
+ if (owner) problems.push(`"${s.key}" is mapped into ${s.rel}, which directory output "${owner.key}" owns`);
205
+ }
206
+ for (const s of steps) {
207
+ if (s.dirKey && exists(s.dest) && fs.statSync(s.dest).isDirectory()) {
208
+ problems.push(`"${s.key}" would be written to ${s.rel}, which is an existing directory`);
209
+ }
210
+ const blocked = steps.find((o) => o !== s && within(o.dest, s.dest));
211
+ if (blocked) problems.push(`"${s.key}" is mapped to ${s.rel}, which is a parent directory of ${blocked.rel}`);
212
+ }
213
+ const seen = new Set();
214
+ for (const s of steps) {
215
+ if (seen.has(s.dest)) problems.push(`two outputs are mapped to the same destination: ${s.rel}`);
216
+ seen.add(s.dest);
217
+ }
218
+
219
+ // Resolve symlinks: the nearest existing ancestor of every destination must stay inside the root.
220
+ const escapes = (dest) => {
221
+ let at = dest;
222
+ while (!exists(at)) at = path.dirname(at);
223
+ let real;
224
+ try { real = fs.realpathSync(at); } catch { return true; }
225
+ return !within(real, realRoot);
226
+ };
227
+ for (const target of [...dirs.map((d) => ({ key: d.key, dest: d.dest })), ...steps]) {
228
+ if (escapes(target.dest)) problems.push(`destination for "${target.key}" resolves outside the consumer root through a symlink`);
146
229
  }
147
230
  if (problems.length) throw new ConfigError(problems.join('\n '));
148
- return { steps, design, local };
231
+
232
+ // Files under a directory destination that the release does not list (symlinks are never followed).
233
+ const listed = new Set(steps.map((s) => s.dest));
234
+ const extras = [];
235
+ const walk = (dir) => {
236
+ let entries;
237
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
238
+ for (const e of entries) {
239
+ const p = path.join(dir, e.name);
240
+ if (e.isSymbolicLink()) extras.push(path.relative(consumerRoot, p));
241
+ else if (e.isDirectory()) walk(p);
242
+ else if (!listed.has(p)) extras.push(path.relative(consumerRoot, p));
243
+ }
244
+ };
245
+ for (const d of dirs) if (exists(d.dest)) walk(d.dest);
246
+ return { steps, design, local, extras };
149
247
  }
150
248
 
151
249
  function sync(args) {
152
- const { steps, design, local } = plan(args);
250
+ const { steps, design, local, extras } = plan(args);
153
251
  for (const { key, src, dest, rel } of steps) {
154
252
  let bytes = fs.readFileSync(src);
155
- if (local) bytes = Buffer.from(localize(bytes.toString('utf8'), key, design.package, local.version, local.sha));
253
+ if (local && isText(key)) bytes = Buffer.from(localize(bytes.toString('utf8'), key, design.package, local.version, local.sha));
156
254
  fs.mkdirSync(path.dirname(dest), { recursive: true });
157
- fs.writeFileSync(dest, bytes);
255
+ const tmp = `${dest}.dataimago-sync-tmp-${process.pid}`;
256
+ try {
257
+ fs.writeFileSync(tmp, bytes);
258
+ fs.renameSync(tmp, dest);
259
+ } catch (e) {
260
+ fs.rmSync(tmp, { force: true });
261
+ throw e;
262
+ }
158
263
  console.log(` ✓ ${rel} ← ${design.package}@${local ? `${LOCAL}(${local.sha})` : design.version} dist/${key}`);
159
264
  }
265
+ for (const rel of extras) console.warn(` ! ${rel}: not in ${design.package}@${local ? `${LOCAL}(${local.sha})` : design.version}; sync never deletes, remove it`);
160
266
  if (local) console.log(`\n⚠ local, unreleased design files: \`dataimago-sync --check\` fails until you re-sync from a release.`);
161
267
  return 0;
162
268
  }
163
269
 
164
270
  function check(args) {
165
- const { steps, design } = plan(args.filter((a) => a !== '--check'));
271
+ const { steps, design, extras } = plan(args.filter((a) => a !== '--check'));
166
272
  const failures = [];
167
- for (const { src, dest, rel } of steps) {
273
+ for (const { key, src, dest, rel } of steps) {
168
274
  if (!fs.existsSync(dest)) { failures.push(`${rel}: missing`); continue; }
169
275
  const have = fs.readFileSync(dest);
170
276
  // The marker lives in the stamp: line 1, or the leading _meta object of a JSON file.
171
- if (have.subarray(0, 1024).includes(LOCAL)) { failures.push(`${rel}: written by --from (local, unreleased); re-sync from a release`); continue; }
277
+ if (isText(key) && have.subarray(0, 1024).includes(LOCAL)) { failures.push(`${rel}: written by --from (local, unreleased); re-sync from a release`); continue; }
172
278
  if (!have.equals(fs.readFileSync(src))) failures.push(`${rel}: differs from ${design.package}@${design.version}`);
173
279
  }
280
+ for (const rel of extras) failures.push(`${rel}: not in ${design.package}@${design.version}; sync never deletes, remove it`);
174
281
  for (const f of failures) console.error(` ✗ ${f}`);
175
282
  if (failures.length) {
176
283
  console.error(`\n✗ dataimago-sync --check: ${failures.length} file(s) out of sync. Run: npx -p ${design.package}@${design.version} dataimago-sync`);
@@ -211,6 +318,7 @@ async function selfTest() {
211
318
  }
212
319
  const { stamp, stampJson } = await import(stampUrl);
213
320
  const assert = (await import('node:assert/strict')).default;
321
+ const { randomBytes } = await import('node:crypto');
214
322
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'di-sync-'));
215
323
 
216
324
  // A fake published package: <tmp>/pkg with dist/{quarto/a.scss, dataviz/p.json, outputs.json, bin/}.
@@ -218,6 +326,9 @@ async function selfTest() {
218
326
  const files = {
219
327
  'quarto/a.scss': stamp({ kind: 'scss', pkg: name, version, distPath: 'quarto/a.scss', body: '$x: 1 !default;\n' }),
220
328
  'dataviz/p.json': stampJson({ pkg: name, version, distPath: 'dataviz/p.json', data: { a: '#E69F00' } }),
329
+ 'fonts/latin/fonts.css': stamp({ kind: 'css', pkg: name, version, distPath: 'fonts/latin/fonts.css', body: '@font-face { font-family: "Noto Sans"; src: url(x.woff2) format("woff2"); }\n' }),
330
+ 'fonts/latin/x.woff2': Buffer.concat([Buffer.from([0xff, 0x00, 0xfe, 0x80]), randomBytes(28)]),
331
+ 'fonts/latin/OFL.txt': 'SIL Open Font License 1.1\n',
221
332
  };
222
333
  fs.mkdirSync(path.join(root, 'dist', 'bin'), { recursive: true });
223
334
  fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name, version }));
@@ -334,6 +445,157 @@ async function selfTest() {
334
445
  assert.equal(r.status, 2);
335
446
  assert.ok(!exists('same.txt'));
336
447
 
448
+ // TEXT_EXT must stay inside what build-outputs' kindOf stamps, and binaries must stay outside it.
449
+ const { kindOf } = await import(new URL('./build-outputs.mjs', import.meta.url));
450
+ // True iff: an extension is in TEXT_EXT exactly when kindOf stamps it (non-binary).
451
+ const { KIND_BY_EXT, BINARY_EXT } = await import(new URL('./build-outputs.mjs', import.meta.url));
452
+ const kindFor = (ext) => kindOf(ext === '.template' ? 'x.md.template' : `x${ext}`);
453
+ for (const ext of TEXT_EXT) {
454
+ if (ext === '.template') continue; // an alias for the inner extension, not a kind of its own
455
+ assert.ok(ext in KIND_BY_EXT, `TEXT_EXT ${ext} maps to a stamp kind`);
456
+ assert.notEqual(kindFor(ext), 'binary', `kindOf stamps ${ext}`);
457
+ }
458
+ assert.ok(TEXT_EXT.includes('.template'), '.template is a text extension');
459
+ for (const ext of Object.keys(KIND_BY_EXT)) assert.ok(TEXT_EXT.includes(ext), `${ext} is stamped by kindOf, so TEXT_EXT must list it`);
460
+ for (const ext of BINARY_EXT) {
461
+ assert.ok(!TEXT_EXT.includes(ext), `${ext} is binary for sync`);
462
+ assert.equal(kindFor(ext), 'binary', `kindOf calls ${ext} binary`);
463
+ }
464
+
465
+ // Directory-output helpers: a clean consumer with the given outputs.
466
+ const fontsOut = { 'fonts/latin/': 'assets/fonts/latin/' };
467
+ const fresh = (outputs) => {
468
+ fs.rmSync(consumer, { recursive: true, force: true });
469
+ fs.mkdirSync(consumer, { recursive: true });
470
+ writeDesign({ ...base, outputs });
471
+ };
472
+ const bytes = (rel) => fs.readFileSync(path.join(consumer, rel));
473
+ const distBytes = (root, key) => fs.readFileSync(path.join(root, 'dist', key));
474
+
475
+ // (l) a directory output copies every listed file, byte-identical.
476
+ fresh(fontsOut);
477
+ r = run();
478
+ assert.equal(r.status, 0, r.stderr);
479
+ for (const f of ['fonts.css', 'x.woff2', 'OFL.txt']) {
480
+ assert.deepEqual(bytes(`assets/fonts/latin/${f}`), distBytes(path.join(tmp, 'pkg'), `fonts/latin/${f}`), f);
481
+ }
482
+ assert.equal(run('--check').status, 0);
483
+ // A directory key nothing lives under is "not shipped" and lists directory keys too.
484
+ fresh({ 'fonts/greek/': 'assets/fonts/greek/' });
485
+ r = run();
486
+ assert.equal(r.status, 2);
487
+ assert.match(r.stderr, /fonts\/greek\//);
488
+ assert.match(r.stderr, /fonts\/latin\//);
489
+
490
+ // (m) a directory output's destination must end in /.
491
+ fresh({ 'fonts/latin/': 'assets/fonts' });
492
+ r = run();
493
+ assert.equal(r.status, 2);
494
+ assert.ok(!exists('assets'), 'nothing written');
495
+
496
+ // (n) a directory owns its destination.
497
+ fresh({ 'fonts/latin/': 'assets/fonts/latin/', 'dataviz/p.json': 'assets/fonts/latin/p.json' });
498
+ r = run();
499
+ assert.equal(r.status, 2);
500
+ assert.ok(!exists('assets'), 'nothing written when a file lands in a directory output');
501
+ fresh({ 'quarto/': 'assets/fonts/latin/', 'fonts/latin/': 'assets/fonts/' });
502
+ r = run();
503
+ assert.equal(r.status, 2);
504
+ assert.ok(!exists('assets'), 'nothing written when directory outputs nest');
505
+
506
+ // (n2) a file output at a directory destination's ancestor, and a member landing on a directory.
507
+ fresh({ 'fonts/latin/x.woff2': 'assets/f', 'fonts/latin/': 'assets/f/latin/' });
508
+ r = run();
509
+ assert.equal(r.status, 2, r.stderr);
510
+ assert.ok(!exists('assets'), 'nothing written when a file is an ancestor of a directory destination');
511
+ fresh(fontsOut);
512
+ fs.mkdirSync(path.join(consumer, 'assets/fonts/latin/x.woff2'), { recursive: true });
513
+ r = run();
514
+ assert.equal(r.status, 2, r.stderr);
515
+ assert.ok(!exists('assets/fonts/latin/fonts.css'), 'nothing written when a member lands on a directory');
516
+
517
+ // (o) a stale file is named and kept: sync warns, --check fails.
518
+ fresh(fontsOut);
519
+ assert.equal(run().status, 0);
520
+ fs.writeFileSync(path.join(consumer, 'assets/fonts/latin/old.woff2'), 'stale');
521
+ r = run();
522
+ assert.equal(r.status, 0, r.stderr);
523
+ assert.match(r.stdout + r.stderr, /old\.woff2/);
524
+ assert.ok(exists('assets/fonts/latin/old.woff2'), 'sync never deletes');
525
+ r = run('--check');
526
+ assert.equal(r.status, 1);
527
+ assert.match(r.stderr, /old\.woff2/);
528
+ assert.match(r.stderr, /sync never deletes/);
529
+ // A symlink inside the destination is an extra too, and is left alone.
530
+ fs.rmSync(path.join(consumer, 'assets/fonts/latin/old.woff2'));
531
+ fs.symlinkSync(tmp, path.join(consumer, 'assets/fonts/latin/link'));
532
+ r = run('--check');
533
+ assert.equal(r.status, 1);
534
+ assert.match(r.stderr, /link/);
535
+ assert.equal(run().status, 0);
536
+ assert.ok(fs.lstatSync(path.join(consumer, 'assets/fonts/latin/link')).isSymbolicLink());
537
+
538
+ // (p) a byte edit and a deletion both fail --check.
539
+ fresh(fontsOut);
540
+ assert.equal(run().status, 0);
541
+ const woff = path.join(consumer, 'assets/fonts/latin/x.woff2');
542
+ const edited = fs.readFileSync(woff);
543
+ edited[5] ^= 0x01;
544
+ fs.writeFileSync(woff, edited);
545
+ r = run('--check');
546
+ assert.equal(r.status, 1);
547
+ assert.match(r.stderr, /x\.woff2/);
548
+ assert.equal(run().status, 0);
549
+ fs.rmSync(woff);
550
+ r = run('--check');
551
+ assert.equal(r.status, 1);
552
+ assert.match(r.stderr, /x\.woff2/);
553
+
554
+ // (q) --from leaves binaries byte-identical and localizes only stamped text.
555
+ fresh(fontsOut);
556
+ r = run('--from', path.join(tmp, 'checkout'));
557
+ assert.equal(r.status, 0, r.stderr);
558
+ const co = path.join(tmp, 'checkout', 'packages', 'css');
559
+ assert.deepEqual(bytes('assets/fonts/latin/x.woff2'), distBytes(co, 'fonts/latin/x.woff2'));
560
+ assert.deepEqual(bytes('assets/fonts/latin/OFL.txt'), distBytes(co, 'fonts/latin/OFL.txt'));
561
+ assert.match(bytes('assets/fonts/latin/fonts.css').toString('utf8').split('\n')[0], new RegExp(LOCAL));
562
+ assert.equal(run('--check').status, 1);
563
+
564
+ // (r) a symlink that resolves outside the consumer root is refused, for directory and file outputs.
565
+ const outside = path.join(tmp, 'outside');
566
+ fs.mkdirSync(outside, { recursive: true });
567
+ fresh(fontsOut);
568
+ fs.symlinkSync(outside, path.join(consumer, 'assets'));
569
+ r = run();
570
+ assert.equal(r.status, 2);
571
+ assert.deepEqual(fs.readdirSync(outside), [], 'nothing written outside the root');
572
+ fresh({ 'dataviz/p.json': 'assets/p.json' });
573
+ fs.symlinkSync(outside, path.join(consumer, 'assets'));
574
+ r = run();
575
+ assert.equal(r.status, 2);
576
+ assert.deepEqual(fs.readdirSync(outside), [], 'nothing written outside the root via a file output');
577
+
578
+ // (r2) a symlink under a directory output's destination that leaves the root is refused, writing nothing.
579
+ fresh({ 'fonts/': 'assets/fonts/' });
580
+ fs.mkdirSync(path.join(consumer, 'assets/fonts'), { recursive: true });
581
+ fs.symlinkSync(outside, path.join(consumer, 'assets/fonts/latin'));
582
+ r = run();
583
+ assert.equal(r.status, 2, r.stderr);
584
+ assert.deepEqual(fs.readdirSync(outside), [], 'nothing written outside the root via a member');
585
+ assert.ok(!exists('assets/fonts/dataviz'), 'nothing written at all');
586
+
587
+ // (s) writes are atomic per file: no temp file survives a sync.
588
+ fresh(fontsOut);
589
+ assert.equal(run().status, 0);
590
+ const leftovers = [];
591
+ (function walk(d) {
592
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
593
+ if (e.isDirectory()) walk(path.join(d, e.name));
594
+ else if (e.name.includes('.dataimago-sync-tmp')) leftovers.push(e.name);
595
+ }
596
+ })(consumer);
597
+ assert.deepEqual(leftovers, []);
598
+
337
599
  fs.rmSync(tmp, { recursive: true, force: true });
338
600
  console.log('dataimago-sync self-test: all checks passed');
339
601
  }
@@ -1,4 +1,4 @@
1
- /* @dataimago/css-neutral@0.1.0-alpha.12 dist/dataviz/dataimago-dataviz.css · sha256:4fb36e2401c1cd21f57d50c017ac080377c3943362a5b6c5ce2cd658080e4499 · generated from tokens; do not edit · restore: npx -p @dataimago/css-neutral@0.1.0-alpha.12 dataimago-sync */
1
+ /* @dataimago/css-neutral@0.1.0-alpha.13 dist/dataviz/dataimago-dataviz.css · sha256:4fb36e2401c1cd21f57d50c017ac080377c3943362a5b6c5ce2cd658080e4499 · generated from tokens; do not edit · restore: npx -p @dataimago/css-neutral@0.1.0-alpha.13 dataimago-sync */
2
2
  /* dataimago data-viz palette (Okabe-Ito). Categorical colour is never the only channel. */
3
3
  :root, [data-bs-theme="light"], body.quarto-light {
4
4
  --color-dataviz-okabe-ito-orange: #E69F00;
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "_meta": {
3
3
  "package": "@dataimago/css-neutral",
4
- "version": "0.1.0-alpha.12",
4
+ "version": "0.1.0-alpha.13",
5
5
  "path": "dist/dataviz/dataimago-dataviz.json",
6
6
  "sha256": "28160e151fca196be3e36790d6b67db232d8df060e8d9556b29dfee50f4b278b",
7
7
  "note": "generated from tokens; do not edit",
8
- "restore": "npx -p @dataimago/css-neutral@0.1.0-alpha.12 dataimago-sync"
8
+ "restore": "npx -p @dataimago/css-neutral@0.1.0-alpha.13 dataimago-sync"
9
9
  },
10
10
  "okabe-ito": {
11
11
  "orange": "#E69F00",
@@ -1,4 +1,4 @@
1
- % @dataimago/css-neutral@0.1.0-alpha.12 dist/dataviz/dataimago-dataviz.tex · sha256:d7be36cb429ce8a1f19c95a7da8f57a7fcfda85fd2b0113e086913d37e9fa645 · generated from tokens; do not edit · restore: npx -p @dataimago/css-neutral@0.1.0-alpha.12 dataimago-sync
1
+ % @dataimago/css-neutral@0.1.0-alpha.13 dist/dataviz/dataimago-dataviz.tex · sha256:d7be36cb429ce8a1f19c95a7da8f57a7fcfda85fd2b0113e086913d37e9fa645 · generated from tokens; do not edit · restore: npx -p @dataimago/css-neutral@0.1.0-alpha.13 dataimago-sync
2
2
  % dataimago data-viz palette (Okabe-Ito), light mode, for PSTricks and TikZ. Requires xcolor.
3
3
  \definecolor{di-dataviz-okabe-ito-orange}{HTML}{E69F00}
4
4
  \definecolor{di-dataviz-okabe-ito-sky-blue}{HTML}{56B4E9}
@@ -1,4 +1,4 @@
1
- # @dataimago/css-neutral@0.1.0-alpha.12 dist/dataviz/dataimago_dataviz.R · sha256:6443982f105b0f5e4a9d90ac2d1f6ce9fce2db4e8e8148b47f71bfc438940055 · generated from tokens; do not edit · restore: npx -p @dataimago/css-neutral@0.1.0-alpha.12 dataimago-sync
1
+ # @dataimago/css-neutral@0.1.0-alpha.13 dist/dataviz/dataimago_dataviz.R · sha256:6443982f105b0f5e4a9d90ac2d1f6ce9fce2db4e8e8148b47f71bfc438940055 · generated from tokens; do not edit · restore: npx -p @dataimago/css-neutral@0.1.0-alpha.13 dataimago-sync
2
2
  # dataimago data-viz palette (Okabe-Ito). Categorical colour is never the only channel:
3
3
  # pair it with marker shape, line type or a label.
4
4
 
@@ -0,0 +1,93 @@
1
+ Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.