diff --git a/README.md b/README.md index b14f027..700328d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ Built by the team behind [SuperDoc](https://github.com/superdoc-dev/superdoc). S - `packages/fallbacks` - runtime fallback decisions and lookup helpers. +## Source backlog + +`packages/fallbacks/sources.json` tracks open-font projects we may measure next. It is public +provenance only, not a recommendation and not shipped in the npm package. The first entries cover TeX +Gyre, the maintained open extension of the URW++ Base 35 set: Termes, Heros, Bonum, Schola, Pagella, +Adventor, Cursor, and Chorus. A candidate only becomes fallback data after measurement and review. + ## API - `getRenderableFallback` - returns the open family to render, or `null` when none is renderable. diff --git a/packages/fallbacks/pack.test.ts b/packages/fallbacks/pack.test.ts index 3f521e0..ee98e29 100644 --- a/packages/fallbacks/pack.test.ts +++ b/packages/fallbacks/pack.test.ts @@ -52,6 +52,7 @@ describe("publish tarball hygiene", () => { /data\/measurements/, /data\/corpus/, /records\.json/, + /sources\.json/, /tsconfig/, ]; for (const f of files) diff --git a/packages/fallbacks/sources.json b/packages/fallbacks/sources.json new file mode 100644 index 0000000..6d4b331 --- /dev/null +++ b/packages/fallbacks/sources.json @@ -0,0 +1,82 @@ +[ + { + "sourceId": "tex-gyre-termes", + "family": "TeX Gyre Termes", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/termes", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. No account required.", + "targetFamilies": ["Times New Roman", "Times"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-heros", + "family": "TeX Gyre Heros", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/heros", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. No account required.", + "targetFamilies": ["Arial", "Helvetica"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-bonum", + "family": "TeX Gyre Bonum", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/bonum", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. No account required.", + "targetFamilies": ["Bookman Old Style", "ITC Bookman"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-schola", + "family": "TeX Gyre Schola", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/schola", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. No account required.", + "targetFamilies": ["Century Schoolbook", "New Century Schoolbook"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-pagella", + "family": "TeX Gyre Pagella", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/pagella", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. No account required.", + "targetFamilies": ["Palatino Linotype", "Book Antiqua"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-adventor", + "family": "TeX Gyre Adventor", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/adventor", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. No account required.", + "targetFamilies": ["Century Gothic", "ITC Avant Garde Gothic"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-cursor", + "family": "TeX Gyre Cursor", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/cursor", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. Monospaced. No account required.", + "targetFamilies": ["Courier New", "Courier"], + "status": "backlog" + }, + { + "sourceId": "tex-gyre-chorus", + "family": "TeX Gyre Chorus", + "project": "TeX Gyre", + "licenseFamily": "GUST-FL", + "upstream": "https://www.gust.org.pl/projects/e-foundry/tex-gyre/chorus", + "retrieval": "OTF release from the GUST e-foundry page; extends the URW++ Base 35 core set. Chancery/script. No account required.", + "targetFamilies": ["Monotype Corsiva", "ITC Zapf Chancery"], + "status": "backlog" + } +] diff --git a/packages/fallbacks/sources.test.ts b/packages/fallbacks/sources.test.ts new file mode 100644 index 0000000..02c4d4d --- /dev/null +++ b/packages/fallbacks/sources.test.ts @@ -0,0 +1,152 @@ +/** + * Guards for the source backlog. It records open-font candidates, not fallback recommendations. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + LICENSE_FAMILIES, + loadSources, + SOURCES_PATH, + type SourceCandidate, +} from "./sources"; + +const joined = (...parts: string[]) => parts.join(""); + +const ALLOWED_KEYS = new Set([ + "sourceId", + "family", + "project", + "licenseFamily", + "upstream", + "retrieval", + "targetFamilies", + "status", +]); + +// Fields that mark a row as a measured verdict. The backlog must never carry them: a font earns these +// only after a bakeoff, by moving to records.json. +const VERDICT_FIELDS = [ + "verdict", + "faceVerdicts", + "physicalFamily", + "policyAction", + "measurementRefs", + "gates", + "advance", + "exportRule", + "candidateLicense", + "faces", + "evidenceId", + "logicalFamily", + "lineBreakSafe", +]; + +const STATUSES = new Set(["backlog", "evaluating"]); +const sources = loadSources(); + +describe("source backlog schema", () => { + test("the backlog is a non-empty array", () => { + expect(Array.isArray(sources)).toBe(true); + expect(sources.length).toBeGreaterThan(0); + }); + + test("every entry has exactly the public-safe fields, well-formed", () => { + for (const s of sources) { + const keys = Object.keys(s); + for (const k of keys) + expect( + ALLOWED_KEYS.has(k as keyof SourceCandidate), + `${s.sourceId}: unexpected field "${k}"`, + ).toBe(true); + for (const k of ALLOWED_KEYS) + expect(keys.includes(k), `${s.sourceId}: missing field "${k}"`).toBe( + true, + ); + + expect( + /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s.sourceId), + `bad sourceId: ${s.sourceId}`, + ).toBe(true); + expect(s.family.length, `${s.sourceId}: empty family`).toBeGreaterThan(0); + expect(s.project.length, `${s.sourceId}: empty project`).toBeGreaterThan( + 0, + ); + expect( + s.retrieval.length, + `${s.sourceId}: empty retrieval`, + ).toBeGreaterThan(0); + expect( + LICENSE_FAMILIES.includes(s.licenseFamily), + `${s.sourceId}: license ${s.licenseFamily}`, + ).toBe(true); + expect( + s.upstream.startsWith("https://"), + `${s.sourceId}: upstream not https`, + ).toBe(true); + expect(STATUSES.has(s.status), `${s.sourceId}: status ${s.status}`).toBe( + true, + ); + + expect( + Array.isArray(s.targetFamilies), + `${s.sourceId}: targetFamilies`, + ).toBe(true); + expect( + s.targetFamilies.length, + `${s.sourceId}: no targetFamilies`, + ).toBeGreaterThan(0); + for (const c of s.targetFamilies) + expect( + typeof c === "string" && c.length > 0, + `${s.sourceId}: bad cluster`, + ).toBe(true); + } + }); + + test("sourceIds are unique", () => { + const ids = sources.map((s) => s.sourceId); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("source backlog non-promotion", () => { + test("no entry carries a verdict-shaped field (a backlog is not a recommendation)", () => { + for (const s of sources) + for (const field of VERDICT_FIELDS) + expect( + field in (s as unknown as Record), + `${s.sourceId}: backlog entry must not carry verdict field "${field}"`, + ).toBe(false); + }); + + test("every entry is in a pre-verdict status", () => { + for (const s of sources) expect(STATUSES.has(s.status)).toBe(true); + }); +}); + +describe("source backlog hygiene", () => { + const raw = readFileSync(SOURCES_PATH, "utf8"); + + test("the backlog text carries no private paths, binaries, or measurement internals", () => { + const FORBIDDEN = [ + joined("/", "Users", "/"), + joined("/", "Applications", "/"), + joined("/", "System", "/"), + joined("C", ":", "\\"), + joined("D", ":", "\\"), + joined("D", "Fonts"), + joined("Microsoft ", "Word"), + "macOS", + joined("or", "acle"), + ".ttf", + ".otf", + ".woff", + ".pfb", + ".pfa", + ]; + for (const needle of FORBIDDEN) + expect(raw.includes(needle), `backlog text contains "${needle}"`).toBe( + false, + ); + }); +}); diff --git a/packages/fallbacks/sources.ts b/packages/fallbacks/sources.ts new file mode 100644 index 0000000..6cb83c6 --- /dev/null +++ b/packages/fallbacks/sources.ts @@ -0,0 +1,51 @@ +/** + * Public source backlog for open-font candidates awaiting measurement. + * These rows are not fallback decisions and are not published in @docfonts/fallbacks. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const SOURCES_PATH = join(import.meta.dir, "sources.json"); + +/** + * Pre-verdict lifecycle of a candidate source. Both values are explicitly NOT a recommendation: + * `backlog` is recorded but untouched; `evaluating` is mid-bakeoff. A promoted candidate leaves this + * file entirely and becomes a measured row in `records.json`. + */ +export type SourceStatus = "backlog" | "evaluating"; + +/** License families we accept for an open-font candidate. SPDX ids, plus the GUST Font License (GFL). */ +export const LICENSE_FAMILIES = [ + "OFL-1.1", + "Apache-2.0", + "GUST-FL", + "AGPL-3.0", +] as const; +export type LicenseFamily = (typeof LICENSE_FAMILIES)[number]; + +/** + * One open-font family recorded as a substitute candidate awaiting measurement. + */ +export interface SourceCandidate { + /** stable backlog id, e.g. "tex-gyre-bonum". */ + sourceId: string; + /** the open-font family name as released, e.g. "TeX Gyre Bonum". */ + family: string; + /** the upstream project the family belongs to, e.g. "TeX Gyre". */ + project: string; + /** license family of the candidate (see {@link LICENSE_FAMILIES}). */ + licenseFamily: LicenseFamily; + /** public upstream URL the font is retrieved from (https). */ + upstream: string; + /** public-safe note on how to obtain the font. No local paths, no binaries. */ + retrieval: string; + /** document font families this candidate might be measured against. Targets, not verdicts. */ + targetFamilies: string[]; + /** pre-verdict lifecycle state; never a recommendation. */ + status: SourceStatus; +} + +/** Load the reviewed source backlog from sources.json. */ +export function loadSources(): SourceCandidate[] { + return JSON.parse(readFileSync(SOURCES_PATH, "utf8")) as SourceCandidate[]; +}