Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Diagrams, specs and exports are written where you or your agent choose. Settings
## Your controls

- Delete generated files, settings and profiles at any time; they are ordinary files.
- Set `fonts` to `system` (`config set fonts system`, or `"fonts": "system"` in a spec) and generated pages no longer link to Google Fonts; SVG export then makes no font request either. Fonts from a brand profile are separate and still load from where the profile points.
- Open a diagram offline and it falls back to system fonts. Export SVG without network access and the fonts aren't embedded; the SVG still works.
- Export GIF and MP4 from the command line instead of the Export menu to avoid the jsDelivr request.
- Uninstall the skill or plugin from your agent at any time.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ Issues, pull requests and new diagram types are welcome. Start with the [contrib

## Privacy

SeeCode has no server, accounts or telemetry. Generated diagrams load their fonts from Google Fonts, and the in-page GIF/MP4 export loads its encoders from jsDelivr. [PRIVACY.md](PRIVACY.md) lists every request.
SeeCode has no server, accounts or telemetry. Generated diagrams load their fonts from Google Fonts (turn that off with `config set fonts system`), and the in-page GIF/MP4 export loads its encoders from jsDelivr. [PRIVACY.md](PRIVACY.md) lists every request.

## Acknowledgements

Expand Down
1 change: 1 addition & 0 deletions skills/seecode/references/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| `duration` | `auto` | GIF/MP4 length: `auto` (reveal plus one token loop) or a number of seconds |
| `outputDir` | `diagrams` | Where specs and HTML are written |
| `profile` | `null` | A brand profile slug from `~/.seecode/profiles/<slug>.json` |
| `fonts` | `google` | `google` loads Fraunces and IBM Plex from Google Fonts; `system` leaves that request out and uses system fonts (for offline or restricted networks) |
| `watermark` | `true` | SeeCode logo in each diagram's bottom-right corner; `false` removes it (only when the user asks) |

**Commands**
Expand Down
2 changes: 2 additions & 0 deletions skills/seecode/references/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@
- Arrays of objects with `id` patch by id: `{"nodes":{"api":{"row":2},"old":null,"new":{"label":"New","row":0,"col":4}}}`.
- Tuple arrays add or remove items: `{"edges":{"add":[["a","b","label"]],"remove":["c>d"]}}`. `remove` also accepts indexes.

**Fonts:** `"fonts": "system"` leaves the Google Fonts stylesheet out of the page, so it makes no font request and shows system fonts. Use it when the user asks for a diagram without external requests; the `fonts` setting does the same for every diagram.

**Watermark:** every diagram carries the SeeCode logo in its bottom-right corner, in all formats. The lettering takes the diagram's text colour, so it stays visible on any background. Keep it unless the user asks to remove it; then set `"watermark": false` in the spec, or `SC config set watermark false` for all diagrams.
7 changes: 7 additions & 0 deletions skills/seecode/schemas/common.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@
"type": "boolean",
"description": "SeeCode logo in the bottom-right corner (default true). Set false only when the user asks to remove it."
},
"fonts": {
"enum": [
"google",
"system"
],
"description": "Where the typefaces come from (default google). system leaves the Google Fonts stylesheet out and uses the system fallbacks."
},
"lang": {
"type": "string",
"pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$",
Expand Down
4 changes: 4 additions & 0 deletions skills/seecode/scripts/lib/config/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ export const DEFAULTS = {
outputDir: 'diagrams',
profile: null,
watermark: true,
fonts: 'google',
};

const FONTS = ['google', 'system'];

const KNOWN = new Set(Object.keys(DEFAULTS).concat(['brand']));

export function globalDir() {
Expand Down Expand Up @@ -147,6 +150,7 @@ function coerce(v) {

export function set(cwd, key, value, { global = false } = {}) {
if (!KNOWN.has(key)) throw new Error(`unknown setting "${key}" (known: ${[...KNOWN].join(', ')})`);
if (key === 'fonts' && !FONTS.includes(value)) throw new Error(`fonts must be one of: ${FONTS.join(', ')}`);
const p = paths(cwd);
const file = global ? p.global : p.project;
let cur = readJson(file) || (global ? { ...DEFAULTS } : {});
Expand Down
3 changes: 2 additions & 1 deletion skills/seecode/scripts/lib/export/fonts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export async function embedFonts(svg) {
faces.push(block.replace(/src:[^;]+;/, `src:url(${await toData(src)}) format('woff2');`));
}
}
if (!faces.length) return { ok: false, svg, error: 'no font faces returned' };
// nothing to fetch when the page was built with fonts: 'system'
if (!faces.length && googleFamilies(svg).length) return { ok: false, svg, error: 'no font faces returned' };
let out = svg.replace(/@import url\('https:\/\/fonts\.googleapis\.com\/[^']*'\);/g, '');
out = out.replace(/(<style>)/, `$1${faces.join('')}`);
// a brand's self-hosted faces: inline each file so the SVG works offline
Expand Down
8 changes: 4 additions & 4 deletions skills/seecode/scripts/lib/folder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { deflateRawSync } from 'node:zlib';
import { renderSpec, outPathFor, writeAtomic, summarize } from './render.mjs';
import { FONTS_HREF, skinCss } from './tokens.mjs';
import { FONTS_HREF, webFontsOn, skinCss } from './tokens.mjs';
import * as config from './config/config.mjs';

export const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
Expand Down Expand Up @@ -41,7 +41,7 @@ export function renderFolder(dir, { motion } = {}) {
});
const done = diagrams.filter((d) => d.ok);
const index = join(dir, 'index.html');
writeAtomic(index, indexHtml(basename(dir), done.map((d) => ({ href: basename(d.out), title: d.title, type: d.type }))));
writeAtomic(index, indexHtml(basename(dir), done.map((d) => ({ href: basename(d.out), title: d.title, type: d.type })), settings));
return {
ok: done.length === diagrams.length,
folder: dir,
Expand All @@ -53,10 +53,10 @@ export function renderFolder(dir, { motion } = {}) {
};
}

export function indexHtml(name, cards) {
export function indexHtml(name, cards, settings = {}) {
const title = name.replace(/[-_]+/g, ' ');
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>
<link rel="stylesheet" href="${FONTS_HREF.replace(/&/g, '&amp;')}"><style>${skinCss()}
${webFontsOn({}, settings) ? `<link rel="stylesheet" href="${FONTS_HREF.replace(/&/g, '&amp;')}">` : ''}<style>${skinCss()}
*{box-sizing:border-box;margin:0}body{background:var(--sc-paper);color:var(--sc-ink);font-family:var(--sc-font-sans);padding:48px 24px}
main{max-width:1240px;margin:0 auto}h1{font-family:var(--sc-font-serif);font-weight:400;font-size:36px}p.lead{color:var(--sc-muted);margin:8px 0 24px}
ol{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;counter-reset:d}
Expand Down
7 changes: 4 additions & 3 deletions skills/seecode/scripts/lib/page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { el, esc } from './svg.mjs';
import { FONTS_HREF, DIAGRAM_CSS, SKINS, ON_FILL, skinCss } from './tokens.mjs';
import { FONTS_HREF, webFontsOn, DIAGRAM_CSS, SKINS, ON_FILL, skinCss } from './tokens.mjs';
import { legendLabels } from './render/shared/legend.mjs';
import { scopeCss, shortHash } from './scope.mjs';
import { cleanFonts } from './config/config.mjs';
Expand All @@ -27,6 +27,7 @@ export const isRtl = (lang) => RTL.has(String(lang || '').split('-')[0].toLowerC
export function buildPage({ spec, result, preset, typeName, settings = {} }) {
const sketchy = (spec.style || settings.style) === 'sketchy';
const mark = watermarkOn(spec, settings);
const webFonts = webFontsOn(spec, settings);
const [vx, vy, vw, vh0] = result.viewBox.map((v) => Math.round(v));
// the watermark gets its own strip under the diagram, so it never covers content
const vh = mark ? vh0 + MARK.strip : vh0;
Expand Down Expand Up @@ -95,8 +96,8 @@ export function buildPage({ spec, result, preset, typeName, settings = {} }) {
<meta name="generator" content="SeeCode">
<link rel="icon" type="image/png" href="${FAVICON()}">
<title>${esc(title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link id="sc-fonts" rel="stylesheet" href="${esc(FONTS_HREF)}${sketchy ? '&amp;family=Kalam:wght@400;700' : ''}">${(brandFonts.href || []).map((h) => `
${webFonts ? `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link id="sc-fonts" rel="stylesheet" href="${esc(FONTS_HREF)}${sketchy ? '&amp;family=Kalam:wght@400;700' : ''}">` : ''}${(brandFonts.href || []).map((h) => `
<link class="sc-brand-fonts" rel="stylesheet" href="${esc(h)}">`).join('')}${brandFonts.faces ? `
<style id="sc-brand-faces">${brandFonts.faces}</style>` : ''}
<style>${skinCss({ skin: spec.skin || settings.skin || 'light', brand })}
Expand Down
6 changes: 6 additions & 0 deletions skills/seecode/scripts/lib/tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { OBJECT_CSS } from './render/structure/object.mjs';
export const FONTS_HREF =
'https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;1,9..144,400&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap';

// fonts: 'system' leaves the Google Fonts stylesheet out, so a page makes no
// font request and the system fallbacks in each font stack apply.
export function webFontsOn(spec = {}, settings = {}) {
return (spec.fonts !== undefined ? spec.fonts : settings.fonts) !== 'system';
}

export const SKINS = {
light: {
paper: '#f6f5f1', 'paper-2': '#eceae4', ink: '#1d2433', 'ink-strong': '#0b0f19',
Expand Down
3 changes: 2 additions & 1 deletion skills/seecode/scripts/lib/viewer/viewer.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,8 @@
const vars = names.map((n) => `${n}:${cs.getPropertyValue(n).trim()}`).join(';');
const css = document.getElementById('sc-diagram-css').textContent;
const style = document.createElementNS(NS, 'style');
const fontRule = fonts || `@import url('${document.getElementById('sc-fonts').href}');`;
const fontsLink = document.getElementById('sc-fonts');
const fontRule = fonts || (fontsLink ? `@import url('${fontsLink.href}');` : '');
// scoped to the diagram's id so several exports can share one page
const rules = `:root{${vars}}${css}`;
style.textContent = `${fontRule}${clone.id && typeof __scScopeCss === 'function' ? __scScopeCss(rules, clone.id) : rules}`;
Expand Down
59 changes: 59 additions & 0 deletions test/fonts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// The fonts setting: Google Fonts by default; 'system' leaves the stylesheet
// out of the page, the folder index and the SVG export.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { renderSpec } from '../skills/seecode/scripts/lib/render.mjs';
import { svgFromHtml, exportSvg } from '../skills/seecode/scripts/lib/export/svg.mjs';
import { indexHtml } from '../skills/seecode/scripts/lib/folder.mjs';
import * as config from '../skills/seecode/scripts/lib/config/config.mjs';

const spec = { type: 'architecture', title: 'Fonts', nodes: [{ id: 'a', label: 'A', row: 0, col: 0 }, { id: 'b', label: 'B', row: 0, col: 1 }], edges: [['a', 'b']] };
// every src/href attribute that points at another host
const external = (html) => [...html.matchAll(/\s(?:src|href)="(https?:\/\/[^"]+)"/g)].map((m) => m[1]);

test('Google Fonts are linked by default', () => {
const html = renderSpec(spec).html;
assert.match(html, /<link id="sc-fonts" rel="stylesheet" href="https:\/\/fonts\.googleapis\.com\//);
assert.match(svgFromHtml(html), /@import url\('https:\/\/fonts\.googleapis\.com\//);
});

test("fonts: 'system' leaves every external reference out, by spec or setting", () => {
for (const html of [renderSpec({ ...spec, fonts: 'system' }).html, renderSpec(spec, { settings: { fonts: 'system' } }).html]) {
assert.deepEqual(external(html), []);
assert.doesNotMatch(html, /id="sc-fonts"|rel="preconnect"/);
assert.match(html, /--sc-font-sans:/, 'the font stacks, and so the system fallbacks, stay');
}
// a spec can ask for web fonts even when the setting turned them off
assert.match(renderSpec({ ...spec, fonts: 'google' }, { settings: { fonts: 'system' } }).html, /id="sc-fonts"/);
});

test('the sketchy style follows the setting too', () => {
assert.deepEqual(external(renderSpec({ ...spec, style: 'sketchy', fonts: 'system' }).html), []);
});

test('SVG export makes no font request and gives no warning', async (t) => {
const html = renderSpec({ ...spec, fonts: 'system' }).html;
assert.doesNotMatch(svgFromHtml(html), /@import/);
t.mock.method(globalThis, 'fetch', () => { throw new Error('no request expected'); });
const out = await exportSvg(html);
assert.equal(out.warning, null);
assert.doesNotMatch(out.svg, /fonts\.googleapis\.com/);
});

test('the folder index follows the setting', () => {
const cards = [{ href: 'a.html', title: 'A', type: 'architecture' }];
assert.match(indexHtml('set', cards), /fonts\.googleapis\.com/);
assert.deepEqual(external(indexHtml('set', cards, { fonts: 'system' })).filter((u) => /^https?:/.test(u)), []);
});

test('config accepts google and system only', () => {
process.env.SEECODE_HOME = mkdtempSync(join(tmpdir(), 'sc-home-'));
const proj = mkdtempSync(join(tmpdir(), 'sc-proj-'));
config.init(proj, 'project');
assert.equal(config.status(proj).settings.fonts, 'google');
assert.equal(config.set(proj, 'fonts', 'system').settings.fonts, 'system');
assert.throws(() => config.set(proj, 'fonts', 'none'), /fonts must be one of/);
});