diff --git a/packages/diffs-highlight/LICENSE.md b/packages/diffs-highlight/LICENSE.md new file mode 100644 index 000000000..e2c2b0606 --- /dev/null +++ b/packages/diffs-highlight/LICENSE.md @@ -0,0 +1,189 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and + distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the + copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with + that entity. For the purposes of this definition, "control" means (i) the + power, direct or indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (ii) ownership of fifty percent + (50%) or more of the outstanding shares, or (iii) beneficial ownership of + such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation source, and + configuration files. + + "Object" form shall mean any form resulting from mechanical transformation + or translation of a Source form, including but not limited to compiled + object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that is + included in or attached to the work (an example is provided in the Appendix + below). + + "Derivative Works" shall mean any work, whether in Source or Object form, + that is based on (or derived from) the Work and for which the editorial + revisions, annotations, elaborations, or other modifications represent, as a + whole, an original work of authorship. For the purposes of this License, + Derivative Works shall not include works that remain separable from, or + merely link (or bind by name) to the interfaces of, the Work and Derivative + Works thereof. + + "Contribution" shall mean any work of authorship, including the original + version of the Work and any modifications or additions to that Work or + Derivative Works thereof, that is intentionally submitted to Licensor for + inclusion in the Work by the copyright owner or by an individual or Legal + Entity authorized to submit on behalf of the copyright owner. For the + purposes of this definition, "submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its + representatives, including but not limited to communication on electronic + mailing lists, source code control systems, and issue tracking systems that + are managed by, or on behalf of, the Licensor for the purpose of discussing + and improving the Work, but excluding communication that is conspicuously + marked or otherwise designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable copyright license to + reproduce, prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute the Work and such Derivative Works in Source or + Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as stated in + this section) patent license to make, have made, use, offer to sell, sell, + import, and otherwise transfer the Work, where such license applies only to + those patent claims licensable by such Contributor that are necessarily + infringed by their Contribution(s) alone or by combination of their + Contribution(s) with the Work to which such Contribution(s) was submitted. + If You institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work or a + Contribution incorporated within the Work constitutes direct or contributory + patent infringement, then any patent licenses granted to You under this + License for that Work shall terminate as of the date such litigation is + filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, and + in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a + copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating + that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy + of the attribution notices contained within such NOTICE file, excluding + those notices that do not pertain to any part of the Derivative Works, in at + least one of the following places: within a NOTICE text file distributed as + part of the Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display generated by + the Derivative Works, if and wherever such third-party notices normally + appear. The contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional attribution + notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated in + this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to the + Licensor shall be under the terms and conditions of this License, without + any additional terms or conditions. Notwithstanding the above, nothing + herein shall supersede or modify the terms of any separate license agreement + you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, except as + required for reasonable and customary use in describing the origin of the + Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in + writing, Licensor provides the Work (and each Contributor provides its + Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied, including, without limitation, any + warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or + FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining + the appropriateness of using or redistributing the Work and assume any risks + associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in + tort (including negligence), contract, or otherwise, unless required by + applicable law (such as deliberate and grossly negligent acts) or agreed to + in writing, shall any Contributor be liable to You for damages, including + any direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or inability + to use the Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all other + commercial damages or losses), even if such Contributor has been advised of + the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a fee for, + acceptance of support, warranty, indemnity, or other liability obligations + and/or rights consistent with this License. However, in accepting such + obligations, You may act only on Your own behalf and on Your sole + responsibility, not on behalf of any other Contributor, and only if You + agree to indemnify, defend, and hold each Contributor harmless for any + liability incurred by, or claims asserted against, such Contributor by + reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Pierre Computer Company + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/packages/diffs-highlight/README.md b/packages/diffs-highlight/README.md new file mode 100644 index 000000000..7e4a4bfeb --- /dev/null +++ b/packages/diffs-highlight/README.md @@ -0,0 +1,100 @@ +PIERRE COMPUTER COMPANY █ PROJECT: DIFFS HIGHLIGHT + +``` + +CONTACT: SUPPORT@PIERRE.CO +LOCATION: USA +STATUS: ONLINE +OPEN POSITIONS: [Systems Engineer](https://pierre.computer/careers/systems-engineer) + +``` + +Overview: + +- A Figma plugin that syntax highlights code in a text layer by binding the + Pierre `syntax/*` variables to each token's characters. +- Because the colors come from variables rather than hex values, switching the + collection's mode re-colors the code: light, dark, soft, and both CVD variants + from one sample. +- Local development only. This is not published to the Figma Community. + +## Setup + +The plugin is loaded from disk, so it has to be built first: + +```bash +moonx diffs-highlight:build +``` + +Then in Figma desktop: **Plugins > Development > Import plugin from manifest**, +and select `packages/diffs-highlight/manifest.json`. + +The variables have to exist in the file you run it on. Import them from +[`@pierre/theme`](../theme/README.md) first — the primitives collection, then +the semantic collection with one mode per variant. + +## Usage + +1. Select one or more text layers containing code. Non-text layers and empty + text layers in the selection are ignored rather than rejected. +2. Run **Plugins > Development > Diffs Highlight**. +3. Pick the language, confirm the variable collection, and press **Highlight**. + Every selected layer is tokenized as that one language, so group a + multi-layer run by language. +4. Switch that collection's mode on the layer (or its parent frame) to move the + sample between Light, Dark, Soft, and the CVD variants. + +Every language Shiki bundles is offered — 235 of them, listed alphabetically by +display name, so the select relies on type-ahead. The picker opens on +TypeScript. + +The status line reports what happened: how many ranges were bound and across how +many layers, any colors that matched no Pierre role, and any variable names +missing from the collection you picked (usually the sign of a partial import, or +the wrong collection). A layer edited between tokenizing and binding is skipped +and named, rather than costing you the rest of the selection. + +## How a color becomes a variable + +Shiki returns a resolved color per token, but binding a variable needs a role +name like `syntax/keyword`. All eight Pierre variants share one scope table and +differ only in resolved color, so the plugin always tokenizes with `pierre-dark` +as a probe — its 18 `syntax/*` colors are all distinct, where the tritanopia +variants collapse five of them into one — looks the role up by that color, and +binds by name. Figma then resolves the color for whichever mode is active, which +is why there is no theme picker in the UI. + +The lookup table is built from `@pierre/theme`'s committed +`figma/semantic/dark.json`, so it regenerates with the variables it describes. +See [`src/shared/roleIndex.ts`](src/shared/roleIndex.ts) for the resolution +order and the collisions it has to break. + +## Development + +```bash +moonx diffs-highlight:dev --ignore-ci-checks # rebuild both realms on change +moonx diffs-highlight:test +moonx diffs-highlight:typecheck +``` + +Figma reloads the plugin from disk each run, so a rebuild takes effect the next +time you run it — no re-import needed. + +The two realms build separately because Figma requires different shapes: +`dist/code.js` is a single IIFE for the sandbox that owns the `figma` API, and +`dist/ui.html` is one document with its script and styles inlined, since the +iframe loads no external resources. Shiki lives in the UI realm; the sandbox +only applies bindings. + +Carrying all 235 grammars makes `dist/ui.html` about 8 MB. That is inherent — +nothing can be fetched at runtime, so every grammar has to be in the file — and +it costs little in practice, since a grammar is only parsed into a registry when +a language is picked: the panel loads in roughly 200 ms and a first highlight +takes about 350 ms, later ones well under 100 ms. Minifying was measured at a +10% saving, not worth the unreadable output, because the bulk is grammar data +rather than code. + +`test/highlight.test.ts` runs every one of those grammars through the JavaScript +regex engine, which is the check that matters here: the sandbox cannot load +WebAssembly, so a grammar that needs Oniguruma would fail in Figma with no other +warning. All 235 pass today, and the sweep is most of the suite's ~12s runtime. diff --git a/packages/diffs-highlight/manifest.json b/packages/diffs-highlight/manifest.json new file mode 100644 index 000000000..84b253d8d --- /dev/null +++ b/packages/diffs-highlight/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Diffs Highlight", + "api": "1.0.0", + "main": "dist/code.js", + "ui": "dist/ui.html", + "editorType": ["figma"], + "documentAccess": "dynamic-page", + "networkAccess": { + "allowedDomains": ["none"] + } +} diff --git a/packages/diffs-highlight/moon.yml b/packages/diffs-highlight/moon.yml new file mode 100644 index 000000000..22a878b56 --- /dev/null +++ b/packages/diffs-highlight/moon.yml @@ -0,0 +1,37 @@ +$schema: 'https://moonrepo.dev/schemas/project.json' + +# A Figma plugin, not an npm package, so there is no `publishable` tag and no +# tsdown build: the two Vite passes emit exactly the two files manifest.json +# points at (dist/code.js for the sandbox, dist/ui.html for the iframe). +language: 'typescript' +layer: 'application' + +tasks: + # Two passes because the realms need different output shapes: the sandbox is a + # bare IIFE with no DOM, the UI is one HTML file with its JS and CSS inlined. + build: + script: + 'vite build --config vite.config.code.ts && vite build --config + vite.config.ui.ts' + deps: + - '^:build' + inputs: + - 'src/**/*' + - 'ui.html' + - 'vite.config.*.ts' + - 'tsconfig.json' + outputs: + - 'dist' + + # Both realms watched in parallel; `wait` keeps the task alive until both + # exit. Figma still needs the plugin re-run to pick up a rebuild. + dev: + script: + 'vite build --config vite.config.code.ts --watch & vite build --config + vite.config.ui.ts --watch & wait' + deps: + - '^:build' + options: + cache: false + persistent: true + runInCI: 'skip' diff --git a/packages/diffs-highlight/package.json b/packages/diffs-highlight/package.json new file mode 100644 index 000000000..bde41a109 --- /dev/null +++ b/packages/diffs-highlight/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pierre/diffs-highlight", + "version": "0.0.0", + "private": true, + "description": "Diffs Highlight, a Figma plugin that binds Pierre syntax variables to code in text layers", + "license": "apache-2.0", + "author": "Pierre Computer ", + "repository": { + "type": "git", + "url": "https://github.com/pierrecomputer/pierre.git", + "directory": "packages/diffs-highlight" + }, + "type": "module", + "dependencies": { + "@pierre/theme": "workspace:*", + "@shikijs/core": "catalog:", + "@shikijs/engine-javascript": "catalog:", + "@shikijs/langs": "catalog:", + "shiki": "catalog:" + }, + "devDependencies": { + "@figma/plugin-typings": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "catalog:" + } +} diff --git a/packages/diffs-highlight/src/sandbox/code.ts b/packages/diffs-highlight/src/sandbox/code.ts new file mode 100644 index 000000000..f01857ad0 --- /dev/null +++ b/packages/diffs-highlight/src/sandbox/code.ts @@ -0,0 +1,243 @@ +/** + * The sandbox realm. It owns the `figma` API — selection, variables, fonts — and + * does no tokenizing: it hands the selected layers' text to the UI and applies + * the character ranges the UI sends back. + */ +import type { + CollectionSummary, + LayerBindings, + SandboxMessage, + SelectionLayer, + TokenBinding, + UiMessage, +} from '../shared/messages'; + +/** Variable-name prefix used to recognize a collection holding Pierre roles. */ +const SYNTAX_PREFIX = 'syntax/'; + +figma.showUI(__html__, { width: 320, height: 320, themeColors: true }); + +function post(message: SandboxMessage): void { + figma.ui.postMessage(message); +} + +function postError(error: unknown): void { + post({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); +} + +/** + * Describes the selected text layers for the UI, or explains why there is + * nothing to work on. + * + * Any number of text layers can be selected at once. Non-text layers and empty + * text layers are filtered out rather than rejected, so a selection that sweeps + * up a frame or a stray rectangle alongside the code still works. + */ +function readSelection(): { + layers: SelectionLayer[]; + issue: string | null; +} { + const nodes = figma.currentPage.selection; + if (nodes.length === 0) { + return { layers: [], issue: 'Select one or more text layers.' }; + } + + const textNodes = nodes.filter( + (node): node is TextNode => node.type === 'TEXT' + ); + if (textNodes.length === 0) { + return { layers: [], issue: 'The selection has no text layers.' }; + } + + const layers = textNodes + .filter((node) => node.characters.length > 0) + .map((node) => ({ + nodeId: node.id, + nodeName: node.name, + characters: node.characters, + })); + + if (layers.length === 0) { + return { + layers: [], + issue: + textNodes.length === 1 + ? 'The selected text layer is empty.' + : 'The selected text layers are empty.', + }; + } + + return { layers, issue: null }; +} + +/** + * Lists every local collection with its modes, plus how many `syntax/*` + * variables it holds. The semantic collection's name is chosen by whoever + * imported the tokens, so that count is what lets the UI preselect the right + * one instead of guessing from the name. + */ +async function readCollections(): Promise { + const [collections, variables] = await Promise.all([ + figma.variables.getLocalVariableCollectionsAsync(), + figma.variables.getLocalVariablesAsync('COLOR'), + ]); + + const syntaxCounts = new Map(); + for (const variable of variables) { + if (!variable.name.startsWith(SYNTAX_PREFIX)) continue; + const collectionId = variable.variableCollectionId; + syntaxCounts.set(collectionId, (syntaxCounts.get(collectionId) ?? 0) + 1); + } + + return collections.map((collection) => ({ + id: collection.id, + name: collection.name, + modeNames: collection.modes.map((mode) => mode.name), + syntaxVariableCount: syntaxCounts.get(collection.id) ?? 0, + })); +} + +async function sendState(): Promise { + const { layers, issue } = readSelection(); + post({ + type: 'state', + collections: await readCollections(), + layers, + issue, + }); +} + +/** Color variables of one collection, keyed by their full name. */ +async function readVariablesByName( + collectionId: string +): Promise> { + const variables = await figma.variables.getLocalVariablesAsync('COLOR'); + const byName = new Map(); + for (const variable of variables) { + if (variable.variableCollectionId === collectionId) { + byName.set(variable.name, variable); + } + } + return byName; +} + +/** `setRangeFills` throws unless every font in the layer is loaded first. */ +async function loadFonts(node: TextNode): Promise { + const fonts = node.getRangeAllFontNames(0, node.characters.length); + await Promise.all(fonts.map((font) => figma.loadFontAsync(font))); +} + +/** + * Rewrites one character range's fill so its color comes from `variable`. + * + * The range's current solid paint is reused when there is one, which keeps + * properties like opacity; a range with mixed or non-solid fills falls back to + * an opaque paint, since the variable supplies the color either way. + */ +function bindRange( + node: TextNode, + binding: TokenBinding, + variable: Variable +): void { + const fills = node.getRangeFills(binding.start, binding.end); + const existing = + fills === figma.mixed + ? undefined + : fills.find((paint): paint is SolidPaint => paint.type === 'SOLID'); + + const paint: SolidPaint = existing ?? { + type: 'SOLID', + color: { r: 0, g: 0, b: 0 }, + }; + + node.setRangeFills(binding.start, binding.end, [ + figma.variables.setBoundVariableForPaint(paint, 'color', variable), + ]); +} + +/** + * Applies one layer's ranges, or returns why it was skipped. + * + * A layer is skipped rather than failing the whole run, so one stale layer in a + * multi-layer selection does not cost the user the others. + */ +async function applyToLayer( + layer: LayerBindings, + variables: Map, + missingVariableNames: Set +): Promise<{ boundRanges: number } | { skipped: string }> { + const node = await figma.getNodeByIdAsync(layer.nodeId); + if (node === null || node.type !== 'TEXT') { + return { skipped: `${layer.nodeName}: no longer exists` }; + } + + // Bindings are ordered, so the last one's end is the highest offset needed. + // Checking it once catches a layer edited between tokenizing and applying, + // which would otherwise make every out-of-bounds range throw. + const lastBinding = layer.bindings[layer.bindings.length - 1]; + if (lastBinding !== undefined && lastBinding.end > node.characters.length) { + return { + skipped: `${layer.nodeName}: text changed since it was tokenized`, + }; + } + + await loadFonts(node); + + let boundRanges = 0; + for (const binding of layer.bindings) { + const variable = variables.get(binding.variableName); + if (variable === undefined) { + missingVariableNames.add(binding.variableName); + continue; + } + bindRange(node, binding, variable); + boundRanges += 1; + } + + return { boundRanges }; +} + +async function applyBindings( + request: Extract +): Promise { + const variables = await readVariablesByName(request.collectionId); + const missingVariableNames = new Set(); + const skippedLayers: string[] = []; + let boundRanges = 0; + let boundLayers = 0; + + for (const layer of request.layers) { + const result = await applyToLayer(layer, variables, missingVariableNames); + if ('skipped' in result) { + skippedLayers.push(result.skipped); + continue; + } + boundRanges += result.boundRanges; + boundLayers += 1; + } + + post({ + type: 'applied', + boundRanges, + boundLayers, + missingVariableNames: [...missingVariableNames].sort(), + skippedLayers, + }); +} + +figma.ui.onmessage = (message: UiMessage) => { + if (message.type === 'cancel') { + figma.closePlugin(); + return; + } + applyBindings(message).catch(postError); +}; + +figma.on('selectionchange', () => { + sendState().catch(postError); +}); + +sendState().catch(postError); diff --git a/packages/diffs-highlight/src/shared/mapTokens.ts b/packages/diffs-highlight/src/shared/mapTokens.ts new file mode 100644 index 000000000..f7a7926ab --- /dev/null +++ b/packages/diffs-highlight/src/shared/mapTokens.ts @@ -0,0 +1,93 @@ +import type { TokenBinding } from './messages'; +import type { RoleIndex } from './roleIndex'; + +/** + * The part of Shiki's `ThemedToken` this module needs. Declared structurally so + * the mapping stays a pure function that tests can call with plain objects. + */ +export interface MappableToken { + /** Start offset relative to the whole input, 0-indexed. */ + offset: number; + content: string; + color?: string; +} + +export interface MapTokensResult { + bindings: TokenBinding[]; + /** Ranges left untouched because no role claimed their color. */ + unmatchedRanges: number; + /** The distinct unmatched colors, sorted, for the UI summary. */ + unmatchedColors: string[]; +} + +const WHITESPACE_ONLY = /^\s*$/; + +/** + * Normalizes a Shiki color to the lowercase `#rrggbb` form the role index is + * keyed by, dropping a fully opaque `ff` alpha suffix if the theme carries one. + */ +function normalizeColor(color: string): string { + const lower = color.toLowerCase(); + return lower.length === 9 && lower.endsWith('ff') ? lower.slice(0, 7) : lower; +} + +/** + * Turns tokenized lines into the character ranges the sandbox should bind. + * + * Two reductions keep the sandbox's per-range `setRangeFills` calls down, since + * that is the expensive side of the plugin: + * + * - Whitespace-only tokens are skipped. They draw no glyph, so a fill on them + * is invisible. + * - Neighbouring ranges that resolve to the same variable are merged, but only + * when they actually touch (`previous.end === token.offset`). A skipped token + * or the newline between two lines breaks that adjacency, so merging can + * never silently swallow a range it should have left alone. + */ +export function mapTokens( + lines: readonly (readonly MappableToken[])[], + index: RoleIndex +): MapTokensResult { + const bindings: TokenBinding[] = []; + const unmatchedColors = new Set(); + let unmatchedRanges = 0; + + for (const line of lines) { + for (const token of line) { + if (token.content.length === 0) continue; + if (WHITESPACE_ONLY.test(token.content)) continue; + + if (token.color === undefined) { + unmatchedRanges += 1; + continue; + } + + const color = normalizeColor(token.color); + const variableName = index.get(color); + if (variableName === undefined) { + unmatchedRanges += 1; + unmatchedColors.add(color); + continue; + } + + const end = token.offset + token.content.length; + const previous = bindings[bindings.length - 1]; + if ( + previous !== undefined && + previous.variableName === variableName && + previous.end === token.offset + ) { + previous.end = end; + continue; + } + + bindings.push({ start: token.offset, end, variableName }); + } + } + + return { + bindings, + unmatchedRanges, + unmatchedColors: [...unmatchedColors].sort(), + }; +} diff --git a/packages/diffs-highlight/src/shared/messages.ts b/packages/diffs-highlight/src/shared/messages.ts new file mode 100644 index 000000000..2062e70a6 --- /dev/null +++ b/packages/diffs-highlight/src/shared/messages.ts @@ -0,0 +1,74 @@ +/** + * The message contract between the two Figma realms. The sandbox + * (`src/sandbox/code.ts`) owns the document and the variables; the UI iframe + * (`src/ui/main.ts`) owns Shiki and the controls. Neither can call into the + * other directly, so every exchange goes through `postMessage` with one of the + * shapes below. + */ + +/** One character range of a text layer, to be bound to one Figma variable. */ +export interface TokenBinding { + /** Character index of the first character, 0-indexed. */ + start: number; + /** Character index one past the last character. */ + end: number; + /** Figma variable name inside the chosen collection, e.g. `syntax/keyword`. */ + variableName: string; +} + +/** A local variable collection the user can target, as offered in the picker. */ +export interface CollectionSummary { + id: string; + name: string; + /** Mode names, so the UI can tell a multi-mode collection from a single one. */ + modeNames: string[]; + /** How many of the collection's variables are named `syntax/*`. */ + syntaxVariableCount: number; +} + +/** One selected text layer and the text to tokenize for it. */ +export interface SelectionLayer { + nodeId: string; + nodeName: string; + characters: string; +} + +/** The ranges to bind on one layer, the result of tokenizing it. */ +export interface LayerBindings { + nodeId: string; + /** Carried along so the sandbox can name the layer if it has to skip it. */ + nodeName: string; + bindings: TokenBinding[]; +} + +export type SandboxMessage = + /** + * Sent on startup and again whenever the selection changes, so the UI never + * has to ask. `layers` is empty when nothing usable is selected, and `issue` + * explains why. + */ + | { + type: 'state'; + collections: CollectionSummary[]; + layers: SelectionLayer[]; + issue: string | null; + } + | { + type: 'applied'; + /** Character ranges that received a bound variable. */ + boundRanges: number; + /** How many layers those ranges are spread across. */ + boundLayers: number; + /** + * Variable names the mapping asked for that the chosen collection does + * not contain — the sign of a wrong collection or a partial import. + */ + missingVariableNames: string[]; + /** Layers left untouched, each as `name: reason`. */ + skippedLayers: string[]; + } + | { type: 'error'; message: string }; + +export type UiMessage = + | { type: 'apply'; collectionId: string; layers: LayerBindings[] } + | { type: 'cancel' }; diff --git a/packages/diffs-highlight/src/shared/roleIndex.ts b/packages/diffs-highlight/src/shared/roleIndex.ts new file mode 100644 index 000000000..c1a270572 --- /dev/null +++ b/packages/diffs-highlight/src/shared/roleIndex.ts @@ -0,0 +1,96 @@ +import darkTokens from '@pierre/theme/figma/semantic/dark.json'; + +/** + * Maps a resolved token color back to the Figma variable that produced it. + * + * Shiki hands us colors, but binding a variable needs a name (`syntax/keyword`). + * All eight Pierre variants share one scope table and differ only in resolved + * color, so the role a color stands for can be recovered from any single + * variant and then bound by name — Figma resolves the actual color per mode. + * `pierre-dark` is the variant used for that lookup (its 18 `syntax/*` colors + * are all distinct, unlike the tritanopia variants where five collapse into + * one), which is why the index is built from the committed `dark.json`. + */ +export type RoleIndex = ReadonlyMap; + +/** Shiki theme name whose colors the index is keyed by. */ +export const PROBE_THEME_NAME = 'pierre-dark'; + +/** + * Groups scanned when a color is not a `syntax/*` color, in the order they are + * scanned. `bg` and `border` are left out entirely: they are surface colors + * that never apply to glyphs, and including them would let a background color + * win over the foreground role that shares its hex. + */ +const FALLBACK_GROUPS = ['states', 'accent', 'ansi', 'fg'] as const; + +const SYNTAX_GROUP = 'syntax'; + +interface ColorToken { + $value: { hex: string }; +} + +interface TokenGroup { + [key: string]: ColorToken | TokenGroup; +} + +function isColorToken(node: ColorToken | TokenGroup): node is ColorToken { + return '$value' in node; +} + +/** + * Walks one group of the token document and records `hex -> variableName` for + * every color token it contains, without overwriting a hex already claimed by + * an earlier (higher priority) group. + */ +function indexGroup( + into: Map, + group: TokenGroup, + path: string[] +): void { + for (const [key, node] of Object.entries(group)) { + const nextPath = [...path, key]; + if (isColorToken(node)) { + const hex = node.$value.hex.toLowerCase(); + if (!into.has(hex)) into.set(hex, nextPath.join('/')); + continue; + } + indexGroup(into, node, nextPath); + } +} + +/** + * Builds the color-to-variable-name index from a semantic token document. + * + * `baseForegroundHex` is the probe theme's `editor.foreground`, which Shiki + * emits for any text no grammar scope claims. That color is also `syntax/invalid` + * in every Pierre variant, so it is resolved last and unconditionally to + * `fg/base`: plain code is common, invalid code is not, and mapping every + * unscoped character onto `syntax/invalid` would tint whole samples wrong. + */ +export function buildRoleIndex( + tokens: TokenGroup, + baseForegroundHex: string +): RoleIndex { + const index = new Map(); + + const syntaxGroup = tokens[SYNTAX_GROUP]; + if (syntaxGroup !== undefined && !isColorToken(syntaxGroup)) { + indexGroup(index, syntaxGroup, [SYNTAX_GROUP]); + } + + for (const groupName of FALLBACK_GROUPS) { + const group = tokens[groupName]; + if (group === undefined || isColorToken(group)) continue; + indexGroup(index, group, [groupName]); + } + + index.set(baseForegroundHex.toLowerCase(), 'fg/base'); + + return index; +} + +/** The index for `pierre-dark`, built from the committed token export. */ +export function createRoleIndex(baseForegroundHex: string): RoleIndex { + return buildRoleIndex(darkTokens as unknown as TokenGroup, baseForegroundHex); +} diff --git a/packages/diffs-highlight/src/ui/highlight.ts b/packages/diffs-highlight/src/ui/highlight.ts new file mode 100644 index 000000000..166c3b094 --- /dev/null +++ b/packages/diffs-highlight/src/ui/highlight.ts @@ -0,0 +1,120 @@ +/** + * Shiki setup for the UI realm: the language list, the probe theme, and the step + * from source text to the character ranges the sandbox binds. Kept free of DOM + * access so it can be exercised directly by tests. + */ +import probeTheme from '@pierre/theme/pierre-dark'; +import { + createHighlighterCore, + type HighlighterCore, + type ThemeRegistrationRaw, +} from '@shikijs/core'; +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'; +import { bundledLanguagesInfo } from 'shiki/langs'; + +import { mapTokens, type MapTokensResult } from '../shared/mapTokens'; +import { createRoleIndex, PROBE_THEME_NAME } from '../shared/roleIndex'; + +export interface Language { + /** Shiki language id, e.g. `typescript`. */ + id: string; + /** Display name from Shiki's bundle, e.g. `TypeScript`. */ + label: string; +} + +/** + * Every language Shiki bundles, sorted by display name for the picker. + * + * The grammars behind them are reached through `bundledLanguagesInfo`'s + * `import()` getters. Those are still statically bundled — the UI is one + * self-contained HTML file and Figma loads no external resources, so there is + * nothing to fetch at runtime — but going through the getters means a grammar is + * only *parsed into a registry* when a user picks it, which keeps startup flat + * regardless of how many languages exist. + */ +export const LANGUAGES: Language[] = bundledLanguagesInfo + .map((info) => ({ id: info.id, label: info.name })) + .sort((a, b) => a.label.localeCompare(b.label)); + +/** + * Grammar loaders keyed by id *and* by alias, so `bash` resolves as readily as + * `shellscript`. Shiki's aliases are unambiguous — none collides with an id and + * none is claimed by two languages — so flattening them into one map is safe. + */ +const LANGUAGE_IMPORTS = new Map( + bundledLanguagesInfo.flatMap((info) => + [info.id, ...(info.aliases ?? [])].map((key) => [key, info.import] as const) + ) +); + +/** + * Shiki's theme type is mutable while `@pierre/theme` exports a frozen readonly + * object; the shapes are otherwise identical, so one cast at the boundary keeps + * the rest of the module honest. + */ +const theme = probeTheme as unknown as ThemeRegistrationRaw; + +// Shiki paints text that no grammar scope claims with `editor.foreground`, which +// is why the index needs it; roleIndex.ts explains what it resolves to. +const roleIndex = createRoleIndex(probeTheme.colors['editor.foreground']); + +/** Highlighters that have had the one-time warm-up described below. */ +const warmed = new WeakSet(); + +export function createProbeHighlighter(): Promise { + return createHighlighterCore({ + themes: [theme], + langs: [], + // The JavaScript engine avoids WebAssembly, which the plugin sandbox cannot + // load. It is the same engine @pierre/diffs defaults to. + engine: createJavaScriptRegexEngine(), + }); +} + +/** + * Registers `lang`'s grammar if it is not registered yet, then makes sure the + * highlighter has been warmed up. + * + * The warm-up exists because a highlighter's very first tokenization comes back + * coarser than every call after it: `const a = 1;` yields `1;` as one token + * instead of separating the number from the semicolon. One throwaway pass settles + * it, and it is genuinely one-time — languages registered later tokenize + * correctly on their first real call, so this does not repeat per language. + */ +async function ensureLanguage( + highlighter: HighlighterCore, + lang: string +): Promise { + if (!highlighter.getLoadedLanguages().includes(lang)) { + const load = LANGUAGE_IMPORTS.get(lang); + if (load === undefined) throw new Error(`Unknown language: ${lang}`); + await highlighter.loadLanguage(load); + } + + if (!warmed.has(highlighter)) { + highlighter.codeToTokens('x', { lang, theme: PROBE_THEME_NAME }); + warmed.add(highlighter); + } +} + +/** + * Tokenizes `characters` from a Figma text layer and resolves each token to the + * Figma variable that should color it. + * + * Figma stores a soft line break as U+2028. Swapping in a newline is + * character-for-character, so Shiki's token offsets still line up with the + * layer's own character indices and the ranges can be applied as-is. + */ +export async function highlightToBindings( + highlighter: HighlighterCore, + characters: string, + lang: string +): Promise { + await ensureLanguage(highlighter, lang); + + const { tokens } = highlighter.codeToTokens( + characters.replaceAll('\u2028', '\n'), + { lang, theme: PROBE_THEME_NAME } + ); + return mapTokens(tokens, roleIndex); +} diff --git a/packages/diffs-highlight/src/ui/main.ts b/packages/diffs-highlight/src/ui/main.ts new file mode 100644 index 000000000..93facc80d --- /dev/null +++ b/packages/diffs-highlight/src/ui/main.ts @@ -0,0 +1,296 @@ +/** + * The UI realm's controls. It never touches the document: it receives the + * selected layers' text from the sandbox, hands each one to Shiki (see + * highlight.ts), and sends the resulting character ranges back for binding. + */ +import type { HighlighterCore } from '@shikijs/core'; + +import type { + CollectionSummary, + LayerBindings, + SandboxMessage, + SelectionLayer, + UiMessage, +} from '../shared/messages'; +import { + createProbeHighlighter, + highlightToBindings, + LANGUAGES, +} from './highlight'; + +function requireElement(id: string): T { + const element = document.getElementById(id); + if (element === null) throw new Error(`Missing #${id} in the plugin UI`); + return element as T; +} + +const elements = { + notice: requireElement('notice'), + language: requireElement('language'), + collection: requireElement('collection'), + status: requireElement('status'), + apply: requireElement('apply'), + cancel: requireElement('cancel'), +}; + +/** What the picker opens on, since the full list is alphabetical. */ +const DEFAULT_LANGUAGE = 'typescript'; + +let collections: CollectionSummary[] = []; +let layers: SelectionLayer[] = []; + +/** Totals from the last run, kept so the sandbox's report can cite them. */ +let lastRun: { unmatchedRanges: number; unmatchedColors: string[] } | null = + null; + +/** + * Created at startup, with no grammars registered yet — the picked language's + * grammar is registered on demand inside `highlightToBindings`. + */ +const highlighterReady: Promise = createProbeHighlighter(); + +function post(message: UiMessage): void { + parent.postMessage({ pluginMessage: message }, '*'); +} + +function setStatus(text: string, tone: 'info' | 'error' = 'info'): void { + elements.status.textContent = text; + elements.status.dataset.tone = tone; +} + +/** + * Fills the language picker with every language Shiki bundles, alphabetically. + * The list is long, so the select relies on the browser's built-in type-ahead; + * it opens on TypeScript rather than whatever sorts first. + */ +function fillLanguages(): void { + for (const language of LANGUAGES) { + const option = document.createElement('option'); + option.value = language.id; + option.textContent = language.label; + elements.language.append(option); + } + + const preferred = LANGUAGES.some( + (language) => language.id === DEFAULT_LANGUAGE + ) + ? DEFAULT_LANGUAGE + : LANGUAGES[0]?.id; + if (preferred !== undefined) elements.language.value = preferred; +} + +/** + * How a collection reads in the picker. The mode count is only worth showing + * when there is more than one: a single-mode collection is fully described by + * its name, and listing every mode of an eight-mode collection would overflow + * the control. + */ +function collectionLabel(collection: CollectionSummary): string { + if (collection.modeNames.length < 2) return collection.name; + return `${collection.name} (${String(collection.modeNames.length)} modes)`; +} + +/** + * Rebuilds the collection picker, keeping the user's choice if that collection + * still exists and otherwise defaulting to the collection holding the most + * `syntax/*` variables — the semantic collection, whatever it was named at + * import time. + */ +function fillCollections(): void { + const previous = elements.collection.value; + elements.collection.replaceChildren(); + + for (const collection of collections) { + const option = document.createElement('option'); + option.value = collection.id; + option.textContent = collectionLabel(collection); + elements.collection.append(option); + } + + const stillPresent = collections.some( + (collection) => collection.id === previous + ); + if (stillPresent) { + elements.collection.value = previous; + return; + } + + let best = collections[0]; + for (const collection of collections) { + if ( + best !== undefined && + collection.syntaxVariableCount > best.syntaxVariableCount + ) { + best = collection; + } + } + if (best !== undefined) elements.collection.value = best.id; +} + +function render(issue: string | null): void { + const hasSyntaxVariables = collections.some( + (collection) => collection.syntaxVariableCount > 0 + ); + + elements.collection.disabled = collections.length === 0; + elements.apply.disabled = layers.length === 0 || collections.length === 0; + // The count lives on the button rather than in a separate line of text, so a + // multi-layer run is legible right where it is about to be triggered. + elements.apply.textContent = + layers.length > 1 + ? `Highlight ${String(layers.length)} layers` + : 'Highlight'; + + // Shown only when there is nothing to work on; a usable selection needs no + // narration. + const notice = + layers.length > 0 ? null : (issue ?? 'Select one or more text layers.'); + elements.notice.textContent = notice ?? ''; + elements.notice.hidden = notice === null; + + if (collections.length === 0) { + setStatus( + 'This file has no local variable collections. Import the Pierre tokens first.', + 'error' + ); + } else if (!hasSyntaxVariables) { + setStatus( + 'No collection has syntax/* variables. Import packages/theme/figma/semantic into this file.', + 'error' + ); + } else if (lastRun === null) { + // Clears an environment warning that no longer holds, without wiping the + // summary from a run that already happened. + setStatus(''); + } +} + +/** + * Tokenizes every selected layer and asks the sandbox to bind the results. + * + * All layers are tokenized as the one language the picker names, which is the + * point of allowing a multi-layer selection: several samples of the same + * language get highlighted in one go. Layers are handled one after another + * because they share a highlighter, and only the first pass has to register the + * grammar. + */ +async function apply(): Promise { + const targets = layers; + const collectionId = elements.collection.value; + if (targets.length === 0 || collectionId === '') return; + + elements.apply.disabled = true; + setStatus('Tokenizing…'); + + try { + const highlighter = await highlighterReady; + const lang = elements.language.value; + + const payload: LayerBindings[] = []; + const unmatchedColors = new Set(); + let unmatchedRanges = 0; + let totalRanges = 0; + + for (const layer of targets) { + const mapping = await highlightToBindings( + highlighter, + layer.characters, + lang + ); + + unmatchedRanges += mapping.unmatchedRanges; + for (const color of mapping.unmatchedColors) unmatchedColors.add(color); + if (mapping.bindings.length === 0) continue; + + totalRanges += mapping.bindings.length; + payload.push({ + nodeId: layer.nodeId, + nodeName: layer.nodeName, + bindings: mapping.bindings, + }); + } + + lastRun = { + unmatchedRanges, + unmatchedColors: [...unmatchedColors].sort(), + }; + + if (payload.length === 0) { + setStatus('Nothing to bind: no token matched a Pierre role.', 'error'); + elements.apply.disabled = false; + return; + } + + setStatus(`Binding ${String(totalRanges)} ranges…`); + post({ type: 'apply', collectionId, layers: payload }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setStatus(`Highlighting failed: ${message}`, 'error'); + elements.apply.disabled = false; + } +} + +function summarize( + applied: Extract +): void { + const lines = [ + applied.boundLayers > 1 + ? `Bound ${String(applied.boundRanges)} ranges across ${String(applied.boundLayers)} layers.` + : `Bound ${String(applied.boundRanges)} ranges.`, + ]; + + if (lastRun !== null && lastRun.unmatchedRanges > 0) { + lines.push( + `Left ${String(lastRun.unmatchedRanges)} ranges alone (colors with no Pierre role: ${lastRun.unmatchedColors.join(', ')}).` + ); + } + if (applied.missingVariableNames.length > 0) { + lines.push( + `Missing from the chosen collection: ${applied.missingVariableNames.join(', ')}.` + ); + } + if (applied.skippedLayers.length > 0) { + const noun = applied.skippedLayers.length === 1 ? 'layer' : 'layers'; + lines.push( + `Skipped ${String(applied.skippedLayers.length)} ${noun} (${applied.skippedLayers.join('; ')}).` + ); + } + + const wentWrong = + applied.missingVariableNames.length > 0 || applied.skippedLayers.length > 0; + setStatus(lines.join('\n'), wentWrong ? 'error' : 'info'); +} + +function handleMessage(message: SandboxMessage): void { + switch (message.type) { + case 'state': + collections = message.collections; + layers = message.layers; + fillCollections(); + render(message.issue); + return; + case 'applied': + elements.apply.disabled = false; + summarize(message); + return; + case 'error': + elements.apply.disabled = false; + setStatus(message.message, 'error'); + return; + } +} + +fillLanguages(); + +elements.apply.addEventListener('click', () => { + void apply(); +}); +elements.cancel.addEventListener('click', () => { + post({ type: 'cancel' }); +}); + +window.addEventListener('message', (event: MessageEvent) => { + const message = (event.data as { pluginMessage?: SandboxMessage }) + .pluginMessage; + if (message !== undefined) handleMessage(message); +}); diff --git a/packages/diffs-highlight/src/ui/ui.css b/packages/diffs-highlight/src/ui/ui.css new file mode 100644 index 000000000..783686155 --- /dev/null +++ b/packages/diffs-highlight/src/ui/ui.css @@ -0,0 +1,159 @@ +/* + * Figma injects --figma-color-* custom properties into every plugin iframe and + * updates them when the editor's own theme changes, so the panel follows light + * and dark Figma without a media query. Fallbacks cover the case of an older + * client that does not inject them. + */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + Inter, + -apple-system, + blinkmacsystemfont, + 'Segoe UI', + roboto, + sans-serif; + font-size: 11px; + line-height: 16px; + color: var(--figma-color-text, #1e1e1e); + background: var(--figma-color-bg, #fff); +} + +.panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} + +/* Only rendered when there is nothing to highlight, so it reads as guidance + rather than an error. */ +.notice { + margin: 0; + color: var(--figma-color-text-secondary, #5b5b5b); +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +label { + font-weight: 600; +} + +.select { + position: relative; + display: flex; +} + +/* + * `appearance: none` drops the platform chevron, which Chrome pins hard against + * the control's right edge, in favor of the one drawn in `.select::after` at a + * deliberate inset. The right padding is what keeps a long option label from + * running underneath that chevron. + */ +.select select { + width: 100%; + height: 32px; + padding: 0 34px 0 10px; + font-family: inherit; + font-size: inherit; + color: var(--figma-color-text, #1e1e1e); + appearance: none; + background: var(--figma-color-bg-secondary, #f5f5f5); + border: 1px solid var(--figma-color-border, #e6e6e6); + border-radius: 6px; +} + +.select select:hover:not(:disabled) { + border-color: var(--figma-color-border-strong, #b3b3b3); +} + +.select select:focus-visible { + border-color: var(--figma-color-border-selected, #0d99ff); + outline: 1px solid var(--figma-color-border-selected, #0d99ff); + outline-offset: -2px; +} + +.select select:disabled { + color: var(--figma-color-text-disabled, #b3b3b3); +} + +/* + * Drawn as a mask filled with `currentColor` rather than a background image, so + * the chevron follows the panel's text color in both Figma themes instead of + * being baked to one value. + */ +.select::after { + position: absolute; + top: 50%; + right: 12px; + width: 10px; + height: 6px; + content: ''; + background-color: currentcolor; + opacity: 0.65; + transform: translateY(-50%); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 10px 6px; + pointer-events: none; +} + +.select:has(select:disabled)::after { + opacity: 0.3; +} + +.status { + min-height: 16px; + margin: 0; + color: var(--figma-color-text-secondary, #5b5b5b); + white-space: pre-line; +} + +.status[data-tone='error'] { + color: var(--figma-color-text-danger, #b3261e); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +button { + height: 32px; + padding: 0 12px; + font-family: inherit; + font-size: inherit; + font-weight: 600; + border: 1px solid transparent; + border-radius: 6px; +} + +button:not(:disabled) { + cursor: pointer; +} + +button:disabled { + opacity: 0.4; +} + +.primary { + color: var(--figma-color-text-onbrand, #fff); + background: var(--figma-color-bg-brand, #0d99ff); +} + +.secondary { + color: var(--figma-color-text, #1e1e1e); + background: var(--figma-color-bg, #fff); + border-color: var(--figma-color-border, #e6e6e6); +} diff --git a/packages/diffs-highlight/test/highlight.test.ts b/packages/diffs-highlight/test/highlight.test.ts new file mode 100644 index 000000000..5d9ddebbe --- /dev/null +++ b/packages/diffs-highlight/test/highlight.test.ts @@ -0,0 +1,174 @@ +import type { HighlighterCore } from '@shikijs/core'; +/** + * Guards the tokenizing side of the plugin, where the standing risk is Shiki's + * JavaScript regex engine: the plugin sandbox cannot load WebAssembly, so a + * grammar that only works under Oniguruma would fail at runtime in Figma with no + * other warning. Since the picker offers every bundled language, that has to be + * checked across all of them, not just the popular ones. + */ +import { beforeAll, describe, expect, test } from 'bun:test'; + +import { + createProbeHighlighter, + highlightToBindings, + LANGUAGES, +} from '../src/ui/highlight'; + +/** + * Languages spot-checked for real role variety, with a snippet each. These are + * the ones the plugin is most likely used on; the sweep below covers the rest. + */ +const SAMPLES: Record = { + typescript: 'const enabled: boolean = true; // note\nexport function go() {}', + tsx: 'const App = () =>
{name}
;', + javascript: 'let total = 0;\nfor (const x of xs) total += x; // sum', + jsx: 'export default () =>

