From 92012b3546b2504bb843921f2868346b15048fd8 Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:58:51 -0700 Subject: [PATCH] Init update-signal-symbols script --- package.json | 3 +- scripts/update-signal-symbols.mjs | 323 ++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 scripts/update-signal-symbols.mjs diff --git a/package.json b/package.json index d0a49ff27..46a36b9f2 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,8 @@ "electron:install-app-deps": "electron-builder install-app-deps", "check-upgradeable-deps": "node scripts/check-upgradeable-deps.mjs", "react-devtools": "react-devtools", - "run-with-devtools": "cross-env REACT_DEVTOOLS=1 run-p --print-label react-devtools start" + "run-with-devtools": "cross-env REACT_DEVTOOLS=1 run-p --print-label react-devtools start", + "update-signal-symbols": "node scripts/update-signal-symbols.mjs" }, "optionalDependencies": { "fs-xattr": "0.4.0" diff --git a/scripts/update-signal-symbols.mjs b/scripts/update-signal-symbols.mjs new file mode 100644 index 000000000..fda36b209 --- /dev/null +++ b/scripts/update-signal-symbols.mjs @@ -0,0 +1,323 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { readFile, copyFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { assert } from './utils/assert.mjs'; + +const DESIGN_ASSETS_PATH = process.env.DESIGN_ASSETS_PATH; +assert(DESIGN_ASSETS_PATH != null, 'Missing DESIGN_ASSETS_PATH'); + +const DESKTOP_ROOT_DIR = join(import.meta.dirname, '..'); +const DESIGN_ROOT_DIR = join(DESKTOP_ROOT_DIR, DESIGN_ASSETS_PATH); + +const DESIGN_SYMBOLS_FONT_PATH = join( + DESIGN_ROOT_DIR, + 'Signal Symbols', + 'Font', + 'SignalSymbolsVariable.woff2' +); + +const DESKTOP_SYMBOLS_FONT_PATH = join( + DESKTOP_ROOT_DIR, + 'fonts', + 'signal-symbols', + 'SignalSymbolsVariable.woff2' +); + +const DESIGN_SYMBOLS_JSON_PATH = join( + DESIGN_ROOT_DIR, + 'Signal Symbols', + 'Font', + 'symbols.json' +); + +const DESKTOP_SYMBOLS_DEFS_PATH = join( + DESKTOP_ROOT_DIR, + 'ts', + 'axo', + '_internal', + 'AxoSymbolDefs.generated.std.ts' +); + +/** + * @typedef {Readonly<{ name: string; unicode: string }>} Glyph + * @typedef {ReadonlyArray} Glyphs + * @typedef {string | { ltr: string; rtl: string }} AxoSymbolBidi + * @typedef {{ icon: AxoSymbolBidi | null; inline: AxoSymbolBidi | null }} AxoSymbolValue + * @typedef {Readonly<{ name: string; value: AxoSymbolValue }>} AxoSymbol + */ + +async function copyVariableFontFile() { + console.log('Copying variable font file...'); + await copyFile(DESIGN_SYMBOLS_FONT_PATH, DESKTOP_SYMBOLS_FONT_PATH); +} + +async function generateAxoSymbolDefs() { + console.log('Generating axo symbol defs...'); + + const text = await readFile(DESIGN_SYMBOLS_JSON_PATH, 'utf-8'); + + /** @type {Glyphs} */ + const glyphs = JSON.parse(text); + /** @type {Map} */ + const glyphsByName = new Map(); + for (const glyph of glyphs) { + glyphsByName.set(glyph.name, glyph); + } + + /** + * @param {string} name + * @returns {boolean} + */ + function isLeftOnly(name) { + return name.includes('left') && !name.includes('right'); + } + + /** + * @param {string} name + * @returns {boolean} + */ + function isRightOnly(name) { + return name.includes('right') && !name.includes('left'); + } + + /** @type {Array} */ + const symbols = []; + + /** + * @param {string} name + * @returns {string} + */ + function stripInline(name) { + return name.replace(/-inline$/, ''); + } + + /** + * @param {Glyph} glyph + * @returns {string} + */ + function getSymbolName(glyph) { + const base = stripInline(glyph.name); + if (isLeftOnly(glyph.name)) { + return base.replace('left', '[start]'); + } + if (isRightOnly(glyph.name)) { + return base.replace('right', '[end]'); + } + return base; + } + + /** + * @param {Glyph} glyph + * @returns {AxoSymbolBidi} + */ + function getSymbolBidi(glyph) { + /** @type {string | null} */ + let otherName = null; + if (isLeftOnly(glyph.name)) { + otherName = glyph.name.replace('left', 'right'); + } + if (isRightOnly(glyph.name)) { + otherName = glyph.name.replace('right', 'left'); + } + if (otherName != null) { + const other = glyphsByName.get(otherName); + if (other == null) { + throw new Error(`Missing glyph ${otherName} for ${glyph.name}`); + } + return { ltr: glyph.unicode, rtl: other.unicode }; + } + return glyph.unicode; + } + + /** @type {Set} */ + const seen = new Set(); + + /** + * @param {Glyph} glyph + * @returns {AxoSymbolValue} + */ + function getSymbolValue(glyph) { + /** @type {Glyph | null} */ + let icon; + /** @type {Glyph | null} */ + let inline; + if (glyph.name.endsWith('-inline')) { + icon = glyphsByName.get(stripInline(glyph.name)) ?? null; + inline = glyph; + } else { + icon = glyph; + inline = glyphsByName.get(`${glyph.name}-inline`) ?? null; + } + + if (icon != null) { + seen.add(icon.name); + } + if (inline != null) { + seen.add(inline.name); + } + + return { + icon: icon != null ? getSymbolBidi(icon) : null, + inline: inline != null ? getSymbolBidi(inline) : null, + }; + } + + for (const glyph of glyphs) { + if (seen.has(glyph.name)) { + continue; + } + + symbols.push({ + name: getSymbolName(glyph), + value: getSymbolValue(glyph), + }); + } + + let res = ''; + res += '// Copyright 2025 Signal Messenger, LLC\n'; + res += '// SPDX-License-Identifier: AGPL-3.0-only\n'; + res += '\n'; + res += + '// WARNING: This file is automatically generated, do not edit directly\n'; + { + res += '\n'; + res += '// Can be used in \n'; + res += 'export type AxoSymbolIconName =\n'; + const lines = []; + for (const symbol of symbols) { + if (symbol.value.icon != null) { + lines.push(` | '${symbol.name}'`); + } + } + res += lines.join('\n'); + res += ';\n'; + } + { + res += '\n'; + res += '// Symbols that can only be used in \n'; + res += 'type AxoSymbolInlineGlyphOnlyName =\n'; + const lines = []; + for (const symbol of symbols) { + if (symbol.value.icon == null && symbol.value.inline != null) { + lines.push(` | '${symbol.name}'`); + } + } + res += lines.join('\n'); + res += ';\n'; + } + { + res += '\n'; + res += + '// Symbols with an inline-specific glyph that override the icon glyph\n'; + res += 'type AxoSymbolInlineGlyphOverrideName =\n'; + const lines = []; + for (const symbol of symbols) { + if (symbol.value.icon != null && symbol.value.inline != null) { + lines.push(` | '${symbol.name}'`); + } + } + res += lines.join('\n'); + res += ';\n'; + } + res += '\n'; + res += '// Symbols that can be used in \n'; + res += 'export type AxoSymbolInlineGlyphName =\n'; + res += ' | AxoSymbolIconName\n'; + res += ' | AxoSymbolInlineGlyphOnlyName;\n'; + res += '\n'; + res += 'type SymbolDef = string | { ltr: string; rtl: string };\n'; + res += 'type IconDefsName = AxoSymbolIconName;\n'; + res += 'type InlineDefsName =\n'; + res += ' | AxoSymbolInlineGlyphOnlyName\n'; + res += ' | AxoSymbolInlineGlyphOverrideName;\n'; + res += '\n'; + res += 'const IconDefs: Record = {\n'; + for (const symbol of symbols) { + if (symbol.value.icon != null) { + res += ' '; + if (/^[a-z_]+$/.test(symbol.name)) { + res += symbol.name; + } else { + res += `'${symbol.name}'`; + } + res += ': '; + if (typeof symbol.value.icon === 'string') { + res += `'\\u{${symbol.value.icon}}'`; + } else { + res += '{ '; + res += `ltr: '\\u{${symbol.value.icon.ltr}}', `; + res += `rtl: '\\u{${symbol.value.icon.rtl}}'`; + res += ' }'; + } + res += ',\n'; + } + } + res += '} satisfies Record;\n'; + + res += '\n'; + res += 'const InlineDefs: Record = {\n'; + for (const symbol of symbols) { + if (symbol.value.inline != null) { + res += ' '; + if (/^[a-z_]+$/.test(symbol.name)) { + res += symbol.name; + } else { + res += `'${symbol.name}'`; + } + res += ': '; + if (typeof symbol.value.inline === 'string') { + res += `'\\u{${symbol.value.inline}}'`; + } else { + res += '{ '; + res += `ltr: '\\u{${symbol.value.inline.ltr}}', `; + res += `rtl: '\\u{${symbol.value.inline.rtl}}'`; + res += ' }'; + } + res += ',\n'; + } + } + res += '} satisfies Record;\n'; + res += '\n'; + res += '/** @testexport */\n'; + res += + 'export function _getAllAxoSymbolIconNames(): ReadonlyArray {\n'; + res += ' return Object.keys(IconDefs) as Array;\n'; + res += '}\n'; + res += '\n'; + res += '/** @testexport */\n'; + res += + 'export function _getAllAxoSymbolInlineGlyphNames(): ReadonlyArray {\n'; + res += ' return Object.keys(IconDefs) as Array;\n'; + res += '}\n'; + res += '\n'; + res += 'export function getAxoSymbolIcon(\n'; + res += ' name: AxoSymbolIconName,\n'; + res += " dir: 'ltr' | 'rtl'\n"; + res += '): string {\n'; + res += ' const value = IconDefs[name];\n'; + res += ' if (value == null) {\n'; + res += + // eslint-disable-next-line no-template-curly-in-string + ' throw new TypeError(`Invalid symbol name for icon: ${name}`);\n'; + res += ' }\n'; + res += " return typeof value === 'string' ? value : value[dir];\n"; + res += '}\n'; + res += '\n'; + res += 'export function getAxoSymbolInlineGlyph(\n'; + res += ' name: AxoSymbolInlineGlyphName,\n'; + res += " dir: 'ltr' | 'rtl'\n"; + res += '): string {\n'; + res += ' const value = InlineDefs[name] ?? IconDefs[name];\n'; + res += ' if (value == null) {\n'; + res += + // eslint-disable-next-line no-template-curly-in-string + ' throw new TypeError(`Invalid symbol name for inline glyph: ${name}`);\n'; + res += ' }\n'; + res += " return typeof value === 'string' ? value : value[dir];\n"; + res += '}\n'; + + await writeFile(DESKTOP_SYMBOLS_DEFS_PATH, res, 'utf-8'); +} + +await Promise.all([copyVariableFontFile(), generateAxoSymbolDefs()]);