{count}

;', + python: 'def go(name: str) -> int:\n return len(name) # count', + go: 'package main\n\nfunc main() { println("hi") }', + rust: 'fn main() {\n let x: u32 = 1; // one\n}', + bash: 'set -euo pipefail\necho "$HOME" # home', + json: '{ "name": "pierre", "count": 2 }', + html: '
hi
', + css: '.a { color: #fff; /* note */ }', + markdown: '# Title\n\nSome `code` and a [link](https://pierre.co).', +}; + +/** Generic enough to exercise comments, strings, numbers, and punctuation. */ +const SWEEP_SAMPLE = `# comment line +const x = "string" /* 1.5 */ +function f(a, b) { return a + b; } +text +[section] +key: value`; + +let highlighter: HighlighterCore; + +beforeAll(async () => { + highlighter = await createProbeHighlighter(); +}); + +describe('the language list', () => { + test('offers Shiki’s full bundle, sorted by display name', () => { + expect(LANGUAGES.length).toBeGreaterThan(200); + + const labels = LANGUAGES.map((language) => language.label); + expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b))); + + const ids = LANGUAGES.map((language) => language.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('lists canonical ids only, leaving aliases out of the picker', () => { + const ids = LANGUAGES.map((language) => language.id); + // `bash` is an alias of `shellscript`; only the latter belongs in the list. + expect(ids).toContain('shellscript'); + expect(ids).not.toContain('bash'); + }); +}); + +describe('spot-checked languages', () => { + for (const [id, sample] of Object.entries(SAMPLES)) { + test(`${id} resolves to several distinct roles`, async () => { + const result = await highlightToBindings(highlighter, sample, id); + + expect(result.bindings.length).toBeGreaterThan(0); + // A grammar the engine cannot run yields one flat unscoped range, so + // several distinct roles is the signal that it really tokenized. + const roles = new Set( + result.bindings.map((binding) => binding.variableName) + ); + expect(roles.size).toBeGreaterThan(1); + // Every color the probe theme produces must be a known Pierre role. + expect(result.unmatchedColors).toEqual([]); + }); + } +}); + +describe('every bundled language', () => { + test('loads and tokenizes under the JavaScript regex engine', async () => { + const failures: string[] = []; + + for (const language of LANGUAGES) { + try { + await highlightToBindings(highlighter, SWEEP_SAMPLE, language.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${language.id}: ${message.split('\n')[0] ?? ''}`); + } + } + + expect(failures).toEqual([]); + }, 120_000); +}); + +describe('highlightToBindings', () => { + test('accepts an alias as readily as its canonical id', async () => { + const code = 'echo "$HOME" # home'; + const viaAlias = await highlightToBindings(highlighter, code, 'bash'); + const viaId = await highlightToBindings(highlighter, code, 'shellscript'); + + expect(viaAlias.bindings.length).toBeGreaterThan(0); + expect(viaAlias.bindings).toEqual(viaId.bindings); + }); + + test('rejects a language that is not in the bundle', async () => { + let message = ''; + try { + await highlightToBindings(highlighter, 'x = 1', 'not-a-language'); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toBe('Unknown language: not-a-language'); + }); + + test('keeps offsets aligned with Figma soft line breaks', async () => { + // U+2028 is how Figma stores a soft line break. Swapping in a newline has to + // leave every offset where it was, so the two identical lines below must + // produce the same role at the same distance into each line. + const code = 'const a = 1;\u2028const b = 2;'; + const secondLineStart = code.indexOf('const', 1); + const result = await highlightToBindings(highlighter, code, 'typescript'); + + const firstDeclaration = result.bindings.find( + (binding) => binding.start === 0 + ); + expect(firstDeclaration?.end).toBe('const'.length); + expect( + result.bindings.find((binding) => binding.start === secondLineStart) + ).toEqual({ + start: secondLineStart, + end: secondLineStart + 'const'.length, + variableName: firstDeclaration?.variableName ?? '', + }); + }); + + test('separates the number from the semicolon on its very first call', async () => { + // The warm-up in ensureLanguage exists for exactly this: without it, a fresh + // highlighter's first tokenization returns `1;` as a single token. + const fresh = await createProbeHighlighter(); + const result = await highlightToBindings( + fresh, + 'const a = 1;', + 'typescript' + ); + const last = result.bindings[result.bindings.length - 1]; + + expect(last).toEqual({ + start: 11, + end: 12, + variableName: 'syntax/punctuation', + }); + }); + + test('produces ranges inside the layer text', async () => { + const code = SAMPLES.typescript ?? ''; + const result = await highlightToBindings(highlighter, code, 'typescript'); + + for (const binding of result.bindings) { + expect(binding.start).toBeGreaterThanOrEqual(0); + expect(binding.end).toBeLessThanOrEqual(code.length); + expect(binding.end).toBeGreaterThan(binding.start); + } + }); +}); diff --git a/packages/diffs-highlight/test/mapTokens.test.ts b/packages/diffs-highlight/test/mapTokens.test.ts new file mode 100644 index 000000000..f4c89037a --- /dev/null +++ b/packages/diffs-highlight/test/mapTokens.test.ts @@ -0,0 +1,165 @@ +/** + * Covers the two pure pieces the plugin's correctness rests on: recovering a + * role name from a resolved color (`roleIndex`) and turning tokens into the + * character ranges the sandbox binds (`mapTokens`). + */ +import { describe, expect, test } from 'bun:test'; + +import { type MappableToken, mapTokens } from '../src/shared/mapTokens'; +import { createRoleIndex } from '../src/shared/roleIndex'; + +/** `pierre-dark`'s `editor.foreground`, which is also `fg/base`. */ +const BASE_FOREGROUND = '#fafafa'; + +const index = createRoleIndex(BASE_FOREGROUND); + +/** Builds a single line of tokens with offsets derived from the contents. */ +function line( + tokens: { content: string; color?: string }[] +): MappableToken[][] { + let offset = 0; + return [ + tokens.map((token) => { + const mapped = { ...token, offset }; + offset += token.content.length; + return mapped; + }), + ]; +} + +describe('createRoleIndex', () => { + test('every syntax role resolves back to its own variable name', () => { + // The probe theme is only usable if its syntax colors are distinct, so this + // asserts the property the whole design depends on rather than a sample. + const syntaxNames = [...index.values()].filter((name) => + name.startsWith('syntax/') + ); + expect(syntaxNames.length).toBe(17); + expect(new Set(syntaxNames).size).toBe(syntaxNames.length); + }); + + test('syntax roles win over fg roles that share their color', () => { + // #737373 is both fg/fg3 and syntax/comment in pierre-dark. + expect(index.get('#737373')).toBe('syntax/comment'); + // #636363 is both fg/fg4 and syntax/punctuation. + expect(index.get('#636363')).toBe('syntax/punctuation'); + // #a3a3a3 is both fg/fg2 and syntax/parameter. + expect(index.get('#a3a3a3')).toBe('syntax/parameter'); + }); + + test('editor.foreground maps to fg/base, not the syntax/invalid it shares', () => { + expect(index.get(BASE_FOREGROUND)).toBe('fg/base'); + }); + + test('states are preferred over the ansi colors they share', () => { + // #08c0ef is states/info, syntax/operator, and four ansi cyans; syntax wins. + expect(index.get('#08c0ef')).toBe('syntax/operator'); + // #ff2e3f is states/danger and the ansi reds, with no syntax claim. + expect(index.get('#ff2e3f')).toBe('states/danger'); + }); + + test('background and border colors are not indexed as text roles', () => { + // #1d1d1d is only ever bg/inset and border/*, so nothing should claim it. + expect(index.get('#1d1d1d')).toBeUndefined(); + }); +}); + +describe('mapTokens', () => { + test('maps token colors to variable-bound ranges', () => { + const result = mapTokens( + line([ + { content: 'const', color: '#ff678d' }, + { content: ' ', color: '#fafafa' }, + { content: 'x', color: '#fafafa' }, + ]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 5, variableName: 'syntax/keyword' }, + { start: 6, end: 7, variableName: 'fg/base' }, + ]); + expect(result.unmatchedRanges).toBe(0); + }); + + test('merges touching ranges that resolve to the same variable', () => { + const result = mapTokens( + line([ + { content: '(', color: '#636363' }, + { content: ')', color: '#636363' }, + ]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 2, variableName: 'syntax/punctuation' }, + ]); + }); + + test('does not merge across a skipped whitespace token', () => { + const result = mapTokens( + line([ + { content: 'a', color: '#636363' }, + { content: ' ', color: '#636363' }, + { content: 'b', color: '#636363' }, + ]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 1, variableName: 'syntax/punctuation' }, + { start: 3, end: 4, variableName: 'syntax/punctuation' }, + ]); + }); + + test('does not merge across the newline between two lines', () => { + // Offsets skip the newline, so line-final and line-initial ranges of the + // same role must stay separate. + const result = mapTokens( + [ + [{ offset: 0, content: 'a', color: '#636363' }], + [{ offset: 2, content: 'b', color: '#636363' }], + ], + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 1, variableName: 'syntax/punctuation' }, + { start: 2, end: 3, variableName: 'syntax/punctuation' }, + ]); + }); + + test('counts unmatched colors and leaves their ranges out', () => { + const result = mapTokens( + line([ + { content: 'x', color: '#123456' }, + { content: 'y', color: '#123456' }, + { content: 'z', color: '#abcdef' }, + ]), + index + ); + + expect(result.bindings).toEqual([]); + expect(result.unmatchedRanges).toBe(3); + expect(result.unmatchedColors).toEqual(['#123456', '#abcdef']); + }); + + test('treats a token with no color as unmatched', () => { + const result = mapTokens(line([{ content: 'x' }]), index); + + expect(result.bindings).toEqual([]); + expect(result.unmatchedRanges).toBe(1); + expect(result.unmatchedColors).toEqual([]); + }); + + test('ignores case and a fully opaque alpha suffix on token colors', () => { + const result = mapTokens( + line([{ content: 'const', color: '#FF678DFF' }]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 5, variableName: 'syntax/keyword' }, + ]); + }); +}); diff --git a/packages/diffs-highlight/tsconfig.json b/packages/diffs-highlight/tsconfig.json new file mode 100644 index 000000000..23d16d387 --- /dev/null +++ b/packages/diffs-highlight/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.options.json", + "include": ["src/**/*.ts", "test/**/*.ts", "vite.config.*.ts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "composite": false, + "noEmit": true, + "emitDeclarationOnly": false, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["bun", "@figma/plugin-typings"], + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true + } +} diff --git a/packages/diffs-highlight/ui.html b/packages/diffs-highlight/ui.html new file mode 100644 index 000000000..0eb5bdbc6 --- /dev/null +++ b/packages/diffs-highlight/ui.html @@ -0,0 +1,38 @@ + + + + + Diffs Highlight + + + +
+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +

+ +
+ + +
+
+ + + + diff --git a/packages/diffs-highlight/vite.config.code.ts b/packages/diffs-highlight/vite.config.code.ts new file mode 100644 index 000000000..ca7d7ab57 --- /dev/null +++ b/packages/diffs-highlight/vite.config.code.ts @@ -0,0 +1,25 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +/** + * Builds the sandbox realm into a single `dist/code.js`. + * + * Figma evaluates that file in a plain JavaScript scope with no module loader + * and no DOM, so the output has to be one IIFE with nothing split out and no + * imports left in it (`lib` mode disables code splitting). This pass runs first + * and owns clearing `dist`; the UI pass writes into the same directory after it. + */ +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2017', + minify: false, + lib: { + entry: resolve(import.meta.dirname, 'src/sandbox/code.ts'), + formats: ['iife'], + name: 'diffsHighlight', + fileName: () => 'code.js', + }, + }, +}); diff --git a/packages/diffs-highlight/vite.config.ui.ts b/packages/diffs-highlight/vite.config.ui.ts new file mode 100644 index 000000000..68aabf8a3 --- /dev/null +++ b/packages/diffs-highlight/vite.config.ui.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +/** + * Builds the UI realm into a single `dist/ui.html`. + * + * Figma hands the UI document to the iframe as a string and loads nothing else, + * so every script and stylesheet has to be inlined, which is what + * `viteSingleFile` does. Vite names an HTML output after its input, so the entry + * is `ui.html` rather than the conventional `index.html` — that is what makes the + * output land at the `dist/ui.html` manifest.json points at. + * + * This pass runs after the sandbox pass and must not wipe its output, hence + * `emptyOutDir: false`. + */ +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + outDir: 'dist', + emptyOutDir: false, + target: 'es2017', + minify: false, + rollupOptions: { + input: 'ui.html', + }, + }, +}); diff --git a/packages/theme/CONTRIBUTING.md b/packages/theme/CONTRIBUTING.md index a32b4abe7..18bcb50ac 100644 --- a/packages/theme/CONTRIBUTING.md +++ b/packages/theme/CONTRIBUTING.md @@ -107,6 +107,13 @@ not in any palette scale fails the build. That is deliberate: it catches roles drifting away from the palette. If a role genuinely needs a standalone literal, add its hex to `UNALIASED_ROLE_COLORS`. +One consumer reads these files directly rather than importing them into Figma: +the [Diffs Highlight](../diffs-highlight/README.md) plugin builds its +color-to-role lookup from `figma/semantic/dark.json`, using the hexes and +variable names side by side. Renaming a role group or changing a `dark` value +changes what that plugin binds, so run `moonx diffs-highlight:test` when either +moves. + ## Scripts | Script | Description | diff --git a/packages/theme/README.md b/packages/theme/README.md index 81489d905..b5cb70bf1 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -56,6 +56,9 @@ Usage: The Vibrant variants are not exported: Figma imports sRGB and HSL only. + To syntax highlight code in a Figma text layer with these variables, see + the Diffs Highlight plugin in packages/diffs-highlight + ``` ## Agent skill diff --git a/packages/theme/package.json b/packages/theme/package.json index 4c91033f5..daca91782 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -26,6 +26,7 @@ "publisher": "pierrecomputer", "files": [ "dist", + "figma", "themes", "icon.png", "LICENSE", @@ -89,7 +90,8 @@ "import": "./dist/pierre-dark-vibrant.mjs", "default": "./dist/pierre-dark-vibrant.mjs" }, - "./themes/*": "./themes/*" + "./themes/*": "./themes/*", + "./figma/*": "./figma/*" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9f6ceda5..b0298828f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@arethetypeswrong/core': specifier: 0.18.2 version: 0.18.2 + '@figma/plugin-typings': + specifier: 1.131.0 + version: 1.131.0 '@icons-pack/react-simple-icons': specifier: 13.8.0 version: 13.8.0 @@ -222,6 +225,9 @@ catalogs: vite: specifier: 8.1.0 version: 8.1.0 + vite-plugin-singlefile: + specifier: 2.3.3 + version: 2.3.3 zod: specifier: 4.1.11 version: 4.1.11 @@ -229,6 +235,7 @@ catalogs: overrides: '@shikijs/core': 4.2.0 '@shikijs/engine-javascript': 4.2.0 + '@shikijs/langs': 4.2.0 '@shikijs/themes': 4.2.0 '@shikijs/transformers': 4.2.0 '@types/react': 19.2.7 @@ -663,6 +670,40 @@ importers: specifier: 'catalog:' version: 8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + packages/diffs-highlight: + dependencies: + '@pierre/theme': + specifier: workspace:* + version: link:../theme + '@shikijs/core': + specifier: 4.2.0 + version: 4.2.0 + '@shikijs/engine-javascript': + specifier: 4.2.0 + version: 4.2.0 + '@shikijs/langs': + specifier: 4.2.0 + version: 4.2.0 + shiki: + specifier: 4.2.0 + version: 4.2.0 + devDependencies: + '@figma/plugin-typings': + specifier: 'catalog:' + version: 1.131.0 + '@types/bun': + specifier: 'catalog:' + version: 1.3.14 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + vite-plugin-singlefile: + specifier: 'catalog:' + version: 2.3.3(vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + packages/path-store: devDependencies: '@pierre/tree-test-data': @@ -1145,6 +1186,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@figma/plugin-typings@1.131.0': + resolution: {integrity: sha512-XBTPNGc65myupitMaeemyiF/ir1jn/EXXTdoGUCEVCfVB6KPQUy+gbfHbnC+QQiHxdw/Naikdx+9v/u4d49gfA==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -6067,6 +6111,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6697,6 +6751,8 @@ snapshots: tslib: 2.8.1 optional: true + '@figma/plugin-typings@1.131.0': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -11827,6 +11883,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-singlefile@2.3.3(vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + micromatch: 4.0.8 + vite: 8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 75097528a..abbe6f0f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,6 +44,7 @@ minimumReleaseAgeExclude: catalog: '@arethetypeswrong/core': '0.18.2' + '@figma/plugin-typings': '1.131.0' '@icons-pack/react-simple-icons': '13.8.0' '@moonrepo/cli': '2.3.3' '@octokit/app': '16.1.1' @@ -70,6 +71,7 @@ catalog: '@radix-ui/react-use-controllable-state': '1.2.2' '@shikijs/core': '4.2.0' '@shikijs/engine-javascript': '4.2.0' + '@shikijs/langs': '4.2.0' '@shikijs/themes': '4.2.0' '@shikijs/transformers': '4.2.0' '@tailwindcss/postcss': '4.1.13' @@ -133,11 +135,13 @@ catalog: 'typescript': '6.0.3' 'unist-util-visit': '5.0.0' 'vite': '8.1.0' + 'vite-plugin-singlefile': '2.3.3' 'zod': '4.1.11' overrides: '@shikijs/core': 'catalog:' '@shikijs/engine-javascript': 'catalog:' + '@shikijs/langs': 'catalog:' '@shikijs/themes': 'catalog:' '@shikijs/transformers': 'catalog:' '@types/react': 'catalog:' diff --git a/tsconfig.json b/tsconfig.json index 628982f12..0a554da33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,9 @@ { "path": "packages/diffs/tsconfig.json" }, + { + "path": "packages/diffs-highlight/tsconfig.json" + }, { "path": "packages/path-store/tsconfig.json" },