diff --git a/.env.template b/.env.template index d7b9fd6adc..748c2f4c06 100644 --- a/.env.template +++ b/.env.template @@ -4,4 +4,14 @@ PUBLIC_FIREBASE_AUTH_DOMAIN = wordplay-dev.firebaseapp.com PUBLIC_FIREBASE_PROJECT_ID = demo-wordplay PUBLIC_FIREBASE_MESSAGING_SENDER_ID = 123456789123 PUBLIC_FIREBASE_APP_ID = 1:123456789123:web:1234567890123456789012 -PUBLIC_FIREBASE_MEASUREMENT_ID = G-FAKEFAKEFA \ No newline at end of file +PUBLIC_FIREBASE_MEASUREMENT_ID = G-FAKEFAKEFA + +# Optional: Anthropic API key for the Claude translation backend used by the +# offline locales CLI (npm run locales-translate). Get one at console.anthropic.com. +# Without it, translation falls back to Google. +# +# Do NOT put it here or in .env — `npm run env` regenerates .env from +# .env. and would wipe it. Instead create a gitignored .env.local +# (which the locales CLI also loads, and no script overwrites): +# ANTHROPIC_API_KEY = sk-ant-... +# or export it in your shell. \ No newline at end of file diff --git a/.github/workflows/translate.yml b/.github/workflows/translate.yml index a2a2f9180f..139ee03e0c 100644 --- a/.github/workflows/translate.yml +++ b/.github/workflows/translate.yml @@ -37,9 +37,11 @@ jobs: - run: npm run locales-fix name: Repair locale structure - # translate -> npm run locales-translate; override -> npm run override - - run: npm run ${{ inputs.mode == 'override' && 'override' || 'locales-translate' }} + # translate -> npm run locales-translate; override -> npm run locales-override + - run: npm run ${{ inputs.mode == 'override' && 'locales-override' || 'locales-translate' }} name: Generate machine translations + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - name: Commit translations to the PR branch run: | diff --git a/.gitignore b/.gitignore index 55bab0c895..f48034258b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .DS_Store **/.DS_Store .env +.env.local .env.wordplay-dev .env.wordplay-prod .env.demo diff --git a/CHANGELOG.md b/CHANGELOG.md index 036feca4ee..deef565d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,29 @@ We'll note all notable changes in this file, including bug fixes, enhancements, Dates are in `YYYY-MM-DD` format and versions are in [semantic versioning](http://semver.org/) format. These notes are publicly posted in [production](https://wordplay.dev/updates), so we write them to an audience of teachers and youth. -## 0.25.0 - 2026-06-22 +## 0.25.0 - 2026-06-27 ### Added - 🌐 When you pick more than one language for Wordplay, we now show the interface in all of them at once. Your first language appears full size, and each other language follows it a little smaller and lighter — in labels, tooltips, the [Guide](https://wordplay.dev/guide), the tutorial, and even error messages. -- 📕 Added the first sentence of project documentation to the project previews (#897). -- 📐 We added a writing layout setting, so your program's output can read top to bottom (vertical) instead of left to right. It follows your language automatically, or you can pick one (#220). +- 🌐 We added a writing layout setting, so your program's output can read top to bottom (vertical) instead of left to right. It follows your language automatically, or you can pick one (#220). - 📖 When you look at a concept in the [Guide](https://wordplay.dev/guide), we now show a link to the lesson that teaches it and a list of how-to's that use it, so it's easier to learn more (#769). +- 📖 We added a glossary to the [Guide](https://wordplay.dev/guide) that explains the key words and ideas used across Wordplay. You can search it, or hover any term in the docs to see what it means (#780). +- 📕 Added the first sentence of project documentation to the project previews (#897). +- 📝 When you help translate Wordplay, we now point out text that may be hard to read and suggest when a key word should link to the glossary, so translations stay clear and easy to read (#460). ### Changed - 🌐 We made Wordplay work much better for right-to-left languages like Arabic and Hebrew. Menus, buttons, text, and the things your programs show on stage now flow from right to left to match how you read. +- 🌐 We now use Claude to translate Wordplay into other languages. The wording is clearer, key words stay consistent, and the small example programs in the docs get translated too, not just the words around them. +- 🌐 We made translating your own project into another language better: names, text, and documentation now translate together, with clearer wording. ### Fixed - 📕 We re-organized the how-to editing and viewing panels to offer much more width for viewing. - 🖱️ We fixed the code examples in the [Guide](https://wordplay.dev/guide) so you can drag pieces of them into your project. -## 0.24.0 - 2026-06-18 +## 0.24.0 - 2026-06-20 This abbreviated week we fleshed out the multilingual and text processing part of the programming language. diff --git a/LANGUAGE.md b/LANGUAGE.md index 1c78cb289d..481edbd73e 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -127,6 +127,10 @@ An external example embeds code from another programming language for documentat A concept link references a documented concept (e.g. `@Phrase`). A concept and one of its members (a property, function, or other subconcept) are separated by `.`, mirroring property access (e.g. `@Color.random`, `@Phrase.size`). A `/` separator instead references something that is not a concept: a UI element (`@UI/toolbar`), a how-to (`@How/...`), or a creator-defined character (`@username/charactername`). The separator must be followed by a name, so a sentence-ending period after a link (e.g. `see @Color.`) is left as punctuation. +A bare lowercase `@term` (no separator) references a **glossary term** rather than a documented concept (e.g. `@value`, `@expression`). Resolution is by id: an `@id` resolves to a concept when the id is a concept's, otherwise to a glossary term — so concepts (capitalized ids like `@Phrase`) and glossary terms (lowercase ids) share the `@` reference syntax. + +A `$` mention substitutes a named template input (e.g. `$expected`), with `$?`/`$!` as special placeholders. `$` is only for input substitution; documented things — concepts and glossary terms alike — are referenced with `@` (above). + > words → _any sequence of characters between `markup` that aren't markup delimeters above_ Compound data structures have several delimiters: diff --git a/functions/package-lock.json b/functions/package-lock.json index fcca1b31d2..727eee68f2 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -9,6 +9,7 @@ "shared-types" ], "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@google-cloud/translate": "^9", "@supercharge/promise-pool": "^3", "firebase-admin": "^13", @@ -27,6 +28,27 @@ "node": "^22.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.106.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.106.0.tgz", + "integrity": "sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -526,6 +548,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1885,6 +1916,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@supercharge/promise-pool": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@supercharge/promise-pool/-/promise-pool-3.3.0.tgz", @@ -3762,6 +3799,12 @@ "license": "MIT", "peer": true }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -5574,6 +5617,19 @@ "license": "MIT", "peer": true }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6945,6 +7001,16 @@ "node": ">=10" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7325,6 +7391,12 @@ "license": "MIT", "optional": true }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-deepmerge": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-2.0.7.tgz", diff --git a/functions/package.json b/functions/package.json index 4201447250..ca4dcfe5fd 100644 --- a/functions/package.json +++ b/functions/package.json @@ -17,6 +17,7 @@ }, "main": "lib/index.js", "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@google-cloud/translate": "^9", "@supercharge/promise-pool": "^3", "firebase-admin": "^13", diff --git a/functions/src/analyzeLocalization.ts b/functions/src/analyzeLocalization.ts new file mode 100644 index 0000000000..25a742bbb0 --- /dev/null +++ b/functions/src/analyzeLocalization.ts @@ -0,0 +1,234 @@ +import Anthropic from '@anthropic-ai/sdk'; +import type { CallableRequest } from 'firebase-functions/v2/https'; +import type { + AnalyzeLocalizationInputs, + AnalyzeLocalizationOutput, + GlossaryWord, + LiteralTermFinding, + StringAnalysis, +} from 'shared-types'; +import { PLAIN_LANGUAGE_GUIDANCE } from './shared/readingLevel.js'; + +const MODEL = 'claude-opus-4-8'; +const MAX_TOKENS = 16000; +/** Strings per request, to bound tokens on large bundles. */ +const CHUNK_SIZE = 40; + +const SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + results: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + complex: { type: 'boolean' }, + readingLevelNote: { type: 'string' }, + literalTerms: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + term: { type: 'string' }, + id: { type: 'string' }, + }, + required: ['term', 'id'], + }, + }, + backTranslation: { type: 'string' }, + }, + required: [ + 'complex', + 'readingLevelNote', + 'literalTerms', + 'backTranslation', + ], + }, + }, + }, + required: ['results'], +}; + +type RawTerm = { term: string; id: string }; +type RawResult = { + complex: boolean; + readingLevelNote: string; + literalTerms: RawTerm[]; + backTranslation: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isRawTerm(value: unknown): value is RawTerm { + return ( + isRecord(value) && + typeof value.term === 'string' && + typeof value.id === 'string' + ); +} + +function isRawResult(value: unknown): value is RawResult { + return ( + isRecord(value) && + typeof value.complex === 'boolean' && + typeof value.readingLevelNote === 'string' && + typeof value.backTranslation === 'string' && + Array.isArray(value.literalTerms) && + value.literalTerms.every(isRawTerm) + ); +} + +/** Parse the structured output, returning the results only when it's an array + * of the expected length and shape (so the caller degrades gracefully). */ +function parse(text: string, expected: number): RawResult[] | null { + let data: unknown; + try { + data = JSON.parse(text); + } catch { + return null; + } + if (!isRecord(data) || !Array.isArray(data.results)) return null; + const results = data.results; + if (results.length !== expected) return null; + return results.every(isRawResult) ? results : null; +} + +/** Replace the first whole-word occurrence of `term` with `$id` so the PR can + * show a concrete symbolization suggestion. Falls back to the original text. */ +function buildSuggestion(text: string, term: string, id: string): string { + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp( + `(? 0 + ? `\n\nGlossary terms (id — word). A term is "literal" when its word appears as plain prose AND genuinely refers to this concept (not an unrelated everyday use); it should instead be a symbolic $id reference:\n${glossary + .map((g) => `- ${g.id} — ${g.word}`) + .join('\n')}` + : ''; + return `You are a localization quality reviewer for Wordplay, a programming language for young, multilingual learners. The strings under review are in ${locale}. + +For each string, return: +- "complex": true if the text requires reading ability beyond a lower-secondary level — i.e., it breaks any of the plain-language principles below; false if it follows them. +- "readingLevelNote": one short English sentence naming the specific issue (e.g. a long sentence with several ideas, an unusual or undefined word, more than one topic in a paragraph, an unexplained abbreviation, passive voice, or an idiom/metaphor) and how to simplify it; "" when the text follows the principles. +- "literalTerms": each genuine literal glossary term as { "term": , "id": }; [] if none. +${backTranslate ? `- "backTranslation": a faithful, natural ${sourceLocale} translation of the string, for reviewers who don't read ${locale}.` : `- "backTranslation": "".`} + +Do not rewrite or change the strings; only assess them. + +${PLAIN_LANGUAGE_GUIDANCE}${glossaryList} + +Return JSON {"results":[...]} with exactly one entry per input string, in the same order.`; +} + +/** + * Analyze locale strings for reading level (#460) and glossary symbolization, + * optionally back-translating. Shared by the `analyzeLocalization` callable + * (in-app) and `submitLocalization` (PR review). Returns one analysis per + * input string, or null on any failure (graceful degrade is the contract). + */ +export async function analyze( + input: AnalyzeLocalizationInputs, +): Promise { + const { locale, sourceLocale, strings, glossary, backTranslate } = input; + if (!Array.isArray(strings) || strings.length === 0) return []; + + try { + const client = new Anthropic(); + const system = buildSystem( + locale, + sourceLocale, + glossary, + backTranslate, + ); + const out: StringAnalysis[] = []; + + for (let i = 0; i < strings.length; i += CHUNK_SIZE) { + const chunk = strings.slice(i, i + CHUNK_SIZE); + const response = await client.messages.create({ + model: MODEL, + max_tokens: MAX_TOKENS, + system: [ + { + type: 'text', + text: system, + cache_control: { type: 'ephemeral' }, + }, + ], + output_config: { + format: { type: 'json_schema', schema: SCHEMA }, + }, + messages: [ + { + role: 'user', + content: `Analyze these ${chunk.length} strings. Return JSON {"results":[...]} with exactly ${chunk.length} entries, in order.\n\n${JSON.stringify( + chunk.map((s) => s.text), + )}`, + }, + ], + }); + + if ( + response.stop_reason === 'refusal' || + response.stop_reason === 'max_tokens' + ) + return null; + + const block = response.content.find((b) => b.type === 'text'); + const parsed = + block !== undefined ? parse(block.text, chunk.length) : null; + if (parsed === null) return null; + + parsed.forEach((r, j) => { + const literalTerms: LiteralTermFinding[] = r.literalTerms.map( + (t) => ({ + term: t.term, + id: t.id, + suggestion: buildSuggestion( + chunk[j].text, + t.term, + t.id, + ), + }), + ); + out.push({ + key: chunk[j].key, + complex: r.complex, + readingLevelNote: r.readingLevelNote, + literalTerms, + ...(backTranslate + ? { backTranslation: r.backTranslation } + : {}), + }); + }); + } + + return out; + } catch (e) { + console.error(e); + return null; + } +} + +/** Callable wrapper for the in-app reading-level check. Requires the + * ANTHROPIC_API_KEY secret bound to the function. */ +export default async function analyzeLocalization( + request: CallableRequest, +): Promise { + return analyze(request.data); +} diff --git a/functions/src/getLLMTranslations.ts b/functions/src/getLLMTranslations.ts new file mode 100644 index 0000000000..91a41b43e4 --- /dev/null +++ b/functions/src/getLLMTranslations.ts @@ -0,0 +1,103 @@ +import Anthropic from '@anthropic-ai/sdk'; +import type { CallableRequest } from 'firebase-functions/v2/https'; +import type { GetLLMTranslationsInputs } from 'shared-types'; +import { PLAIN_LANGUAGE_GUIDANCE } from './shared/readingLevel.js'; + +const MODEL = 'claude-opus-4-8'; +const MAX_TOKENS = 16000; + +const SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + translations: { type: 'array', items: { type: 'string' } }, + }, + required: ['translations'], +}; + +function hasTranslations(data: unknown): data is { translations: unknown } { + return typeof data === 'object' && data !== null && 'translations' in data; +} + +/** Parse and validate the structured output; null unless it's a string array of + * the expected length (so the client keeps the source on any anomaly). */ +function parse(text: string, expected: number): string[] | null { + let data: unknown; + try { + data = JSON.parse(text); + } catch { + return null; + } + if (!hasTranslations(data)) return null; + const translations = data.translations; + if (!Array.isArray(translations)) return null; + if (!translations.every((t): t is string => typeof t === 'string')) + return null; + return translations.length === expected ? translations : null; +} + +function buildSystem( + from: string, + to: string, + context: GetLLMTranslationsInputs['projectContext'], +): string { + const names = context?.names?.length + ? `\nOther names in this project (for domain context — do not translate these, just use them to choose fitting words): ${context.names.join(', ')}` + : ''; + const docs = context?.docs?.length + ? `\nWhat this project is about: ${context.docs.join(' ').slice(0, 800)}` + : ''; + return `You are translating the contents of a Wordplay creative coding project from ${from} to ${to}. + +Rules: +- Translate the natural-language text only. Preserve Wordplay markup exactly: keep every @Concept reference, every $name reference, and every \\code\\ block verbatim — never translate or alter them. +- A short standalone word is a code name; translate it to a fitting single word in the target language (the app converts it to a valid identifier). +- Keep blank lines (paragraph breaks) between paragraphs, and keep words separated by a space; within a paragraph you may reflow text. +- Write for young, multilingual learners. + +${PLAIN_LANGUAGE_GUIDANCE}${names}${docs}`; +} + +/** + * Translate a project's strings with Claude, server-side. Mirrors the + * string-translation role of [getTranslations](functions/src/getTranslations.ts) + * (the client's translateProjectContent does the AST work), with a + * markup-preserving prompt and optional project context for quality. Requires + * the ANTHROPIC_API_KEY secret bound to the function. + */ +export default async function getLLMTranslations( + request: CallableRequest, +): Promise { + const { from, to, texts, projectContext } = request.data; + if (!Array.isArray(texts) || texts.length === 0) return []; + + try { + const client = new Anthropic(); + const response = await client.messages.create({ + model: MODEL, + max_tokens: MAX_TOKENS, + system: buildSystem(from, to, projectContext), + output_config: { format: { type: 'json_schema', schema: SCHEMA } }, + messages: [ + { + role: 'user', + content: `Translate these ${texts.length} strings from ${from} to ${to}. Return JSON {"translations":[...]} with exactly ${texts.length} entries, in the same order.\n\n${JSON.stringify(texts)}`, + }, + ], + }); + + if ( + response.stop_reason === 'refusal' || + response.stop_reason === 'max_tokens' + ) + return null; + + const textBlock = response.content.find((b) => b.type === 'text'); + return textBlock !== undefined + ? parse(textBlock.text, texts.length) + : null; + } catch (e) { + console.error(e); + return null; + } +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 2365314978..c4cfac83df 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -5,12 +5,15 @@ import { onDocumentWritten, } from 'firebase-functions/v2/firestore'; import { onCall, onRequest } from 'firebase-functions/v2/https'; +import { defineSecret } from 'firebase-functions/params'; import { onSchedule } from 'firebase-functions/v2/scheduler'; import type { + AnalyzeLocalizationInputs, CreateClassInputs, CreateClassOutput, EmailExistsInputs, EmailExistsOutput, + GetLLMTranslationsInputs, } from 'shared-types'; import compactProjectUpdatesHandler from './compactProjectUpdates.js'; @@ -21,6 +24,8 @@ import getCreatorsHandler from './getCreators.js'; import getTranslationsHandler, { type GetTranslationsInputs, } from './getTranslations.js'; +import getLLMTranslationsHandler from './getLLMTranslations.js'; +import analyzeLocalizationHandler from './analyzeLocalization.js'; import getWebpageHandler from './getWebpage.js'; import postFeedbackHandler from './postFeedback.js'; import purgeArchivedProjectsHandler from './purgeArchivedProjects.js'; @@ -62,6 +67,31 @@ export const getTranslations = onCall( getTranslationsHandler, ); +/** The Anthropic API key, for the Claude-backed project translation. Set with + * `firebase functions:secrets:set ANTHROPIC_API_KEY` (and, for the emulator, + * in the gitignored functions/.env.local). */ +const anthropicKey = defineSecret('ANTHROPIC_API_KEY'); + +/** + * Like getTranslations, but uses Claude for higher-quality, context-aware + * project translation. The Google getTranslations remains registered as a + * fallback. The SDK reads ANTHROPIC_API_KEY from the bound secret. + */ +export const getLLMTranslations = onCall( + { ...cors, secrets: [anthropicKey] }, + getLLMTranslationsHandler, +); + +/** + * Analyze locale strings for reading level (#460) and glossary symbolization. + * Used by the in-app localization workspace's "check reading level" action; the + * same core also runs inside submitLocalization for PR review. + */ +export const analyzeLocalization = onCall( + { ...cors, secrets: [anthropicKey] }, + analyzeLocalizationHandler, +); + /** Given a URL that should refer to an HTML document, sends a GET request to the URL to try to get the document's text. */ export const getWebpage = onRequest(cors, getWebpageHandler); diff --git a/functions/src/shared/index.ts b/functions/src/shared/index.ts index abbde003b3..d6eab2d92e 100644 --- a/functions/src/shared/index.ts +++ b/functions/src/shared/index.ts @@ -2,6 +2,67 @@ export type EmailExistsInputs = string[]; export type EmailExistsOutput = Record | undefined; +// FUNCTION getLLMTranslations +/** Translate a project's strings with an LLM (Claude). The client's + * translateProjectContent handles the AST (names, docs, text) and sends the + * unique strings here; project context improves domain-appropriate word + * choices. Returns translations 1:1 with `texts`, or null on failure. */ +export type GetLLMTranslationsInputs = { + /** Source locale string, e.g. 'en-US'. */ + from: string; + /** Target locale string, e.g. 'es-MX'. */ + to: string; + /** The unique source strings to translate, in order. */ + texts: string[]; + /** Optional context for quality: a sample of the project's other names and + * docs so translations fit the project's domain. */ + projectContext?: { names?: string[]; docs?: string[] }; +}; +export type GetLLMTranslationsOutput = string[] | null; + +// FUNCTION analyzeLocalization +/** A glossary id + its localized word, for the literal-term check. */ +export type GlossaryWord = { id: string; word: string }; +/** A glossary term found written as literal prose, with a one-click fix that + * swaps the occurrence for a symbolic `$id` reference. (Shared shape; the + * client computes these live, the server returns them for PR review.) */ +export type LiteralTermFinding = { + term: string; + id: string; + suggestion: string; +}; +/** Per-string quality analysis: reading level (#460) and glossary symbolization, + * plus an optional English back-translation for PR review. */ +export type StringAnalysis = { + /** The locale path of the analyzed string. */ + key: string; + /** True if the string reads above a ~6th-grade level. */ + complex: boolean; + /** One short English sentence on why / how to simplify, or '' if fine. */ + readingLevelNote: string; + /** Genuine literal-glossary-term findings (LLM-judged). */ + literalTerms: LiteralTermFinding[]; + /** English back-translation, present only when requested (PR review). */ + backTranslation?: string; +}; +/** Analyze locale strings for reading level + glossary symbolization. The + * caller supplies the locale's glossary words (submitLocalization: from the + * fetched locale JSON) so the core never imports from src/. Returns one + * analysis per input string, or null on failure. */ +export type AnalyzeLocalizationInputs = { + /** The locale being analyzed, e.g. 'es-MX'. */ + locale: string; + /** The locale to back-translate into, e.g. 'en-US'. */ + sourceLocale: string; + /** The strings to analyze, in order. */ + strings: { key: string; text: string }[]; + /** The locale's glossary words for the literal-term check (empty to skip). */ + glossary: GlossaryWord[]; + /** Whether to also produce an English back-translation per string. */ + backTranslate: boolean; +}; +export type AnalyzeLocalizationOutput = StringAnalysis[] | null; + export type CreateClassInputs = { /** The uid of the teacher that should be the curator of the gallery created. */ teacher: string; diff --git a/functions/src/shared/readingLevel.ts b/functions/src/shared/readingLevel.ts new file mode 100644 index 0000000000..916392de10 --- /dev/null +++ b/functions/src/shared/readingLevel.ts @@ -0,0 +1,26 @@ +/** + * Shared plain-language guidance for translation and reading-level analysis. + * + * Based on WCAG 2.2 Success Criterion 3.1.5, Reading Level (Level AAA): + * https://www.w3.org/WAI/WCAG22/Understanding/reading-level.html + * + * We target a *lower-secondary* reading level via concrete plain-language + * principles rather than a country-specific grade level — grade levels are + * culturally specific and don't suit Wordplay's multilingual audience. The + * principles below are the WCAG plain-language techniques: short single-idea + * sentences, common words (unusual ones are defined by the glossary), + * single-topic paragraphs, explained abbreviations, active voice, direct action + * verbs, and no idioms or metaphors. + * + * Keep in sync with src/locale/readingLevel.ts (the functions↔src wall prevents + * a single shared module). + */ +export const PLAIN_LANGUAGE_GUIDANCE = `Write at a lower-secondary reading level, following WCAG 2.2 plain-language guidance (Success Criterion 3.1.5). Do not use country-specific grade levels. Apply these plain-language principles: +- Keep sentences short, each expressing a single idea. +- Use common, everyday words; avoid rare, technical, or unusual words unless they are defined (key terms are defined in the glossary). +- Keep each paragraph to a single topic. +- Spell out or explain abbreviations and acronyms. +- Use the active voice and direct action verbs. +- Avoid idioms, metaphors, and figurative language, which often do not translate across cultures. + +Proper names and titles are exempt (per WCAG SC 3.1.5): do not count them as unusual or complex words.`; diff --git a/functions/src/submitLocalization.ts b/functions/src/submitLocalization.ts index 3e7bf39e78..e8d168f7d1 100644 --- a/functions/src/submitLocalization.ts +++ b/functions/src/submitLocalization.ts @@ -24,9 +24,10 @@ * existing contributors PR function). */ -import Translate from '@google-cloud/translate'; import { HttpsError, onCall } from 'firebase-functions/v2/https'; import prettier from 'prettier'; +import type { StringAnalysis } from 'shared-types'; +import { analyze } from './analyzeLocalization.js'; const REPO_OWNER = 'wordplaydev'; const REPO_NAME = 'wordplay'; @@ -81,10 +82,7 @@ function parseOverrideKey(key: string): { /** Walk a record along dotted segments and return the leaf value, or undefined * if any step fails. */ -function resolveAtPath( - root: Record, - path: string, -): unknown { +function resolveAtPath(root: Record, path: string): unknown { let node: unknown = root; for (const seg of path.split('.').filter((s) => s.length > 0)) { if (typeof node !== 'object' || node === null) return undefined; @@ -126,7 +124,9 @@ function setAtPath( let node: unknown = root; for (let i = 0; i < segments.length - 1; i++) { if (typeof node !== 'object' || node === null) - throw new Error(`Cannot descend into ${segments.slice(0, i).join('.')}`); + throw new Error( + `Cannot descend into ${segments.slice(0, i).join('.')}`, + ); node = (node as Record)[segments[i]]; } if (typeof node !== 'object' || node === null) @@ -269,27 +269,23 @@ async function createPullRequest( // Backtranslation // --------------------------------------------------------------------------- -/** Translate a batch of strings via Google Cloud Translate. Returns an array - * of translations aligned with the input. Falls back to empty strings if the - * API fails — backtranslation is a courtesy for reviewers, not load-bearing. */ -async function backtranslate( - text: string[], - fromLocale: string, - toLocale: string, -): Promise { - if (text.length === 0) return []; - if (fromLocale === toLocale) return text; - try { - const translator = new Translate.v2.Translate(); - const [translations] = await translator.translate(text, { - from: fromLocale, - to: toLocale, - }); - return Array.isArray(translations) ? translations : [translations]; - } catch (e) { - console.error('Backtranslation failed', e); - return text.map(() => ''); +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Read a locale JSON's glossary words ({ id: { word, definition } }) for the + * literal-term check, tolerating any shape (returns [] if absent/malformed). */ +function extractGlossaryWords( + json: Record | undefined, +): { id: string; word: string }[] { + if (json === undefined || !isRecord(json.glossary)) return []; + const out: { id: string; word: string }[] = []; + for (const id of Object.keys(json.glossary)) { + const entry = json.glossary[id]; + if (isRecord(entry) && typeof entry.word === 'string') + out.push({ id, word: entry.word }); } + return out; } // --------------------------------------------------------------------------- @@ -309,6 +305,22 @@ function truncate(text: string, max = 200): string { return text.length <= max ? text : text.slice(0, max - 1) + '…'; } +/** A reviewer-facing quality note for one string: a reading-level flag and any + * glossary terms that should become symbolic `$term` references. */ +function qualityCell(analysis: StringAnalysis | undefined): string { + if (analysis === undefined) return ''; + const parts: string[] = []; + if (analysis.complex) + parts.push( + `⚠ reading level${analysis.readingLevelNote ? `: ${analysis.readingLevelNote}` : ''}`, + ); + if (analysis.literalTerms.length > 0) + parts.push( + `use ${analysis.literalTerms.map((t) => `$${t.id}`).join(', ')}`, + ); + return parts.length > 0 ? escapeCell(truncate(parts.join('; '), 240)) : '✓'; +} + function composePrBody(args: { contributor: { uid: string; name: string | null; email: string | null }; locale: string; @@ -318,6 +330,7 @@ function composePrBody(args: { sourceEnglish: string; edited: string; backtranslation: string; + analysis?: StringAnalysis; }[]; }): string { const { contributor, locale, description, rows } = args; @@ -326,6 +339,18 @@ function composePrBody(args: { contributor.email ?? `user ${contributor.uid.slice(0, 8)}`; + // Only show quality columns/summary when analysis succeeded (graceful + // degrade: a failed analysis renders exactly the original table). + const analyses = rows + .map((r) => r.analysis) + .filter((a): a is StringAnalysis => a !== undefined); + const hasAnalysis = analyses.length > 0; + const complexCount = analyses.filter((a) => a.complex).length; + const literalCount = analyses.reduce( + (n, a) => n + a.literalTerms.length, + 0, + ); + return [ `## Submission`, ``, @@ -343,18 +368,31 @@ function composePrBody(args: { .join('\n') : `_(no rationale provided)_`, ``, + ...(hasAnalysis + ? [ + `### Quality`, + ``, + `- Strings above ~6th-grade reading level: **${complexCount}**`, + `- Glossary terms that could be symbolic \`$term\` references: **${literalCount}**`, + ``, + ] + : []), `### Edits`, ``, - `| Key | Original English | Edited (\`${locale}\`) | Backtranslation to English |`, - `|-----|------------------|------------------------|----------------------------|`, - ...rows.map( - (r) => - `| \`${escapeCell(r.key)}\` | ${escapeCell( - truncate(r.sourceEnglish), - )} | ${escapeCell(truncate(r.edited))} | ${escapeCell( - truncate(r.backtranslation), - )} |`, - ), + hasAnalysis + ? `| Key | Original English | Edited (\`${locale}\`) | Backtranslation to English | Quality |` + : `| Key | Original English | Edited (\`${locale}\`) | Backtranslation to English |`, + hasAnalysis + ? `|-----|------------------|------------------------|----------------------------|---------|` + : `|-----|------------------|------------------------|----------------------------|`, + ...rows.map((r) => { + const base = `| \`${escapeCell(r.key)}\` | ${escapeCell( + truncate(r.sourceEnglish), + )} | ${escapeCell(truncate(r.edited))} | ${escapeCell( + truncate(r.backtranslation), + )} |`; + return hasAnalysis ? `${base} ${qualityCell(r.analysis)} |` : base; + }), ``, `_Generated by the localization workspace._`, ].join('\n'); @@ -376,10 +414,16 @@ export const submitLocalizationBundle = onCall< const { locale, description, edits } = request.data; - if (typeof locale !== 'string' || !/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) + if ( + typeof locale !== 'string' || + !/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale) + ) throw new HttpsError('invalid-argument', `Invalid locale: ${locale}`); if (typeof description !== 'string') - throw new HttpsError('invalid-argument', 'description must be a string.'); + throw new HttpsError( + 'invalid-argument', + 'description must be a string.', + ); if (description.length > LIMITS.maxDescriptionLength) throw new HttpsError('invalid-argument', 'description is too long.'); if (typeof edits !== 'object' || edits === null || Array.isArray(edits)) @@ -388,7 +432,10 @@ export const submitLocalizationBundle = onCall< if (entries.length === 0) throw new HttpsError('invalid-argument', 'No edits to submit.'); if (entries.length > LIMITS.maxEdits) - throw new HttpsError('invalid-argument', 'Too many edits in one bundle.'); + throw new HttpsError( + 'invalid-argument', + 'Too many edits in one bundle.', + ); for (const [key, value] of entries) { if (typeof value !== 'string') throw new HttpsError( @@ -449,10 +496,7 @@ export const submitLocalizationBundle = onCall< `Tutorial file not found for ${locale}.`, ); if (!sourceLocaleFile) - throw new HttpsError( - 'internal', - 'en-US source locale file not found.', - ); + throw new HttpsError('internal', 'en-US source locale file not found.'); // Apply edits to in-memory copies of the JSON. Throws on invalid paths. const summaryRows: { @@ -494,27 +538,37 @@ export const submitLocalizationBundle = onCall< ); } - // Backtranslate each edited value into English for the reviewer. - const backtranslations = - locale === 'en-US' - ? summaryRows.map((r) => r.edited) - : await backtranslate( - summaryRows.map((r) => r.edited), - locale, - 'en', - ); - - const rows = summaryRows.map((r, i) => ({ - ...r, - backtranslation: backtranslations[i] ?? '', - })); + // One Claude pass over the edited strings: back-translation (for non-English + // locales) plus reading-level + glossary-symbolization analysis for review. + // Null on any failure — the PR still opens, just without these aids. + const analysis = await analyze({ + locale, + sourceLocale: 'en-US', + strings: summaryRows.map((r) => ({ key: r.key, text: r.edited })), + glossary: extractGlossaryWords(targetLocaleFile?.json), + backTranslate: locale !== 'en-US', + }); + const analysisByKey = new Map( + (analysis ?? []).map((a): [string, StringAnalysis] => [a.key, a]), + ); + + const rows = summaryRows.map((r) => { + const a = analysisByKey.get(r.key); + return { + ...r, + backtranslation: + locale === 'en-US' ? r.edited : (a?.backTranslation ?? ''), + analysis: a, + }; + }); // Compose PR body using the contributor's auth context. - const userRecord = - await (await import('firebase-admin/auth')) - .getAuth() - .getUser(request.auth.uid) - .catch(() => undefined); + const userRecord = await ( + await import('firebase-admin/auth') + ) + .getAuth() + .getUser(request.auth.uid) + .catch(() => undefined); const contributor = { uid: request.auth.uid, name: userRecord?.displayName ?? request.auth.token.name ?? null, @@ -574,9 +628,7 @@ export const submitLocalizationBundle = onCall< `Branch: ${branch}`, `Title: ${title}`, `Files: ${files.length}`, - ...files.map( - (f) => ` • ${f.path} (${f.content.length} bytes)`, - ), + ...files.map((f) => ` • ${f.path} (${f.content.length} bytes)`), '', '--- PR body ---', body, diff --git a/package-lock.json b/package-lock.json index af5a974f33..b6b158c8c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,25 @@ { "name": "wordplay", - "version": "0.24.0", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wordplay", - "version": "0.24.0", + "version": "0.25.0", "bundleDependencies": [ "shared-types" ], "hasInstallScript": true, "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@axe-core/playwright": "^4", "@mediapipe/tasks-vision": "^0.10.35", "@types/fontkit": "^2.0.9", "colorjs.io": "^0", "decimal.js": "^10", "dexie": "^4", + "dotenv": "^17.4.2", "firebase": "^12", "fontkit": "^2.0.4", "graphemer": "^1", @@ -80,6 +82,27 @@ "node": ">=14.17" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.106.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.106.0.tgz", + "integrity": "sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", @@ -663,6 +686,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -4528,6 +4560,12 @@ "text-hex": "1.0.x" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -7786,6 +7824,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8506,6 +8556,12 @@ "license": "MIT", "peer": true }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -12131,6 +12187,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -15715,6 +15784,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -16593,6 +16672,12 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-json-schema-generator": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.9.0.tgz", diff --git a/package.json b/package.json index 08f9a22f5b..8f06332c6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wordplay", - "version": "0.25.0", + "version": "0.26.0", "type": "module", "scripts": { "sync": "svelte-kit sync", @@ -31,12 +31,14 @@ "locales-ci": "npx tsx src/util/verify-locales/start.ts ci", "locales-fix": "npx tsx src/util/verify-locales/start.ts fix", "locales-prune": "npx tsx src/util/verify-locales/prune.ts", - "locales-translate": "npx tsx src/util/verify-locales/start.ts translate", - "locales-emojis": "node scripts/generate-emojis.mjs", + "locales-translate": "WORDPLAY_TRANSLATOR=claude npx tsx src/util/verify-locales/start.ts translate", + "locales-override": "WORDPLAY_TRANSLATOR=claude npx tsx src/util/verify-locales/start.ts override", + "locales-translate-parallel": "WORDPLAY_TRANSLATOR=claude npx tsx src/util/verify-locales/batch.ts translate", + "locales-override-parallel": "WORDPLAY_TRANSLATOR=claude npx tsx src/util/verify-locales/batch.ts override", + "locales-emojis": "npx tsx src/util/verify-locales/generateEmojis.ts", "how": "npx tsx src/util/verify-locales/buildHowTos.ts", "codes": "npx tsx src/unicode/compress.ts", "rtl": "npx tsx scripts/check-logical-css.ts", - "override": "npx tsx src/util/verify-locales/start.ts override", "schemas": "npm-watch create-schemas", "merge": "git checkout main && git merge dev && git push origin main && git checkout dev", "outdated": "npx check-outdated --ignore-pre-releases --columns name,type,current,latest,changes", @@ -94,12 +96,14 @@ "vitest": "^4" }, "dependencies": { + "@anthropic-ai/sdk": "^0", "@axe-core/playwright": "^4", "@mediapipe/tasks-vision": "^0.10.35", "@types/fontkit": "^2.0.9", "colorjs.io": "^0", "decimal.js": "^10", "dexie": "^4", + "dotenv": "^17", "firebase": "^12", "fontkit": "^2.0.4", "graphemer": "^1", diff --git a/scripts/generate-emojis.mjs b/scripts/generate-emojis.mjs deleted file mode 100644 index 50a80ade22..0000000000 --- a/scripts/generate-emojis.mjs +++ /dev/null @@ -1,272 +0,0 @@ -// Generate per-locale emoji translation files from Unicode CLDR annotations. -// -// For each supported locale, downloads the CLDR annotation XML files (base + -// derived) for the closest matching CLDR locale, and emits -// static/locales/{locale}/{locale}-emojis.json. Each entry is keyed by the -// codepoint sequence used in codes.txt and contains an array whose first -// element is the display ("tts") name and remaining elements are searchable -// keywords: -// -// { "1F600": ["grinning face", "face", "grin", "smile"] } -// -// Emojis with no CLDR coverage in the target locale fall back to the English -// CLDR entry, then to the name in static/unicode/codes.txt. Files are pretty- -// printed with 4-space indent so human translators can edit them directly. - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(__dirname, '..'); - -// Map each supported locale to its CLDR annotation locale code(s). The list is -// consulted in order; the first one that exists is used. CLDR uses -// underscores while we use hyphens. -const LocaleToCLDR = { - 'en-US': ['en'], - 'es-MX': ['es_MX', 'es'], - 'zh-CN': ['zh'], - 'ko-KR': ['ko'], - 'zh-TW': ['zh_Hant'], - 'fr-FR': ['fr'], - 'ja-JP': ['ja'], - 'de-DE': ['de'], - 'hi-IN': ['hi'], - 'pa-IN': ['pa'], - 'ta-IN-LK-SG': ['ta'], - 'sv-FI': ['sv_FI', 'sv'], - 'sr-RS': ['sr'], - 'mr-IN': ['mr'], - 'pl-PL': ['pl'], - 'gu-IN': ['gu'], - 'tr-TR': ['tr'], - 'ar-SA': ['ar_SA', 'ar'], - 'el-GR': ['el'], - 'kn-IN': ['kn'], - 'te-IN': ['te'], - 'as-IN': ['as'], - 'he-IL': ['he'], - 'vi-VN': ['vi'], - 'tl-PH': ['fil'], // CLDR uses Filipino code for Tagalog. -}; - -const CLDR_BASE = 'https://raw.githubusercontent.com/unicode-org/cldr/main'; - -/** Read SupportedLocales from the TS source so we don't hard-code the list. */ -function readSupportedLocales() { - const tsPath = path.join(ROOT, 'src', 'locale', 'SupportedLocales.ts'); - const src = readFileSync(tsPath, 'utf8'); - // Strip everything outside the two array literals and pull out the - // quoted strings. The file is small and follows a fixed shape. - const matches = src.matchAll(/'([a-z]{2,3}(?:-[A-Z]{2}(?:-[A-Z]{2})*)?)'/g); - return Array.from(new Set(Array.from(matches, (m) => m[1]))); -} - -/** Read codes.txt and return entries that should get emoji translations. - * We translate every entry that has a subgroup code (column 4+), matching the - * subset GlyphChooser shows in the emoji picker. */ -function readEmojiCodes() { - const codesPath = path.join(ROOT, 'static', 'unicode', 'codes.txt'); - const lines = readFileSync(codesPath, 'utf8').split('\n'); - const entries = []; - for (const line of lines) { - if (!line) continue; - const cols = line.split(';'); - // cols: [hex sequence, name, category, group?, subgroup?] - // Only translate entries with a group/subgroup (the picker's set). - if (cols.length < 4 || !cols[3]) continue; - const hex = cols[0].trim(); - const name = cols[1].trim(); - entries.push({ key: hex, name }); - } - return entries; -} - -/** Convert a "1F600" or "0023 FE0F 20E3" hex string into the actual emoji - * character it represents. */ -function hexToEmoji(hex) { - return String.fromCodePoint(...hex.split(' ').map((h) => parseInt(h, 16))); -} - -/** CLDR strips U+FE0F (variation selector-16) from the cp attribute on - * annotations. Strip it from a string so we can match codes.txt entries - * against CLDR keys consistently. */ -function stripVariationSelectors(s) { - return s.replace(/[︎️]/g, ''); -} - -/** Fetch a CLDR annotation file. Returns the body or null if 404. */ -async function fetchCLDR(filename, kind) { - const url = `${CLDR_BASE}/common/${kind}/${filename}`; - const res = await fetch(url); - if (res.status === 404) return null; - if (!res.ok) - throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); - return await res.text(); -} - -/** Decode common XML entities. CLDR uses these in keyword text. */ -function decodeXMLEntities(s) { - return s - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'"); -} - -/** Parse a CLDR annotation XML body into a Map. - * Annotations may carry extra attributes (draft, references) in any order - * between the tag name and `>`, so we match the start tag loosely and pull - * `cp` and `type` out separately. */ -function parseAnnotations(xml) { - const map = new Map(); - if (!xml) return map; - const re = /]*)>([\s\S]*?)<\/annotation>/g; - const cpRe = /\bcp="([^"]+)"/; - const typeRe = /\btype="([^"]+)"/; - let m; - while ((m = re.exec(xml)) !== null) { - const attrs = m[1]; - const cpMatch = cpRe.exec(attrs); - if (!cpMatch) continue; - const cp = stripVariationSelectors(decodeXMLEntities(cpMatch[1])); - const typeMatch = typeRe.exec(attrs); - const isTTS = typeMatch?.[1] === 'tts'; - const value = decodeXMLEntities(m[2]).trim(); - let entry = map.get(cp); - if (!entry) { - entry = {}; - map.set(cp, entry); - } - if (isTTS) entry.tts = value; - else - entry.keywords = value - .split('|') - .map((s) => s.trim()) - .filter(Boolean); - } - return map; -} - -/** Load CLDR annotations (base + derived) for a CLDR locale code. Tries each - * candidate code until one returns data. Returns the merged map. */ -async function loadCLDRForCandidate(candidate, cache) { - if (cache.has(candidate)) return cache.get(candidate); - const baseXML = await fetchCLDR(`${candidate}.xml`, 'annotations'); - const derivedXML = await fetchCLDR(`${candidate}.xml`, 'annotationsDerived'); - if (!baseXML && !derivedXML) { - cache.set(candidate, null); - return null; - } - const merged = parseAnnotations(baseXML); - // Merge derived (skin tone variants, ZWJ sequences) on top. - const derived = parseAnnotations(derivedXML); - for (const [cp, entry] of derived) { - const existing = merged.get(cp); - if (existing) - merged.set(cp, { - tts: existing.tts ?? entry.tts, - keywords: existing.keywords ?? entry.keywords, - }); - else merged.set(cp, entry); - } - cache.set(candidate, merged); - return merged; -} - -/** Resolve the merged CLDR map for a supported locale, walking the candidate - * list. Region-specific CLDR files (e.g. sv_FI, ar_SA, es_MX) only contain - * entries that differ from the language base, so we layer the language map - * underneath the region overrides. Returns { used, map } where `used` lists - * the candidates that contributed entries. */ -async function loadCLDRForLocale(locale, cache) { - const candidates = LocaleToCLDR[locale]; - if (!candidates) return { used: [], map: null }; - const merged = new Map(); - const used = []; - // Iterate in priority order; first occurrence wins per emoji. - for (const candidate of candidates) { - const map = await loadCLDRForCandidate(candidate, cache); - if (!map || map.size === 0) continue; - used.push(candidate); - for (const [cp, entry] of map) { - const existing = merged.get(cp); - if (existing) { - merged.set(cp, { - tts: existing.tts ?? entry.tts, - keywords: existing.keywords ?? entry.keywords, - }); - } else { - merged.set(cp, entry); - } - } - } - return { used, map: merged.size > 0 ? merged : null }; -} - -/** Build the per-locale emoji array for a single emoji entry. Falls back - * through the locale's CLDR data, then English CLDR, then codes.txt. */ -function buildEntry(entry, cldrMap, englishMap) { - const emoji = stripVariationSelectors(hexToEmoji(entry.key)); - const cldr = cldrMap?.get(emoji); - const english = englishMap?.get(emoji); - const tts = cldr?.tts ?? english?.tts ?? entry.name.toLowerCase() ?? ''; - const keywords = - cldr?.keywords && cldr.keywords.length > 0 - ? cldr.keywords - : english?.keywords ?? []; - // Deduplicate while preserving order, and drop the tts from the keyword - // list to avoid redundancy. The first array element is always the tts. - const seen = new Set([tts.toLowerCase()]); - const dedupedKeywords = []; - for (const k of keywords) { - const lower = k.toLowerCase(); - if (seen.has(lower)) continue; - seen.add(lower); - dedupedKeywords.push(k); - } - return [tts, ...dedupedKeywords]; -} - -async function main() { - const supported = readSupportedLocales(); - const emojiCodes = readEmojiCodes(); - console.log( - `Generating emoji translations for ${supported.length} locales (${emojiCodes.length} emoji entries each)`, - ); - - const cldrCache = new Map(); - // Load English CLDR first so it can serve as fallback for everything else. - const englishResult = await loadCLDRForCandidate('en', cldrCache); - if (!englishResult) - throw new Error('Could not fetch English CLDR annotations.'); - - for (const locale of supported) { - const { used, map } = await loadCLDRForLocale(locale, cldrCache); - const cldrMap = map ?? englishResult; - const cldrEntries = map ? map.size : 0; - const obj = {}; - let cldrCovered = 0; - for (const entry of emojiCodes) { - const emojiChar = stripVariationSelectors(hexToEmoji(entry.key)); - if (cldrMap.has(emojiChar)) cldrCovered++; - obj[entry.key] = buildEntry(entry, cldrMap, englishResult); - } - const dir = path.join(ROOT, 'static', 'locales', locale); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const file = path.join(dir, `${locale}-emojis.json`); - writeFileSync(file, JSON.stringify(obj, null, 4) + '\n'); - const usedLabel = used.length > 0 ? used.join('+') : 'en (fallback)'; - console.log( - ` ${locale.padEnd(12)} ← CLDR ${usedLabel.padEnd(12)} (${cldrEntries} entries, ${cldrCovered}/${emojiCodes.length} matched) → ${file}`, - ); - } - console.log('Done.'); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/app.html b/src/app.html index ab5d96e4b2..ec21c82d52 100644 --- a/src/app.html +++ b/src/app.html @@ -446,16 +446,15 @@ h2:not(:first-of-type), h3:not(:first-of-type) { margin-top: calc(2 * var(--wordplay-spacing)); + font-weight: bold; } h1 { font-size: calc(var(--wordplay-font-size) + 3pt); - font-weight: bold; } h2 { font-size: calc(var(--wordplay-font-size) + 1pt); - font-weight: bold; } h3 { diff --git a/src/components/app/AddProject.svelte b/src/components/app/AddProject.svelte index 7d1ad1cf2a..1eb950a988 100644 --- a/src/components/app/AddProject.svelte +++ b/src/components/app/AddProject.svelte @@ -20,7 +20,7 @@ null, '', new Source( - $locales.getUnannotatedText((l) => l.term.start), + $locales.getUnannotatedText((l) => l.glossary.start.word), $locales.getUnannotatedText( (l) => l.ui.project.defaults.starterCode, ), diff --git a/src/components/app/TutorialView.svelte b/src/components/app/TutorialView.svelte index 3b25df8cbc..2e08699195 100644 --- a/src/components/app/TutorialView.svelte +++ b/src/components/app/TutorialView.svelte @@ -1,4 +1,5 @@ -{#if concept} +{#if term} +{:else if concept} {#if isConceptGalleryHow && (concept as GalleryHowConcept).howTo.hasBookmarker($user?.uid ?? '')} {/if} diff --git a/src/components/concepts/Documentation.svelte b/src/components/concepts/Documentation.svelte index 70b26b5c07..be3cc6ad02 100644 --- a/src/components/concepts/Documentation.svelte +++ b/src/components/concepts/Documentation.svelte @@ -1,6 +1,6 @@ + +
+

{word}

+ getTermDefinition(l, id) }} /> +
+ + diff --git a/src/components/concepts/GlossaryView.svelte b/src/components/concepts/GlossaryView.svelte new file mode 100644 index 0000000000..30a964aaaa --- /dev/null +++ b/src/components/concepts/GlossaryView.svelte @@ -0,0 +1,36 @@ + + +
+ l.ui.docs.glossary.explain} sub /> + {#each terms as term (term.id)} + + {/each} +
+ + diff --git a/src/components/concepts/GuideHistory.ts b/src/components/concepts/GuideHistory.ts index db6e789105..c9cf8716df 100644 --- a/src/components/concepts/GuideHistory.ts +++ b/src/components/concepts/GuideHistory.ts @@ -1,8 +1,9 @@ import type Concept from '@concepts/Concept'; import type { PurposeType } from '@concepts/Purpose'; -/** The guide's two top-level sections: language/code concepts vs. how-to guides. */ -export type GuideMode = 'language' | 'howto'; +/** The guide's top-level sections: language/code concepts, how-to guides, and + * the glossary of key terms. */ +export type GuideMode = 'language' | 'howto' | 'glossary'; /** * One location in the guide's navigation history: diff --git a/src/components/concepts/MarkupHTMLView.svelte b/src/components/concepts/MarkupHTMLView.svelte index 583fac5804..7f46e6776e 100644 --- a/src/components/concepts/MarkupHTMLView.svelte +++ b/src/components/concepts/MarkupHTMLView.svelte @@ -9,6 +9,7 @@ import Notice from '@components/app/Notice.svelte'; import SegmentHTMLView from '@components/concepts/SegmentHTMLView.svelte'; import { accessorToLocalePath } from '@components/localization/accessorToLocalePath'; + import LocalizationQuality from '@components/localization/LocalizationQuality.svelte'; import { getLocalizing } from '@components/project/Contexts'; import Button from '@components/widgets/Button.svelte'; import FormattedEditor from '@components/widgets/FormattedEditor.svelte'; @@ -26,8 +27,8 @@ import ConceptLink from '@nodes/ConceptLink'; import Markup from '@nodes/Markup'; import Paragraph from '@nodes/Paragraph'; - import type Spaces from '@parser/Spaces'; import { parseDocs, parseFormattedLiteral } from '@parser/parseExpression'; + import type Spaces from '@parser/Spaces'; import { CANCEL_SYMBOL, CONFIRM_SYMBOL, @@ -144,17 +145,21 @@ // Convert sequences of paragraphs that start with bullets into an HTML list. function toParagraphsAndLists(m: Markup): ParagraphOrList[] { - return m.paragraphs.reduce((stuff: ParagraphOrList[], next: Paragraph) => { - if (next.isBulleted()) { - const items = next.getBullets(); - const previous = stuff.at(-1); - if (previous instanceof Paragraph) return [...stuff, { items }]; - else if (previous !== undefined) { - previous.items.push(next); - return stuff; - } else return [{ items }]; - } else return [...stuff, next]; - }, []); + return m.paragraphs.reduce( + (stuff: ParagraphOrList[], next: Paragraph) => { + if (next.isBulleted()) { + const items = next.getBullets(); + const previous = stuff.at(-1); + if (previous instanceof Paragraph) + return [...stuff, { items }]; + else if (previous !== undefined) { + previous.items.push(next); + return stuff; + } else return [{ items }]; + } else return [...stuff, next]; + }, + [], + ); } let paragraphsAndLists = $derived(toParagraphsAndLists(parsed)); @@ -256,7 +261,9 @@ Array.isArray(text) ? text.join('\n\n') : text; const seen = new Set([ - withoutAnnotations(joinWords($locales.getWithAnnotations(accessor))), + withoutAnnotations( + joinWords($locales.getWithAnnotations(accessor)), + ), ]); for (const view of $locales.getSecondaryLocaleViews()) { const raw = joinWords(view.getWithAnnotations(accessor)); @@ -349,34 +356,37 @@ }); -{#snippet paragraphsView(items: ParagraphOrList[], sp: Spaces)}{#each items as paragraphOrList, index}{#if paragraphOrList instanceof Paragraph} -
0} - style="--delay:{$animationDuration * index * 0.1}ms" - >{#each paragraphOrList.segments as segment, segIndex}{/each}
{:else}
    0} - style="--delay:{$animationDuration * index * 0.1}ms" - >{#each paragraphOrList.items as paragraph}
  • {#each paragraph.segments as segment, segIndex} 0} + style="--delay:{$animationDuration * index * 0.1}ms" + >{#each paragraphOrList.segments as segment, segIndex}{/each}
  • {/each}
{/if}{/each}{/snippet} + />{/each}{:else}
    0} + style="--delay:{$animationDuration * index * 0.1}ms" + >{#each paragraphOrList.items as paragraph}
  • {#each paragraph.segments as segment, segIndex}{/each}
  • {/each}
{/if}{/each}{/snippet} {#if localizing?.on && isLocalizable} {/if} + (editedText = suggestion)} + />
+ + diff --git a/src/components/concepts/placeLabel.ts b/src/components/concepts/placeLabel.ts index 267b7eb84d..daee3658ab 100644 --- a/src/components/concepts/placeLabel.ts +++ b/src/components/concepts/placeLabel.ts @@ -19,7 +19,11 @@ export default function placeLabel( const language = locales.getLocale().language; return `${SEARCH_SYMBOL} ${getLanguageQuoteOpen(language)}${place.query}${getLanguageQuoteClose(language)}`; } - return place.mode === 'howto' - ? locales.getPlainText((l) => l.ui.docs.mode.browse.labels[1]) - : locales.getPlainText((l) => l.ui.docs.purposes[place.purpose].header); + if (place.mode === 'howto') + return locales.getPlainText((l) => l.ui.docs.mode.browse.labels[1]); + if (place.mode === 'glossary') + return locales.getPlainText((l) => l.ui.docs.mode.browse.labels[2]); + return locales.getPlainText( + (l) => l.ui.docs.purposes[place.purpose].header, + ); } diff --git a/src/components/editor/commands/Commands.ts b/src/components/editor/commands/Commands.ts index 2603ccd6e7..5367a551a5 100644 --- a/src/components/editor/commands/Commands.ts +++ b/src/components/editor/commands/Commands.ts @@ -1453,7 +1453,7 @@ const Commands: Command[] = [ FunctionDefinition.make( undefined, Names.make([ - locales.getWithAnnotations((l) => l.term.name), + locales.getWithAnnotations((l) => l.glossary.name.word), ]), undefined, [], diff --git a/src/components/localization/LocalizationQuality.svelte b/src/components/localization/LocalizationQuality.svelte new file mode 100644 index 0000000000..74eb8bc60a --- /dev/null +++ b/src/components/localization/LocalizationQuality.svelte @@ -0,0 +1,187 @@ + + +{#if literalTerms.length > 0} + +

+ l.ui.page.localize.literalTermsWarning} + /> +

+
+ {#each literalTerms as finding (finding.id)} + + {/each} +
+
+{/if} +{#if readingLevel !== undefined} + {@const rl = readingLevel} + + {#if rl.complex} + + {#if rl.note.length > 0} +

{rl.note}

+ {:else} +

+ l.ui.page.localize.readingLevelComplex} + /> +

+ {/if} + {:else} +

+ l.ui.page.localize.readingLevelOk} + /> +

+ {/if} +
+{/if} +{#if functions !== undefined} +
+ +
+{/if} + + diff --git a/src/components/output/GroupView.svelte b/src/components/output/GroupView.svelte index 6a26118d22..40bd569f47 100644 --- a/src/components/output/GroupView.svelte +++ b/src/components/output/GroupView.svelte @@ -1,7 +1,19 @@ @@ -2086,7 +2087,7 @@ function addSource() { const newProject = project.withNewSource( - `${$locales.getUnannotatedText((l) => l.term.source)}${ + `${$locales.getUnannotatedText((l) => getConceptName(l, 'source'))}${ project.getSupplements().length + 1 }`, ); @@ -2397,9 +2398,7 @@ .resetZoom} background active={focusOverridden} - > {/if} @@ -2579,7 +2578,9 @@ recycle bar, rather than spanning the full tile footer below both margins. --> {#if editable} -
+
{#each notifications as notification (notification.id)} @@ -2620,7 +2621,9 @@ than dangling in the footer; interactive (it has a Restore button), so pointer-events are enabled. --> {#if checkpoint > -1} -
+
@@ -2645,9 +2648,12 @@ project.withCheckpoint(), ), ); - checkpoint = -1; + checkpoint = + -1; }} - label={(l) => + label={( + l, + ) => l.ui .checkpoints .button diff --git a/src/components/project/SourceTileToggle.svelte b/src/components/project/SourceTileToggle.svelte index 05067401ca..72a5fddf90 100644 --- a/src/components/project/SourceTileToggle.svelte +++ b/src/components/project/SourceTileToggle.svelte @@ -51,7 +51,7 @@ >{#if project.getSources().length > 1}{$locales.getName( source.names, )}{:else} locale.term.code} + > locale.glossary.code.word} >{/if} diff --git a/src/components/project/Translate.svelte b/src/components/project/Translate.svelte index cc103eef72..28ed7303bb 100644 --- a/src/components/project/Translate.svelte +++ b/src/components/project/Translate.svelte @@ -10,7 +10,8 @@ import { functions } from '@db/firebase'; import type Project from '@db/projects/Project'; import translateProject from '@db/projects/translate'; - import { Languages, TranslatableLocales } from '@locale/LanguageCode'; + import { Languages } from '@locale/LanguageCode'; + import getTranslatableLocales from '@locale/getTranslatableLocales'; import { localesAreEqual, localeToString, @@ -56,8 +57,9 @@ let destinationLocales = $derived.by(() => { const langs = $locales.getLanguages(); const q = query.trim().toLocaleLowerCase(langs); - if (q.length === 0) return TranslatableLocales; - return TranslatableLocales.filter((locale) => { + const offered = getTranslatableLocales(); + if (q.length === 0) return offered; + return offered.filter((locale) => { const info = Languages[locale.language]; const haystack = [ info?.name ?? '', // native name, e.g. "español", "日本語" diff --git a/src/db/projects/ProjectsDatabase.svelte.ts b/src/db/projects/ProjectsDatabase.svelte.ts index 2a695fd2e4..87afacbf30 100644 --- a/src/db/projects/ProjectsDatabase.svelte.ts +++ b/src/db/projects/ProjectsDatabase.svelte.ts @@ -1381,7 +1381,7 @@ export default class ProjectsDatabase { const newProject = Project.make( null, '', - new Source(locales[0].term.start, code), + new Source(locales[0].glossary.start.word, code), [], // The project starts with all of the locales currently selected in the config. locales, diff --git a/src/db/projects/translate.ts b/src/db/projects/translate.ts index efd67ac3bc..b973be3680 100644 --- a/src/db/projects/translate.ts +++ b/src/db/projects/translate.ts @@ -1,437 +1,50 @@ import { Locales } from '@db/Database'; -import { GoogleTranslateCodeOverrides } from '@locale/LanguageCode'; import type Locale from '@locale/Locale'; import { localeToString } from '@locale/Locale'; -import BinaryEvaluate from '@nodes/BinaryEvaluate'; -import Doc from '@nodes/Doc'; -import Docs from '@nodes/Docs'; -import Evaluate from '@nodes/Evaluate'; -import FormattedLiteral from '@nodes/FormattedLiteral'; -import FormattedTranslation from '@nodes/FormattedTranslation'; -import Input from '@nodes/Input'; -import Language from '@nodes/Language'; -import Markup from '@nodes/Markup'; -import Names from '@nodes/Names'; -import Reference from '@nodes/Reference'; -import type Source from '@nodes/Source'; -import TextLiteral from '@nodes/TextLiteral'; -import TextType from '@nodes/TextType'; -import Token from '@nodes/Token'; -import Translation from '@nodes/Translation'; -import getPreferredSpaces from '@parser/getPreferredSpaces'; -import { toMarkup } from '@parser/toMarkup'; import { httpsCallable, type Functions } from 'firebase/functions'; +import type { + GetLLMTranslationsInputs, + GetLLMTranslationsOutput, +} from 'shared-types'; import type Project from '@db/projects/Project'; - -// Convert any camel cased word into space separated words. -const SeparateWords = /[A-Z-_](?=[a-z0-9]+)|[A-Z-_]+(?![a-z0-9])/g; - -/** The code to hand to Google Translate for a locale. A few languages Google - * supports under a different code (e.g. `nb`→`no`); those use the override - * (no region, since the override is already the code Google expects). All - * others are sent as the full locale string, unchanged from before. */ -function toGoogleTranslateCode(locale: Locale): string { - return ( - GoogleTranslateCodeOverrides[locale.language] ?? localeToString(locale) - ); -} - -/** - * Doc.make() and FormattedTranslation.make() build their inner Markup with - * no Spaces, but the stage renderer ([MarkupHTMLView](src/components/concepts/MarkupHTMLView.svelte)) - * falls back to "unable to render markup without spaces" when that field is - * undefined. We reattach computed spaces here so the translated Doc / - * FormattedTranslation render the same way the original source-parsed one - * does. Same pattern as the source-level `getPreferredSpaces` call below. - */ -function withFormattedMarkupSpaces( - node: T, -): T { - const inner = node.markup; - return node.replace( - inner, - new Markup(inner.paragraphs, getPreferredSpaces(inner)), - ) as T; -} +import translateProjectContent from './translateProjectContent'; /** Given a reference to Firebase functions, a project, and a target language, translate the project's names, documentation, and text literals - * using a combination of the text already in the project and translations from Google's Translate API. Subsequent calls the project should - * use the previously translations to avoid unnecessary API calls. + * using a combination of the text already in the project and translations from Claude. Subsequent calls on the project + * reuse the previous translations to avoid unnecessary API calls. Delegates to the backend-agnostic + * [translateProjectContent](src/db/projects/translateProjectContent.ts), injecting the Firebase + * `getLLMTranslations` callable (Claude) as the raw translator and forwarding project context for quality. */ export default async function translateProject( functions: Functions, project: Project, sourceLocale: Locale, targetLocale: Locale, -) { - const targetLanguage = targetLocale.language; - - try { - // Keep track of existing names in target language - const existingNames = new Set(); - - // collect existing names in target language - project.getSources().forEach((source) => { - source - .nodes() - .filter((node): node is Names => node instanceof Names) - .forEach((names) => { - const targetName = names - .getNameInLanguage(targetLanguage, undefined) - ?.getName(); - if (targetName) existingNames.add(targetName); - }); - }); - - // Find all of the names binds in the project's sources. We're going to add translated names to them, and update references to those names, if necessary. - // Convert the binds into a record of translations to perform. - const bindsToTranslate = project - .getSources() - .reduce( - (names: Names[], source) => [ - ...names, - ...source - .nodes() - .filter((node): node is Names => node instanceof Names), - ], - [], - ) - .map((names) => { - // Is there a name in the source language or a name with no language? Use that as the source name. - const nameToTranslate = names.names.find( - (name) => - name.isLanguage(sourceLocale.language) || - !name.hasLanguage(), - ); - - if (nameToTranslate === undefined) return undefined; - - // Get the name already in the target language, if there is one. Prefer full names, not symbolic names. - const targetName = names - .getNameInLanguage(targetLanguage, false) - ?.getName(); - - return { - names, - // The original text to translate, or undefined if there is no text to translate. - // Convert the camel cased name into separated words for better translation performance. - original: nameToTranslate - .getName() - ?.replace(SeparateWords, ' $&') - .trim(), - // The translation, or undefined if there is no translation yet. - translation: targetName, - }; - }) - // Skip any names that don't need a translation. - .filter( - ( - text, - ): text is { - names: Names; - original: string; - translation: string | undefined; - } => text !== undefined, - ); - - // Find all the docs and text literals in the program needing translation. - const textToTranslate = project - .getSources() - .reduce( - ( - markups: (Docs | FormattedLiteral | TextLiteral)[], - source, - ) => [ - ...markups, - ...source - .nodes() - .filter( - ( - node, - ): node is Docs | FormattedLiteral | TextLiteral => - node instanceof Docs || - node instanceof FormattedLiteral || - node instanceof TextLiteral, - ), - ], - [], - ) - // Filter out values that are input to evaluate binds that are literal text types, since they won't permit arbritrary text. - .filter((markup) => { - if (markup instanceof TextLiteral) { - const root = project.getRoot(markup); - if (root) { - const evaluates = root - .getAncestors(markup) - .filter((node) => node instanceof Evaluate); - for (const evaluate of evaluates) { - const source = project.getSourceOf(evaluate); - if (source === undefined) continue; - const inputs = evaluate.getInputMapping( - project.getContext(source), - ); - const input = inputs?.inputs.find( - (mapping) => - mapping.given === markup || - (mapping.given instanceof Input && - mapping.given.value === markup), - ); - const types = input?.expected - .getType(project.getContext(source)) - .getTypeSet(project.getContext(source)) - .list(); - if ( - types !== undefined && - types.some( - (t) => - t instanceof TextType && t.isLiteral(), - ) - ) - return false; - } - } - } - return true; - }) - .map((markups) => { - const docToTranslate = - markups.getLanguage(sourceLocale.language) ?? - markups.getOptions()[0]; - const existingTranslation = markups.getLanguage(targetLanguage); - - return { - names: markups, - original: - docToTranslate === undefined - ? undefined - : docToTranslate instanceof Translation - ? docToTranslate.segments - .filter( - (t): t is Token => t instanceof Token, - ) - .map((t) => t.toWordplay()) - .join('') - : docToTranslate.markup.paragraphs - .map((p) => p.toWordplay()) - .join('\n\n'), - translation: existingTranslation?.toWordplay(), - }; - }); - - // Get the original text with no translation to send to the Google Translate API via our Firebase cloud function. - const originalTexts = [...bindsToTranslate, ...textToTranslate] - .filter((bind) => bind.translation === undefined) - .map((bind) => bind.original) - .filter((text): text is string => text !== undefined); - - // Keep a record of the revised project to return. - let newProject = project; - - // Build a map from each unique original text to its translation, so lookups don't depend on positional indexes (which break when originalTexts has duplicates). - let translationByOriginal: Map | null = null; - // If there are more than one and the source and target are different, get some translations. - if ( - originalTexts.length > 0 && - sourceLocale.language !== targetLanguage - ) { - const getTranslations = httpsCallable< - { from: string; to: string; text: string[] }, - string[] | null - >(functions, 'getTranslations'); - - // Remove duplicates from the original texts to minimize cost. - const uniqueOriginals = Array.from(new Set(originalTexts)); +): Promise { + // Load the target locale text so the project gains that locale's names. + const targetLocaleText = await Locales.loadLocale( + localeToString(targetLocale), + false, + ); - const translations = ( - await getTranslations({ - from: toGoogleTranslateCode(sourceLocale), - to: toGoogleTranslateCode(targetLocale), - text: uniqueOriginals, + return translateProjectContent( + project, + sourceLocale, + targetLocale, + async (texts, from, to, context) => { + const getLLMTranslations = httpsCallable< + GetLLMTranslationsInputs, + GetLLMTranslationsOutput + >(functions, 'getLLMTranslations'); + return ( + await getLLMTranslations({ + from: localeToString(from), + to: localeToString(to), + texts, + ...(context ? { projectContext: context } : {}), }) ).data; - - // If we didn't get any translations, return nothing as an indicator of failure. - if (translations === null) return null; - - translationByOriginal = new Map(); - uniqueOriginals.forEach((original, index) => { - const translated = translations[index]; - if (typeof translated === 'string') - translationByOriginal!.set(original, translated); - }); - } - - // First, revise the project to contain the target locale, so we have names from the locale. - const targetLocaleText = await Locales.loadLocale( - localeToString(targetLocale), - false, - ); - if (targetLocaleText) - newProject = newProject.withPrimaryLocale(targetLocaleText); - - // Revise the project to include the new translated names and updated references to those new names. - newProject = newProject.withRevisedNodes( - bindsToTranslate.map((bindToTranslate) => { - const { names, original } = bindToTranslate; - // If we already have a translation, use it. - let translation = bindToTranslate.translation; - // If we don't, and there was an original text, and we have some translations, then use the translation. - if ( - translation === undefined && - original !== undefined && - translationByOriginal - ) { - const translated = translationByOriginal.get(original); - if (translated === undefined) return [names, names]; - // Convert the translated text into camel case, to confirm to Wordplay's naming rules. - translation = translated - .split(' ') - .map((word, i) => - i === 0 - ? word.toLowerCase() - : word.charAt(0).toUpperCase() + word.slice(1), - ) - .join(''); - if (existingNames.has(translation)) { - let counter = 2; - // Increment the counter until a unique name is found - while (existingNames.has(`${translation}${counter}`)) { - counter++; - } - translation = `${translation}${counter}`; - } - - //Add the unique translation to the set - existingNames.add(translation); - } - - // If we have a translation, add it to the bind and update its references. - if (translation !== undefined) { - // Return the updated bind and the updated references. - return [names, names.withName(translation, targetLanguage)]; - } else return [names, names]; - }), - ); - - // Now that the revised projects as all of the translations required, revise all references in the project to use the name in the target language, when available. - newProject = newProject.withRevisedNodes( - newProject - .getSources() - .reduce( - ( - references: { reference: Reference; source: Source }[], - source, - ) => [ - ...references, - ...source - .nodes() - .filter( - (node): node is Reference => - node instanceof Reference, - ) - .map((reference) => ({ reference, source })), - ], - [], - ) - .map(({ reference, source }) => { - const definition = reference.resolve( - newProject.getContext(source), - ); - - // Find the references to this bind so we can replace them with the new name - if (definition === undefined) return [reference, reference]; - - // Get the name in the target language. - const parent = newProject - .getRoot(reference) - ?.getParent(reference); - const infix = - parent instanceof BinaryEvaluate && - parent.fun === reference - ? true - : undefined; - const translation = - definition.names - .getNameInLanguage(targetLanguage, infix) - ?.getName() ?? - definition.names.names - .find((name) => !name.hasLanguage()) - ?.getName(); - - if ( - translation === undefined || - reference.getName() === translation - ) - return [reference, reference]; - - return [reference, Reference.make(translation)]; - }), - ); - - // Add the translated text to the project. - newProject = newProject.withRevisedNodes( - textToTranslate.map((textToTranslate) => { - const { names: markups, original } = textToTranslate; - let translation = textToTranslate.translation; - - // Already have a translation? No change. - if (translationByOriginal === null || translation !== undefined) - return [markups, markups]; - - if (original === undefined) return [markups, markups]; - const translated = translationByOriginal.get(original); - if (translated === undefined) return [markups, markups]; - - translation = translated; - - const [markup] = toMarkup(translation); - - return [ - markups, - markups instanceof TextLiteral - ? markups.withOption( - Translation.make( - translation, - Language.make(targetLanguage), - ), - ) - : markups instanceof Docs - ? markups.withOption( - withFormattedMarkupSpaces( - Doc.make( - markup.paragraphs, - Language.make(targetLanguage), - ), - ), - ) - : markups.withOption( - withFormattedMarkupSpaces( - FormattedTranslation.make( - markup.paragraphs, - Language.make(targetLanguage), - ), - ), - ), - ]; - }), - ); - - // Tidy all sources - newProject = newProject.withRevisedNodes( - newProject.getSources().map((source) => { - return [ - source, - source.withSpaces( - getPreferredSpaces(source.root, source.spaces), - ), - ]; - }), - ); - - // Return the revised project - return newProject; - } catch (e) { - console.error('translateProject failed:', e); - return null; - } + }, + targetLocaleText ?? undefined, + ); } diff --git a/src/db/projects/translateProjectContent.test.ts b/src/db/projects/translateProjectContent.test.ts new file mode 100644 index 0000000000..8e3433a03b --- /dev/null +++ b/src/db/projects/translateProjectContent.test.ts @@ -0,0 +1,68 @@ +import DefaultLocale from '@locale/DefaultLocale'; +import { stringToLocale } from '@locale/Locale'; +import Source from '@nodes/Source'; +import { expect, test } from 'vitest'; +import Project from '@db/projects/Project'; +import translateProjectContent, { + type RawTranslator, +} from './translateProjectContent'; + +const en = stringToLocale('en-US'); +const es = stringToLocale('es-ES'); + +/** A fake translator with a fixed source→target dictionary, echoing anything + * unknown so the test is deterministic and needs no network. */ +function fakeTranslator(dictionary: Record): RawTranslator { + return async (texts) => texts.map((t) => dictionary[t] ?? t); +} + +test('replace mode rewrites names, their references, and text into the target language', async () => { + if (en === undefined || es === undefined) throw new Error('bad locale'); + + // A bind, a text literal, and a reference to the bind. + const source = new Source('start', 'cat: "meow"\ncat'); + const project = Project.make(null, 'test', source, [], DefaultLocale); + + const result = await translateProjectContent( + project, + en, + es, + fakeTranslator({ cat: 'gato', meow: 'miau' }), + undefined, + true, + ); + + expect(result).not.toBeNull(); + const out = result?.getSources()[0].toWordplay() ?? ''; + + // The name and its reference are replaced (not added alongside the source). + expect(out).toContain('gato'); + expect(out).not.toContain('cat'); + // The text literal is replaced in place. + expect(out).toContain('miau'); + expect(out).not.toContain('meow'); +}); + +test('add mode keeps the source name and adds the target as another option', async () => { + if (en === undefined || es === undefined) throw new Error('bad locale'); + + // A language-tagged source name, so adding the target keeps both. + const source = new Source('start', 'cat/en: "meow"\ncat'); + const project = Project.make(null, 'test', source, [], DefaultLocale); + + const result = await translateProjectContent( + project, + en, + es, + fakeTranslator({ cat: 'gato', meow: 'miau' }), + undefined, + // replace defaults to false + ); + + expect(result).not.toBeNull(); + const out = result?.getSources()[0].toWordplay() ?? ''; + + // Both the source and target names are present (multilingual). + expect(out).toContain('cat'); + expect(out).toContain('gato'); +}); diff --git a/src/db/projects/translateProjectContent.ts b/src/db/projects/translateProjectContent.ts new file mode 100644 index 0000000000..89a5f82902 --- /dev/null +++ b/src/db/projects/translateProjectContent.ts @@ -0,0 +1,522 @@ +import type Locale from '@locale/Locale'; +import type LocaleText from '@locale/LocaleText'; +import BinaryEvaluate from '@nodes/BinaryEvaluate'; +import Doc from '@nodes/Doc'; +import Docs from '@nodes/Docs'; +import Evaluate from '@nodes/Evaluate'; +import FormattedLiteral from '@nodes/FormattedLiteral'; +import FormattedTranslation from '@nodes/FormattedTranslation'; +import Input from '@nodes/Input'; +import Language from '@nodes/Language'; +import Markup from '@nodes/Markup'; +import Names from '@nodes/Names'; +import Reference from '@nodes/Reference'; +import type Source from '@nodes/Source'; +import TextLiteral from '@nodes/TextLiteral'; +import TextType from '@nodes/TextType'; +import Token from '@nodes/Token'; +import Translation from '@nodes/Translation'; +import getPreferredSpaces from '@parser/getPreferredSpaces'; +import { toMarkup } from '@parser/toMarkup'; +import type Project from '@db/projects/Project'; +import { splitMarkupAndCode } from '@util/verify-locales/protect'; + +// Convert any camel cased word into space separated words. +const SeparateWords = /[A-Z-_](?=[a-z0-9]+)|[A-Z-_]+(?![a-z0-9])/g; + +/** + * Collapse soft line breaks (single newlines and other whitespace runs) within + * a paragraph's markup to single spaces, leaving `\code\` blocks untouched. + * + * Translators reflow prose, so a source newline has no stable counterpart in the + * translation; sending the newline through risks the model dropping it entirely + * and running words together (e.g. "and\nexplain" → "yexplicar"). Normalizing to + * spaces up front guarantees correct word spacing; the cost is that translated + * docs don't preserve the source's line wrapping. + */ +function normalizeSoftBreaks(text: string): string { + return splitMarkupAndCode(text) + .map((seg) => + seg.kind === 'code' ? seg.text : seg.text.replace(/\s+/g, ' '), + ) + .join(''); +} + +/** + * The raw machine-translation step, injected so this core is independent of the + * backend and transport. Receives the unique source strings plus the source and + * target locales, and returns translations aligned 1:1 with the inputs, or + * `null` on failure. The browser injects a Firebase callable; the CLI injects a + * direct backend call. + */ +export type RawTranslator = ( + texts: string[], + from: Locale, + to: Locale, + /** Optional context for quality: a sample of the project's other names and + * docs so the backend can choose domain-appropriate words. */ + context?: { names?: string[]; docs?: string[] }, +) => Promise<(string | undefined)[] | null>; + +/** + * Doc.make() and FormattedTranslation.make() build their inner Markup with + * no Spaces, but the stage renderer ([MarkupHTMLView](src/components/concepts/MarkupHTMLView.svelte)) + * falls back to "unable to render markup without spaces" when that field is + * undefined. We reattach computed spaces here so the translated Doc / + * FormattedTranslation render the same way the original source-parsed one + * does. Same pattern as the source-level `getPreferredSpaces` call below. + */ +function withFormattedMarkupSpaces( + node: T, +): T { + const inner = node.markup; + return node.replace( + inner, + new Markup(inner.paragraphs, getPreferredSpaces(inner)), + ) as T; +} + +/** + * Backend-agnostic core of project translation: given a project, source and + * target locales, an injected raw translator, and the already-loaded target + * locale text, translate the project's names, documentation, and text literals + * (reusing any translations already present), update references to renamed + * binds, and return the revised project — or `null` on failure. Decoupled from + * Firebase and the Database store so both the in-app dialog and the CLI can + * reuse it. + */ +export default async function translateProjectContent( + project: Project, + sourceLocale: Locale, + targetLocale: Locale, + translateTexts: RawTranslator, + targetLocaleText: LocaleText | undefined, + /** When true, REPLACE content with the target language (the program ends up + * written in the target language) instead of ADDING a target translation + * alongside the source. Used to localize embedded `\code\` examples so they + * read natively; the default (false) is the in-app "add a translation" + * behavior. References (including standard-library ones) are retargeted to + * the target locale's names in both modes. */ + replace = false, +): Promise { + const targetLanguage = targetLocale.language; + + try { + // Keep track of existing names in target language + const existingNames = new Set(); + + // collect existing names in target language + project.getSources().forEach((source) => { + source + .nodes() + .filter((node): node is Names => node instanceof Names) + .forEach((names) => { + const targetName = names + .getNameInLanguage(targetLanguage, undefined) + ?.getName(); + if (targetName) existingNames.add(targetName); + }); + }); + + // Find all of the names binds in the project's sources. We're going to add translated names to them, and update references to those names, if necessary. + // Convert the binds into a record of translations to perform. + const bindsToTranslate = project + .getSources() + .reduce( + (names: Names[], source) => [ + ...names, + ...source + .nodes() + .filter((node): node is Names => node instanceof Names), + ], + [], + ) + .map((names) => { + // Is there a name in the source language or a name with no language? Use that as the source name. + const nameToTranslate = names.names.find( + (name) => + name.isLanguage(sourceLocale.language) || + !name.hasLanguage(), + ); + + if (nameToTranslate === undefined) return undefined; + + // Get the name already in the target language, if there is one. Prefer full names, not symbolic names. + const targetName = names + .getNameInLanguage(targetLanguage, false) + ?.getName(); + + return { + names, + // The original text to translate, or undefined if there is no text to translate. + // Convert the camel cased name into separated words for better translation performance. + original: nameToTranslate + .getName() + ?.replace(SeparateWords, ' $&') + .trim(), + // The translation, or undefined if there is no translation yet. + translation: targetName, + }; + }) + // Skip any names that don't need a translation. + .filter( + ( + text, + ): text is { + names: Names; + original: string; + translation: string | undefined; + } => text !== undefined, + ); + + // Find all the docs and text literals in the program needing translation. + const textToTranslate = project + .getSources() + .reduce( + ( + markups: (Docs | FormattedLiteral | TextLiteral)[], + source, + ) => [ + ...markups, + ...source + .nodes() + .filter( + ( + node, + ): node is Docs | FormattedLiteral | TextLiteral => + node instanceof Docs || + node instanceof FormattedLiteral || + node instanceof TextLiteral, + ), + ], + [], + ) + // Filter out values that are input to evaluate binds that are literal text types, since they won't permit arbritrary text. + .filter((markup) => { + if (markup instanceof TextLiteral) { + const root = project.getRoot(markup); + if (root) { + const evaluates = root + .getAncestors(markup) + .filter((node) => node instanceof Evaluate); + for (const evaluate of evaluates) { + const source = project.getSourceOf(evaluate); + if (source === undefined) continue; + const inputs = evaluate.getInputMapping( + project.getContext(source), + ); + const input = inputs?.inputs.find( + (mapping) => + mapping.given === markup || + (mapping.given instanceof Input && + mapping.given.value === markup), + ); + const types = input?.expected + .getType(project.getContext(source)) + .getTypeSet(project.getContext(source)) + .list(); + if ( + types !== undefined && + types.some( + (t) => + t instanceof TextType && t.isLiteral(), + ) + ) + return false; + } + } + } + return true; + }) + .map((markups) => { + const docToTranslate = + markups.getLanguage(sourceLocale.language) ?? + markups.getOptions()[0]; + const existingTranslation = markups.getLanguage(targetLanguage); + + return { + names: markups, + original: + docToTranslate === undefined + ? undefined + : docToTranslate instanceof Translation + ? docToTranslate.segments + .filter( + (t): t is Token => t instanceof Token, + ) + .map((t) => t.toWordplay()) + .join('') + : docToTranslate.markup.paragraphs + .map((p) => + normalizeSoftBreaks(p.toWordplay()), + ) + .join('\n\n'), + translation: existingTranslation?.toWordplay(), + }; + }); + + // Get the original text with no translation to send to the translator. + const originalTexts = [...bindsToTranslate, ...textToTranslate] + .filter((bind) => bind.translation === undefined) + .map((bind) => bind.original) + .filter((text): text is string => text !== undefined); + + // Keep a record of the revised project to return. + let newProject = project; + + // Build a map from each unique original text to its translation, so lookups don't depend on positional indexes (which break when originalTexts has duplicates). + let translationByOriginal: Map | null = null; + // If there are more than one and the source and target are different, get some translations. + if ( + originalTexts.length > 0 && + sourceLocale.language !== targetLanguage + ) { + // Remove duplicates from the original texts to minimize cost. + const uniqueOriginals = Array.from(new Set(originalTexts)); + + // Sample the project's names and docs as domain context for the + // backend (bounded to keep the request small). + const context = { + names: bindsToTranslate + .map((b) => b.original) + .filter((n): n is string => n !== undefined) + .slice(0, 30), + docs: textToTranslate + .map((t) => t.original) + .filter((d): d is string => d !== undefined) + .slice(0, 5), + }; + + const translations = await translateTexts( + uniqueOriginals, + sourceLocale, + targetLocale, + context, + ); + + // If we didn't get any translations, return nothing as an indicator of failure. + if (translations === null) return null; + + translationByOriginal = new Map(); + uniqueOriginals.forEach((original, index) => { + const translated = translations[index]; + if (typeof translated === 'string') + translationByOriginal!.set(original, translated); + }); + } + + // First, revise the project to contain the target locale, so we have names from the locale. + if (targetLocaleText) + newProject = newProject.withPrimaryLocale(targetLocaleText); + + // Revise the project to include the new translated names and updated references to those new names. + // Compute the target-language name for each bind (camel-cased, + // collision-free), keyed by its Names node. We resolve names up front so + // references can be retargeted while the source name still resolves — + // essential in replace mode, where the source name is then removed. + const targetNameByNames = new Map(); + for (const bindToTranslate of bindsToTranslate) { + const { names, original } = bindToTranslate; + // If we already have a translation, use it. + let translation = bindToTranslate.translation; + // If we don't, and there was an original text, and we have some translations, then use the translation. + if ( + translation === undefined && + original !== undefined && + translationByOriginal + ) { + const translated = translationByOriginal.get(original); + if (translated === undefined) continue; + // Convert the translated text into camel case, to conform to + // Wordplay's naming rules. Split on spaces AND underscores (a + // translator may snake_case a name; `_` is the reserved + // placeholder symbol, never a valid name character), and drop + // empties so leading/trailing separators don't add stray casing. + translation = translated + .split(/[ _]+/u) + .filter((word) => word.length > 0) + .map((word, i) => + i === 0 + ? word.toLowerCase() + : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(''); + if (existingNames.has(translation)) { + let counter = 2; + // Increment the counter until a unique name is found + while (existingNames.has(`${translation}${counter}`)) { + counter++; + } + translation = `${translation}${counter}`; + } + + //Add the unique translation to the set + existingNames.add(translation); + } + + if (translation !== undefined) + targetNameByNames.set(names, translation); + } + + // Retarget every reference to its definition's target-language name. For + // creator binds we use the freshly-computed name (so this works before + // the name change is applied); standard-library references fall back to + // the definition's name in the target locale (present via the + // withPrimaryLocale above). + newProject = newProject.withRevisedNodes( + newProject + .getSources() + .reduce( + ( + references: { reference: Reference; source: Source }[], + source, + ) => [ + ...references, + ...source + .nodes() + .filter( + (node): node is Reference => + node instanceof Reference, + ) + .map((reference) => ({ reference, source })), + ], + [], + ) + .map(({ reference, source }) => { + const definition = reference.resolve( + newProject.getContext(source), + ); + + // Find the references to this bind so we can replace them with the new name + if (definition === undefined) return [reference, reference]; + + // Get the name in the target language. + const parent = newProject + .getRoot(reference) + ?.getParent(reference); + const infix = + parent instanceof BinaryEvaluate && + parent.fun === reference + ? true + : undefined; + const translation = + targetNameByNames.get(definition.names) ?? + definition.names + .getNameInLanguage(targetLanguage, infix) + ?.getName() ?? + definition.names.names + .find((name) => !name.hasLanguage()) + ?.getName(); + + if ( + translation === undefined || + reference.getName() === translation + ) + return [reference, reference]; + + return [reference, Reference.make(translation)]; + }), + ); + + // Now apply the name change to each bind. In replace mode the bind + // becomes a single target-language name (so the program reads natively); + // otherwise the target name is added alongside the source. + newProject = newProject.withRevisedNodes( + bindsToTranslate.map(({ names }) => { + const translation = targetNameByNames.get(names); + if (translation === undefined) return [names, names]; + return [ + names, + replace + ? Names.make([translation]) + : names.withName(translation, targetLanguage), + ]; + }), + ); + + // Add the translated text to the project. + newProject = newProject.withRevisedNodes( + textToTranslate.map((textToTranslate) => { + const { names: markups, original } = textToTranslate; + let translation = textToTranslate.translation; + + // Already have a translation? No change. + if (translationByOriginal === null || translation !== undefined) + return [markups, markups]; + + if (original === undefined) return [markups, markups]; + const translated = translationByOriginal.get(original); + if (translated === undefined) return [markups, markups]; + + translation = translated; + + const [markup] = toMarkup(translation); + + // In replace mode the text becomes a single target-language + // option (so the program reads natively); otherwise the target + // is added as another option alongside the source. + return [ + markups, + markups instanceof TextLiteral + ? replace + ? TextLiteral.make(translation) + : markups.withOption( + Translation.make( + translation, + Language.make(targetLanguage), + ), + ) + : markups instanceof Docs + ? replace + ? Docs.make([ + withFormattedMarkupSpaces( + Doc.make(markup.paragraphs), + ), + ]) + : markups.withOption( + withFormattedMarkupSpaces( + Doc.make( + markup.paragraphs, + Language.make(targetLanguage), + ), + ), + ) + : replace + ? FormattedLiteral.make([ + withFormattedMarkupSpaces( + FormattedTranslation.make( + markup.paragraphs, + ), + ), + ]) + : markups.withOption( + withFormattedMarkupSpaces( + FormattedTranslation.make( + markup.paragraphs, + Language.make(targetLanguage), + ), + ), + ), + ]; + }), + ); + + // Tidy all sources + newProject = newProject.withRevisedNodes( + newProject.getSources().map((source) => { + return [ + source, + source.withSpaces( + getPreferredSpaces(source.root, source.spaces), + ), + ]; + }), + ); + + // Return the revised project + return newProject; + } catch (e) { + console.error('translateProjectContent failed:', e); + return null; + } +} diff --git a/src/locale/BasisTexts.ts b/src/locale/BasisTexts.ts index 4b091fbc94..78b777453d 100644 --- a/src/locale/BasisTexts.ts +++ b/src/locale/BasisTexts.ts @@ -1,10 +1,15 @@ -import type { DocText, FunctionText, NameAndDoc, NameText } from '@locale/LocaleText'; +import type { + DocText, + FunctionText, + NameAndDoc, + NameText, +} from '@locale/LocaleText'; const Empty = [] as const; type EmptyInputs = typeof Empty; export type BasisNameAndDoc = { - /** Documentation to explain what the type is for and how it's used. */ + /** [formatted] Documentation to explain what the type is for and how it's used. */ doc: DocText; /** [name] The name to use to describe the type of value. */ name: NameText; @@ -15,20 +20,20 @@ type BasisTexts = { Boolean: BasisNameAndDoc & { /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ and: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ or: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ not: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequal: FunctionText<[NameAndDoc]>; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; }; }; @@ -36,14 +41,14 @@ type BasisTexts = { None: BasisNameAndDoc & { /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequals: FunctionText<[NameAndDoc]>; }; /** Conversions in the type */ conversion: { - /** None to Text */ + /** [formatted] None to Text */ text: DocText; }; }; @@ -51,23 +56,23 @@ type BasisTexts = { Text: BasisNameAndDoc & { /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ length: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ has: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ starts: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ ends: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ repeat: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ segment: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ combine: FunctionText<[NameAndDoc]>; /** `≈` — whether a pattern matches the whole text. See LANGUAGE.md. */ matches: FunctionText<[NameAndDoc]>; @@ -76,11 +81,11 @@ type BasisTexts = { }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ list: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ number: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ formatted: DocText; }; }; @@ -88,26 +93,26 @@ type BasisTexts = { Formatted: BasisNameAndDoc & { /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ length: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ has: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ starts: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ ends: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ repeat: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ combine: FunctionText<[NameAndDoc]>; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; }; }; @@ -115,322 +120,322 @@ type BasisTexts = { Number: BasisNameAndDoc & { /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ add: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ subtract: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ multiply: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ divide: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ remainder: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ positive: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ round: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ roundDown: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ roundUp: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ power: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ root: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ lessThan: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ greaterThan: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ lessOrEqual: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ greaterOrEqual: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equal: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequal: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ cos: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ sin: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ min: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ max: FunctionText<[NameAndDoc]>; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ list: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ s2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ s2h: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ s2day: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ s2wk: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ s2year: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ s2ms: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ ms2s: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ min2s: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ h2s: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ day2s: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ wk2s: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ yr2s: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2pm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2nm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2micro: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2mm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2cm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2dm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2km: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2Mm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2Gm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2Tm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ pm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ nm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ micro2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ mm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ cm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ dm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ km2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ Mm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ Gm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ Tm2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ km2mi: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ mi2km: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ cm2in: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ in2cm: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ m2ft: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ ft2m: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ g2mg: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ mg2g: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ g2kg: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ kg2g: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ g2oz: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ oz2g: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ oz2lb: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ lb2oz: DocText; }; }; /** A list value, e.g., `[1 2 3]` */ List: BasisNameAndDoc & { - /** The type variable name for the kind of values in the list */ + /** [name] The type variable name for the kind of values in the list */ kind: NameText; - /** The type variable name to use for functions that produce lists of different types */ + /** [name] The type variable name to use for functions that produce lists of different types */ out: NameText; - /** The name of the exception when an index is out of bounds of a list's values */ + /** [name] The name of the exception when an index is out of bounds of a list's values */ outofbounds: NameText; /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ add: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ append: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ replace: FunctionText<[NameAndDoc, NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ length: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ random: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ shuffled: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ first: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ last: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ has: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ join: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ subsequence: FunctionText<[NameAndDoc, NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ sansFirst: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ sansLast: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ sans: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ sansAll: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ reverse: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ translate: FunctionText<[NameAndDoc]> & { translator: [NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ filter: FunctionText<[NameAndDoc]> & { checker: [NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ all: FunctionText<[NameAndDoc]> & { checker: [NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ until: FunctionText<[NameAndDoc]> & { checker: [NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ find: FunctionText<[NameAndDoc]> & { checker: [NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ combine: FunctionText<[NameAndDoc, NameAndDoc]> & { combiner: [NameAndDoc, NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ sorted: FunctionText<[NameAndDoc]> & { sequencer: [NameAndDoc] }; }; - /** Conversions in the type */ + /** [formatted] Conversions in the type */ conversion: { text: DocText; set: DocText }; }; /** A set value, `{1 2 3}` */ Set: BasisNameAndDoc & { - /** The type variable name for the kind of value in the set */ + /** [name] The type variable name for the kind of value in the set */ kind: NameText; - /** The type variable name for sets that produce sets of different value types */ + /** [name] The type variable name for sets that produce sets of different value types */ out: NameText; /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ size: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ add: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ remove: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ union: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ intersection: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ difference: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ filter: FunctionText<[NameAndDoc]> & { checker: [NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ translate: FunctionText<[NameAndDoc]> & { translator: [NameAndDoc, NameAndDoc]; }; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ list: DocText; }; }; Map: BasisNameAndDoc & { - /** The type variable name for the map's type of keys */ + /** [name] The type variable name for the map's type of keys */ key: NameText; - /** The type variable name for the map's type of values */ + /** [name] The type variable name for the map's type of values */ value: NameText; - /** The type variable name for higher order functions that produce maps of different types */ + /** [name] The type variable name for higher order functions that produce maps of different types */ result: NameText; /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ size: FunctionText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ set: FunctionText<[NameAndDoc, NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ unset: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ remove: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ filter: FunctionText<[NameAndDoc]> & { checker: [NameAndDoc, NameAndDoc, NameAndDoc]; }; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ translate: FunctionText<[NameAndDoc]> & { translator: [NameAndDoc, NameAndDoc, NameAndDoc]; }; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ set: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ list: DocText; }; }; /** A table value type, e.g., `⎡a•# b•#⎦⎡1 2⎦` */ Table: BasisNameAndDoc & { - /** The type variable name for the type of row in a table */ + /** [name] The type variable name for the type of row in a table */ row: NameText; /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequal: FunctionText<[NameAndDoc]>; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ list: DocText; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; }; }; @@ -438,14 +443,14 @@ type BasisTexts = { Structure: BasisNameAndDoc & { /** Functions in the type */ function: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ equals: FunctionText<[NameAndDoc]>; - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ notequal: FunctionText<[NameAndDoc]>; }; /** Conversions in the type */ conversion: { - /** See `en-US.json` for documentation */ + /** [formatted] See `en-US.json` for documentation */ text: DocText; }; }; diff --git a/src/locale/Glossary.test.ts b/src/locale/Glossary.test.ts new file mode 100644 index 0000000000..b9e4869e9a --- /dev/null +++ b/src/locale/Glossary.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'vitest'; +import DefaultLocale from '@locale/DefaultLocale'; +import { getGlossaryForPrompt } from '@locale/Glossary'; + +test('with no target, lists the en-US word only (no mapping)', () => { + const out = getGlossaryForPrompt(undefined); + // The `value` term shows as the plain en-only form. + expect(out).toContain('- value:'); + expect(out).not.toContain('- "value" ->'); +}); + +test('maps each en-US term to its target-language word', () => { + const target = { + ...DefaultLocale, + glossary: { + ...DefaultLocale.glossary, + value: { ...DefaultLocale.glossary.value, word: 'valor' }, + }, + }; + expect(getGlossaryForPrompt(target)).toContain('- "value" -> "valor":'); +}); + +test('strips annotation markers from the target word', () => { + const target = { + ...DefaultLocale, + glossary: { + ...DefaultLocale.glossary, + value: { ...DefaultLocale.glossary.value, word: '$~valor' }, + }, + }; + expect(getGlossaryForPrompt(target)).toContain('- "value" -> "valor":'); +}); + +test('falls back to the en-only form when the target word equals en or is empty', () => { + const target = { + ...DefaultLocale, + glossary: { + ...DefaultLocale.glossary, + value: { ...DefaultLocale.glossary.value, word: '$?' }, + }, + }; + const out = getGlossaryForPrompt(target); + expect(out).toContain('- value:'); + expect(out).not.toContain('- "value" ->'); +}); diff --git a/src/locale/Glossary.ts b/src/locale/Glossary.ts new file mode 100644 index 0000000000..8e39de3820 --- /dev/null +++ b/src/locale/Glossary.ts @@ -0,0 +1,54 @@ +import DefaultLocale from '@locale/DefaultLocale'; +import type Locales from '@locale/Locales'; +import type LocaleText from '@locale/LocaleText'; +import { withoutAnnotations } from '@locale/withoutAnnotations'; +import type Markup from '@nodes/Markup'; + +/** + * A plain-text glossary block for the translation prompt: each en-US term's word + * and a simplified definition (cross-reference markers stripped). When `target` + * is given, each term shows its target-language word too — `"" -> ""` + * — so the model translates bare occurrences of the term to that word (the point + * of the glossary) rather than leaving English. Pass `undefined` (e.g. before the + * target glossary is translated) to get the en-only form. + */ +export function getGlossaryForPrompt(target: LocaleText | undefined): string { + // Map target glossary ids → localized word (markers stripped). Iterate + // entries so a string id needs no unsafe keyof cast. + const targetWords = new Map(); + if (target !== undefined) + for (const [id, entry] of Object.entries(target.glossary)) { + const word = withoutAnnotations(entry.word).trim(); + if (word.length > 0) targetWords.set(id, word); + } + return Object.entries(DefaultLocale.glossary) + .map(([id, entry]) => { + const def = entry.definition.replace(/[$@]([A-Za-z]+)/g, '$1'); + const targetWord = targetWords.get(id); + return targetWord !== undefined && targetWord !== entry.word + ? `- "${entry.word}" -> "${targetWord}": ${def}` + : `- ${entry.word}: ${def}`; + }) + .join('\n'); +} + +/** The raw (unconcretized) definition string for a glossary id in one locale, + * or '' if absent. Iterates entries so a runtime string id needs no unsafe + * keyof cast. Use as a `LocaleTextAccessor`, e.g. with `getMultilingualMarkup`. */ +export function getTermDefinitionString( + locale: LocaleText, + id: string, +): string { + for (const [key, entry] of Object.entries(locale.glossary)) + if (key === id) return entry.definition; + return ''; +} + +/** + * The concretized definition of a glossary term as Markup — its `@term` and + * `@Concept` cross-references resolved — for display in the glossary UI. Use + * `.toText()` for a plain-text form (e.g. a tooltip). + */ +export function getTermDefinition(locales: Locales, id: string): Markup { + return locales.concretize((l) => getTermDefinitionString(l, id)); +} diff --git a/src/locale/GlossaryTexts.ts b/src/locale/GlossaryTexts.ts new file mode 100644 index 0000000000..2e5735819b --- /dev/null +++ b/src/locale/GlossaryTexts.ts @@ -0,0 +1,62 @@ +import type { FormattedText } from '@locale/LocaleText'; + +/** One glossary entry: a localized word and a learner-facing definition. */ +export type GlossaryText = { + /** [plain] The word or short phrase for this term, shown wherever a @term reference to it appears. */ + word: string; + /** [formatted] A short, simple definition of the term, written for young learners. */ + definition: FormattedText; +}; + +/** + * The ids of Wordplay's glossary terms — vocabulary that is widely used but not + * already defined by a language or API concept (concepts define themselves in + * their docs). Replaces the former flat `term` block; see + * [Glossary](src/locale/Glossary.ts) and the migration in the translation work. + */ +export type GlossaryId = + | 'value' + | 'type' + | 'expression' + | 'variable' + | 'parameter' + | 'argument' + | 'operator' + | 'condition' + | 'loop' + | 'iteration' + | 'recursion' + | 'property' + | 'element' + | 'scope' + | 'state' + | 'sideEffect' + | 'abstraction' + | 'project' + | 'code' + | 'act' + | 'how' + | 'gallery' + | 'blocks' + | 'placeholder' + | 'conflict' + | 'checkpoint' + | 'guide' + | 'tutorial' + | 'name' + | 'markup' + | 'language' + | 'documentation' + | 'region' + | 'start' + | 'query' + | 'key' + | 'index' + | 'moved' + | 'entered' + | 'cell' + | 'column'; + +type GlossaryTexts = Record; + +export { type GlossaryTexts as default }; diff --git a/src/locale/InputTexts.ts b/src/locale/InputTexts.ts index 35e8ce93df..ede3bd3fef 100644 --- a/src/locale/InputTexts.ts +++ b/src/locale/InputTexts.ts @@ -19,7 +19,7 @@ type InputTexts = { key: NameAndDoc; /** The optional up or down state to notify about */ down: NameAndDoc; - /** Per-locale translations for the named keys `Key()` can emit + /** [plain] Per-locale translations for the named keys `Key()` can emit * (Space, Enter, ArrowUp, etc.). Keyed by the canonical English * `KeyboardEvent.key` string from [KeyboardKeys.ts](src/input/KeyboardKeys.ts). * Each value is an array whose first entry is this locale's display diff --git a/src/locale/LanguageCode.test.ts b/src/locale/LanguageCode.test.ts index fc25396198..5117c888a3 100644 --- a/src/locale/LanguageCode.test.ts +++ b/src/locale/LanguageCode.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import { + getCLDRCandidates, GoogleTranslateCodeOverrides, Languages, Translatable, @@ -89,6 +90,21 @@ test('languages Google Translate does not support are not offered', () => { ).not.toContain(code); }); +test('getCLDRCandidates derives CLDR codes from language metadata', () => { + // Region variant first, then base (es_MX → es). + expect(getCLDRCandidates('es', 'MX')).toEqual(['es_MX', 'es']); + // Base code with a region that has no special CLDR (ne_NP 404s → ne). + expect(getCLDRCandidates('ne', 'NP')).toEqual(['ne_NP', 'ne']); + // `cldr` override (Tagalog → fil) becomes the base. + expect(getCLDRCandidates('tl', 'PH')).toEqual(['fil_PH', 'fil']); + // `cldrByRegion` script override comes first (Chinese in TW → Traditional). + expect(getCLDRCandidates('zh', 'TW')).toEqual(['zh_Hant', 'zh_TW', 'zh']); + // A region without a script override falls to the base. + expect(getCLDRCandidates('zh', 'CN')).toEqual(['zh_CN', 'zh']); + // No region → just the base. + expect(getCLDRCandidates('en', undefined)).toEqual(['en']); +}); + test('code overrides map to the code Google expects', () => { expect(GoogleTranslateCodeOverrides).toMatchObject({ nb: 'no', diff --git a/src/locale/LanguageCode.ts b/src/locale/LanguageCode.ts index 4624a94aac..9cab7d4dd0 100644 --- a/src/locale/LanguageCode.ts +++ b/src/locale/LanguageCode.ts @@ -37,6 +37,13 @@ type LanguageMetadata = { * for relative ordering, not statistical claims. * */ speakers?: number; + /** The Unicode CLDR emoji-annotation base code, when it differs from this + * language code (e.g. Tagalog `tl` → `fil`). Used by the emoji generator + * (see `getCLDRCandidates`); defaults to the language code when omitted. */ + cldr?: string; + /** Region-specific CLDR emoji-annotation code overrides, for script variants + * a region implies (e.g. Chinese in `TW` → Traditional `zh_Hant`). */ + cldrByRegion?: Partial>; }; /** Wordplay `LanguageCode`s that Google Cloud Translate can translate. This is @@ -1535,6 +1542,7 @@ export const Languages = { scripts: ['Latn', 'Tglg'], regions: ['PH', 'US'], speakers: 87, + cldr: 'fil', // CLDR uses the Filipino code for Tagalog emoji annotations. }, tn: { name: 'Setswana', @@ -1702,6 +1710,9 @@ export const Languages = { scripts: ['Hans', 'Hant', 'Hani', 'Bopo'], regions: ['CN', 'TW', 'HK', 'SG', 'MO', 'MY'], speakers: 1140, + // Traditional-script regions use CLDR's zh_Hant annotations; CN/SG use + // the base zh (Simplified). + cldrByRegion: { TW: 'zh_Hant', HK: 'zh_Hant', MO: 'zh_Hant' }, }, zu: { name: 'isiZulu', @@ -2233,6 +2244,29 @@ export const Languages = { type LanguageCode = keyof typeof Languages; export { type LanguageCode as default }; +/** + * The Unicode CLDR emoji-annotation locale codes to try for a given language and + * region, most-specific first (the emoji generator uses the first that exists, + * falling back through the rest, then to English). The base is the language's + * `cldr` override or its own code; a `cldrByRegion` entry (e.g. Chinese in TW → + * `zh_Hant`) and the generic `base_REGION` form (e.g. `es_MX`) are tried ahead of + * the base. Centralized here so CLDR mappings live with the language metadata + * rather than in the generator script. + */ +export function getCLDRCandidates( + language: LanguageCode, + region: RegionCode | undefined, +): string[] { + const meta: LanguageMetadata = Languages[language]; + const base = meta.cldr ?? language; + const candidates = [ + region ? meta.cldrByRegion?.[region] : undefined, + region ? `${base}_${region}` : undefined, + base, + ]; + return [...new Set(candidates.filter((c): c is string => c !== undefined))]; +} + export const PossibleLanguages: LanguageCode[] = Object.keys( Languages, ) as LanguageCode[]; diff --git a/src/locale/LocaleText.ts b/src/locale/LocaleText.ts index 1a1dcfb965..ea40ad6be0 100644 --- a/src/locale/LocaleText.ts +++ b/src/locale/LocaleText.ts @@ -16,7 +16,7 @@ import type { KeywordId } from '@parser/Keywords'; import type OutputTexts from '@locale/OutputTexts'; import { Regions, type RegionCode } from '@locale/Regions'; import { DraftLocales } from '@locale/SupportedLocales'; -import type TermTexts from '@locale/TermTexts'; +import type GlossaryTexts from '@locale/GlossaryTexts'; import type UITexts from '@locale/UITexts'; import { withoutAnnotations } from '@locale/withoutAnnotations'; @@ -34,8 +34,8 @@ export type LocaleText = { regions: RegionCode[]; /** [plain] The name of the platform */ wordplay: string; - /** Common vocabulary that can be used in documentation and descriptions. */ - term: TermTexts; + /** A glossary of widely-used terms not already defined by a concept, each with a localized word and a learner-facing definition. Words are referenced symbolically as @term in documentation and descriptions. */ + glossary: GlossaryTexts; /** [plain] Descriptions of all token categories. See Sym.ts for the symbol or symbol category that each represents. */ token: Record; /** [name] The localized word for each built-in keyword, written and read interchangeably with its symbol. Each must be a single token (no spaces or hyphens). See LANGUAGE.md and Keywords.ts. */ @@ -67,7 +67,9 @@ export type LocaleText = { export { type LocaleText as default }; -/** Represents a string that is in Wordplay markup formatted syntax. */ +/** [formatted] Represents a string that is in Wordplay markup formatted syntax. + * Tagging the alias makes any bare `FormattedText` field default to the + * formatted editor; fields with their own (untagged) comment are still flagged. */ export type FormattedText = string; /** @@ -94,10 +96,10 @@ export type FunctionText = NameAndDoc & { inputs: Inputs; }; -/** A single name or a list of names, all valid Wordplay names */ +/** [name] A single name or a list of names, all valid Wordplay names */ export type NameText = string | string[]; -/** Wordplay markup, a single paragraph or a list of paragraphs. */ +/** [formatted] Wordplay markup, a single paragraph or a list of paragraphs. */ export type DocText = string | string[]; export function toLocaleString(locale: Locale) { @@ -160,9 +162,7 @@ export function getLocaleLanguage(locale: string): LanguageCode | undefined { /** All language codes in a locale string or Locale. For `es_en-MX` (string) * or a Locale with `multilingual: ['es', 'en']`, returns `['es', 'en']`. * For a monolingual Locale, returns just `[locale.language]`. */ -export function getLocaleLanguages( - locale: string | Locale, -): LanguageCode[] { +export function getLocaleLanguages(locale: string | Locale): LanguageCode[] { if (typeof locale !== 'string') return locale.multilingual ?? [locale.language]; const { languages } = splitLocaleString(locale); @@ -185,9 +185,7 @@ export function getLocaleLanguageName( /** Localized display of every language in a multilingual tag, joined with * ` + `. Returns the single language's name for monolingual tags so it * is safe to use as a drop-in for `getLocaleLanguageName`. */ -export function getMultilingualLanguageLabel( - locale: string | Locale, -): string { +export function getMultilingualLanguageLabel(locale: string | Locale): string { const codes = getLocaleLanguages(locale); if (codes.length === 0) return ''; const names = codes.map((code) => Languages[code]?.name ?? code); diff --git a/src/locale/Locales.multilingual.test.ts b/src/locale/Locales.multilingual.test.ts index b23cc90f1f..571473a7f7 100644 --- a/src/locale/Locales.multilingual.test.ts +++ b/src/locale/Locales.multilingual.test.ts @@ -5,16 +5,19 @@ import type LocaleText from '@locale/LocaleText'; import Locales, { MULTILINGUAL_SEPARATOR } from '@locale/Locales'; import { describe, expect, test } from 'vitest'; -/** Build a locale that's like en-US but with a different language and `term.start`. */ +/** Build a locale that's like en-US but with a different language and `glossary.start.word`. */ function localeWith(language: string, start: string): LocaleText { return { ...DefaultLocale, language, - term: { ...DefaultLocale.term, start }, + glossary: { + ...DefaultLocale.glossary, + start: { ...DefaultLocale.glossary.start, word: start }, + }, } as unknown as LocaleText; } -const en = DefaultLocale; // term.start === 'start' +const en = DefaultLocale; // glossary.start.word === 'start' const es = localeWith('es', 'empezar'); const esUnwritten = localeWith('es', Unwritten); @@ -22,7 +25,7 @@ function locales(...preferred: LocaleText[]) { return new Locales(concretize, preferred, DefaultLocale); } -const start = (l: LocaleText) => l.term.start; +const start = (l: LocaleText) => l.glossary.start.word; describe('getSecondaryLocaleViews', () => { test('is empty for a single chosen locale', () => { @@ -61,9 +64,10 @@ describe('getMultilingualEntries', () => { }); test('dedupes a secondary that equals the primary', () => { - const entries = locales(en, localeWith('es', 'start')).getMultilingualEntries( - start, - ); + const entries = locales( + en, + localeWith('es', 'start'), + ).getMultilingualEntries(start); expect(entries.map((e) => e.text)).toEqual(['start']); }); }); @@ -98,8 +102,8 @@ describe('getMultilingualMarkup', () => { describe('getMultilingualFrom', () => { test('formats a structure subtree per chosen locale', () => { const entries = locales(en, es).getMultilingualFrom( - (l) => l.term, - (term) => term.start, + (l) => l.glossary, + (glossary) => glossary.start.word, ); expect(entries.map((e) => e.text)).toEqual(['start', 'empezar']); }); diff --git a/src/locale/Locales.ts b/src/locale/Locales.ts index 5ee7c1db24..f173e27f3a 100644 --- a/src/locale/Locales.ts +++ b/src/locale/Locales.ts @@ -14,11 +14,7 @@ import { } from '@locale/LanguageCode'; import { localeToString } from '@locale/Locale'; import type LocaleText from '@locale/LocaleText'; -import { - isUnwritten, - toLocaleString, - type Template, -} from '@locale/LocaleText'; +import { isUnwritten, toLocaleString, type Template } from '@locale/LocaleText'; import type NodeRef from '@locale/NodeRef'; import type { Script, WritingDirection } from '@locale/Scripts'; import type ValueRef from '@locale/ValueRef'; @@ -465,9 +461,12 @@ export default class Locales { } getTermByID(id: string) { - const locale = this.getLocale(); - const term = id as keyof LocaleText['term']; - return Object.hasOwn(locale.term, term) ? locale.term[term] : undefined; + // Glossary entries are { word, definition }; the word is the display + // term used when an @term reference appears. Iterate entries to look up + // by a runtime string id without an unsafe keyof cast. + for (const [key, entry] of Object.entries(this.getLocale().glossary)) + if (key === id) return entry.word; + return undefined; } getName(names: Names, symbolic = true) { diff --git a/src/locale/ModerationTexts.ts b/src/locale/ModerationTexts.ts index 5f8e542d81..e6f1cec6b0 100644 --- a/src/locale/ModerationTexts.ts +++ b/src/locale/ModerationTexts.ts @@ -11,7 +11,7 @@ export type ModerationTexts = { unmoderated: HeaderAndExplanationText; /** Moderation view text */ moderate: HeaderAndExplanationText; - /** Content moderation rules that creators promise to follow. See en-US.json for ground truth language. */ + /** [formatted] Content moderation rules that creators promise to follow. See en-US.json for ground truth language. */ flags: FlagDescriptions; /** [formatted] Progress message */ progress: Template<['moderated', 'remaining']>; diff --git a/src/locale/NodeTexts.ts b/src/locale/NodeTexts.ts index ee59261841..4bafda7da6 100644 --- a/src/locale/NodeTexts.ts +++ b/src/locale/NodeTexts.ts @@ -4,7 +4,7 @@ import type { DocText, FormattedText, Template } from '@locale/LocaleText'; export type NodeText = { /** [name] The name that should be used to refer to the node type */ name: string; - /** Documentation text that appears in the documentation view */ + /** [formatted] Documentation text that appears in the documentation view */ doc: DocText; /** [emotion] The emotion that should be conveyed in animations of the node type */ emotion: `${Emotion}`; @@ -1195,6 +1195,7 @@ type NodeTexts = { */ UnparsableConflict: { conflict: ConflictText<['expression']>; + /** [formatted] Suggested fix for an unparsable expression or type */ resolution: Template<['first', 'second']>; }; /** diff --git a/src/locale/SupportedLocales.ts b/src/locale/SupportedLocales.ts index 4369f14ab2..d42ddbbf85 100644 --- a/src/locale/SupportedLocales.ts +++ b/src/locale/SupportedLocales.ts @@ -24,7 +24,8 @@ export const DraftLocales = [ 'bn-BD', 'id-ID', 'ro-RO', - 'pt-PT' + 'pt-PT', + 'ne-NP', ]; /** Supported locale names. Put a locale in this list when it's no longer a draft. */ diff --git a/src/locale/TermRef.ts b/src/locale/TermRef.ts new file mode 100644 index 0000000000..a035d2203b --- /dev/null +++ b/src/locale/TermRef.ts @@ -0,0 +1,26 @@ +/** + * A resolved reference to a glossary term, produced when an `@term` reference + * resolves to a glossary entry. Mirrors {@link ConceptRef}: a tiny plain object + * (not an AST node) that flows through markup concretization as a segment and is + * rendered interactively by `TermView`. Carries the glossary id (to look up the + * definition) and the localized word (what to display). + */ +export default class TermRef { + /** The glossary id, e.g. 'value' — used to resolve the definition. */ + readonly id: string; + /** The localized word to display, e.g. 'value' / 'valor'. */ + readonly word: string; + + constructor(id: string, word: string) { + this.id = id; + this.word = word; + } + + getDescription() { + return this.word; + } + + toText() { + return this.word; + } +} diff --git a/src/locale/TermTexts.ts b/src/locale/TermTexts.ts deleted file mode 100644 index 1f4fb9c666..0000000000 --- a/src/locale/TermTexts.ts +++ /dev/null @@ -1,102 +0,0 @@ -type TermTexts = { - /** [plain] The phrase to use to describe when values are bound to names, e.g., 'num: 5' */ - bind: string; - /** [plain] The phrase to use to describe */ - evaluate: string; - /** [plain] The phrase to use to describe conditional logic */ - decide: string; - /** [plain] What to call documentation in code */ - document: string; - /** [plain] What to call a Wordplay project */ - project: string; - /** [plain] What to call code */ - code: string; - /** [plain] What to call a Wordplay project source file */ - source: string; - /** [plain] What to call data that goes into a program */ - input: string; - /** [plain] What to call data that comes out of a program */ - output: string; - /** [plain] The verb for converting data from one type to another */ - convert: string; - /** [plain] The phrase for describing a how to concept */ - how: string; - /** [plain] The word for the top level organizational scheme of the tutorial, as in a dramatic play */ - act: string; - /** [plain] The word for the a comoponent of an act in a dramatic play */ - scene: string; - /** [plain] The word for phrase output in a Wordplay program */ - phrase: string; - /** [plain] The word for a group of output in a Wordplay program */ - group: string; - /** [plain] The word for the visual stage on which output is displayed */ - stage: string; - /** [plain] The word for a data type */ - type: string; - /** [plain] What to call the main source in a project. */ - start: string; - /** [plain] How to describe output that has entered for the first time */ - entered: string; - /** [plain] How to describe output that has changed */ - changed: string; - /** [plain] How to describe output that has moved */ - moved: string; - /** [name] How to refer to names */ - name: string; - /** [plain] What to call a data value */ - value: string; - /** [plain] What to call a boolean value */ - boolean: string; - /** [plain] What to call a pattern value */ - pattern: string; - /** [plain] What to call a table value */ - table: string; - /** [plain] What to call a table column */ - column: string; - /** [plain] What to call a table cell */ - cell: string; - /** [plain] What to call a row value */ - row: string; - /** [plain] What to call a list value */ - list: string; - /** [plain] What to call a map value */ - map: string; - /** [plain] What to call a text value */ - text: string; - /** [plain] What to call a number value */ - number: string; - /** [plain] What to call a number unit */ - unit: string; - /** [plain] What to call rich text */ - markup: string; - /** [plain] What to call a function value */ - function: string; - /** [plain] What to call a none value */ - none: string; - /** [plain] What to call an exception value */ - exception: string; - /** [plain] What to call a set value */ - set: string; - /** [plain] What to call a structure value */ - structure: string; - /** [plain] What to call a stream value */ - stream: string; - /** [plain] What to call an index into a list value */ - index: string; - /** [plain] What to call a query a table value */ - query: string; - /** [plain] What to call a key in a map */ - key: string; - /** [plain] What to call help in help/feedback links */ - help: string; - /** [plain] What to call feedback in help/feedback links */ - feedback: string; - /** [plain] What to call language tags */ - language: string; - /** [plain] What to call region tags */ - region: string; - /** [plain] What to call documentation */ - documentation: string; -}; - -export { type TermTexts as default }; diff --git a/src/locale/UITexts.ts b/src/locale/UITexts.ts index 91d1f1be76..ae6f9e0564 100644 --- a/src/locale/UITexts.ts +++ b/src/locale/UITexts.ts @@ -75,9 +75,9 @@ export type FieldText = { type UITexts = { font: { - /** The application font to use throughout the application. Should support the language used in this locale so that characters render correctly. Add the face to Fonts.ts if the one you choose is not yet supported. */ + /** [plain] The application font to use throughout the application. Should support the language used in this locale so that characters render correctly. Add the face to Fonts.ts if the one you choose is not yet supported. */ app: SupportedFace; - /** The monospace font to use for code in the editor and code examples. Should support the language used in this locale so that characters render correctly. Add the face to Fonts.ts if the one you choose is not yet supported. */ + /** [plain] The monospace font to use for code in the editor and code examples. Should support the language used in this locale so that characters render correctly. Add the face to Fonts.ts if the one you choose is not yet supported. */ code: SupportedFace; /** [plain] The word shown before the markup symbols that a font face doesn't support (e.g. "missing * ^" for a face without bold or extra bold) */ missing: string; diff --git a/src/locale/concretize.test.ts b/src/locale/concretize.test.ts index 12460b8567..c96dfe44ed 100644 --- a/src/locale/concretize.test.ts +++ b/src/locale/concretize.test.ts @@ -13,15 +13,13 @@ test.each([ {}, ], [ - 'To create a new $project, click here.', + // Glossary terms are referenced with `@term` (resolved by ConceptLink to + // the localized word), not `$term`. + 'To create a new @project, click here.', 'To create a new project, click here.', {}, ], - [ - 'I am $1 ??', - DefaultLocale.ui.template.unparsable + ': I am $1 ??', - {}, - ], + ['I am $1 ??', DefaultLocale.ui.template.unparsable + ': I am $1 ??', {}], ['I received $a[$a|nothing]', 'I received nothing', { a: undefined }], ['I received $a[$a|nothing]', 'I received 1', { a: 1 }], [ diff --git a/src/locale/en-US.json b/src/locale/en-US.json index 580a0037ae..e6b133c702 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -3,56 +3,171 @@ "language": "en", "regions": ["US"], "wordplay": "Wordplay", - "term": { - "bind": "bind", - "evaluate": "evaluate", - "decide": "decide", - "project": "project", - "document": "explain", - "source": "source", - "code": "code", - "input": "input", - "output": "output", - "convert": "convert", - "how": "how-to", - "act": "act", - "scene": "scene", - "phrase": "phrase", - "group": "group", - "stage": "stage", - "type": "type", - "start": "start", - "entered": "new", - "changed": "changed", - "moved": "moved", - "name": "name", - "value": "value", - "text": "text", - "boolean": "boolean", - "pattern": "pattern", - "map": "map", - "number": "number", - "unit": "unit", - "markup": "markup", - "function": "function", - "exception": "exception", - "table": "table", - "column": "column", - "row": "row", - "cell": "cell", - "none": "none", - "list": "list", - "stream": "stream", - "structure": "structure", - "index": "index", - "query": "query", - "set": "set", - "key": "key", - "help": "Help", - "feedback": "feedback", - "documentation": "documentation", - "language": "language", - "region": "region" + "glossary": { + "value": { + "word": "value", + "definition": "A piece of information, like a number, some words, or a list." + }, + "type": { + "word": "type", + "definition": "The kind of a value, such as number or text. A value's type decides what you can do with it." + }, + "expression": { + "word": "expression", + "definition": "A piece of code that produces a value when it runs." + }, + "variable": { + "word": "variable", + "definition": "A name whose value can change while a program runs." + }, + "parameter": { + "word": "parameter", + "definition": "A named input that a function expects when you call it." + }, + "argument": { + "word": "argument", + "definition": "A value you give to a function for one of its parameters." + }, + "operator": { + "word": "operator", + "definition": "A symbol, such as + or -, that combines or compares values." + }, + "condition": { + "word": "condition", + "definition": "A true-or-false test that decides what a program does next." + }, + "loop": { + "word": "loop", + "definition": "Code that repeats an action many times." + }, + "iteration": { + "word": "iteration", + "definition": "One pass through a loop." + }, + "recursion": { + "word": "recursion", + "definition": "When a function calls itself to handle a smaller part of a problem." + }, + "property": { + "word": "property", + "definition": "A named part of a value, such as a color's brightness." + }, + "element": { + "word": "element", + "definition": "One item in a list or group." + }, + "scope": { + "word": "scope", + "definition": "The part of a program where a name can be used." + }, + "state": { + "word": "state", + "definition": "Information a program keeps and can change over time." + }, + "sideEffect": { + "word": "side effect", + "definition": "A change a program makes besides giving back a value, such as showing output." + }, + "abstraction": { + "word": "abstraction", + "definition": "Hiding details so you can use something without knowing how it works inside." + }, + "project": { + "word": "project", + "definition": "All the files that make up one Wordplay program." + }, + "code": { + "word": "code", + "definition": "The text you write to tell a program what to do." + }, + "act": { + "word": "act", + "definition": "A large part of the tutorial, like an act in a play." + }, + "how": { + "word": "how-to", + "definition": "A short guide that shows how to do one thing." + }, + "gallery": { + "word": "gallery", + "definition": "A place where people share and explore projects." + }, + "blocks": { + "word": "blocks", + "definition": "A way to edit code by dragging pieces instead of typing." + }, + "placeholder": { + "word": "placeholder", + "definition": "An empty spot that shows where code or a value is missing." + }, + "conflict": { + "word": "conflict", + "definition": "A problem in code that Wordplay marks so you can fix it." + }, + "checkpoint": { + "word": "checkpoint", + "definition": "A saved point in your work that you can return to." + }, + "guide": { + "word": "guide", + "definition": "The searchable reference that explains Wordplay's ideas." + }, + "tutorial": { + "word": "tutorial", + "definition": "The step-by-step lessons that teach Wordplay." + }, + "name": { + "word": "name", + "definition": "A word that refers to a value or a function." + }, + "markup": { + "word": "markup", + "definition": "Text with formatting, such as bold words or links." + }, + "language": { + "word": "language", + "definition": "A human language that a name or text is written in." + }, + "documentation": { + "word": "documentation", + "definition": "Text that explains what code does." + }, + "region": { + "word": "region", + "definition": "A place where a language is used, which can change how numbers and dates look." + }, + "start": { + "word": "start", + "definition": "The main source file where a project begins." + }, + "query": { + "word": "query", + "definition": "A way to choose rows from a table." + }, + "key": { + "word": "key", + "definition": "The value used to find an item in a map." + }, + "index": { + "word": "index", + "definition": "A number that picks an item by its position in a list." + }, + "moved": { + "word": "moved", + "definition": "Output that changed position on stage." + }, + "entered": { + "word": "new", + "definition": "Output that appeared on stage for the first time." + }, + "cell": { + "word": "cell", + "definition": "One value in a table row." + }, + "column": { + "word": "column", + "definition": "A named field in a table." + } }, "token": { "EvalOpen": "evaluation open", @@ -183,7 +298,7 @@ "emotion": "serious", "doc": [ "I richly format things with @Markup, like explanations of some of your @Program, or even the words you put on stage with @Phrase.", - "For example, I can go before any expression:", + "For example, I can go before any @expression:", "\\¶Is this really supposed to be 7?¶\n7\\", "For example, you can place me before @Bind:", "\\¶I measure how tall someone is¶\nheight: 5m\\", @@ -215,7 +330,7 @@ "emotion": "kind", "doc": [ "I'm a mapping from a *key* to a *value*, always in a @Map.", - "You can map any kind of value to any other. For example, here's a mapping of numbers:", + "You can map any kind of @value to any other. For example, here's a mapping of numbers:", "\\{1:1}\\", "Or a mapping from text to numbers:", "\\{'bunny':1}\\" @@ -237,7 +352,7 @@ "Sometimes text isn't just one language — it's a *mix*. Like Spanglish, where Spanish and English blend together. For those, list each language with an underscore between them. The first one is the *primary* — that's the one I use when I have to pick (for example, to choose which quote marks to use).", "\\\"Hola gentleman!\"/es_en\\", "\\¶A bilingual greeting¶/es_en\nhello/es_en: \"Hola gentleman!\"\\", - "You can add a single region too, after a dash, just like with a single language:", + "You can add a single @region too, after a dash, just like with a single language:", "\\\"Hola gentleman!\"/es_en-MX\\", "There's no limit to how many languages you can list — just don't repeat the same one twice; I'll let you know if you do. There are many <2-letter language codes@https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes> that I understand. If you don't use one of those, I'll let you know too." ], @@ -264,7 +379,7 @@ "description": "Declare name $name[$name|unnamed]", "emotion": "kind", "doc": [ - "I identify a value, and am a helpful way of giving a shorthand label to something that was hard to evaluate, or that you don't want to have to evaluate over and over.", + "I identify a @value, and am a helpful way of giving a shorthand label to something that was hard to evaluate, or that you don't want to have to evaluate over and over.", "@Bind gives me my name like this:", "\\hi: 5\\", "I only ever represent one value, and once I have it, I can't change. For example, if you tried to do this with @Bind, we would complain.", @@ -285,7 +400,7 @@ "names": "names" }, "doc": [ - "I'm a list of @Name, useful when you want to give a value multiple names, often with different @Language.", + "I'm a list of @Name, useful when you want to give a @value multiple names, often with different @Language.", "Names are separated by \\,\\ symbols. For example, here's @Bind giving a value multiple @Name", "\\hi/en,hello/en,hola/es: 'welcome'\\" ] @@ -345,7 +460,7 @@ "label": { "type": "type" }, - "doc": "I am a mystery type on @FunctionDefinition or @StructureDefinition, provided by @TypeInputs when either is evaluated. @Set, @List, and @Map use me.", + "doc": "I am a mystery @type on @FunctionDefinition or @StructureDefinition, provided by @TypeInputs when either is evaluated. @Set, @List, and @Map use me.", "conflict": { "DuplicateTypeVariable": { "name": "duplicate type variable", @@ -368,7 +483,7 @@ "paragraphs": "paragraphs" }, "doc": [ - "I'm a list of paragraphs, using the many kinds of markup available in explanations, such as @Words, @WebLink, @ConceptLink, and @Example." + "I'm a list of paragraphs, using the many kinds of @markup available in explanations, such as @Words, @WebLink, @ConceptLink, and @Example." ] }, "Paragraph": { @@ -442,7 +557,7 @@ "\\¶Here's an example of adding: \\1 + 2\\¶3 + 4\\", "If you are using Examples in your how-tos, you can tag one Example as a highlight.", "\\¶Here's a highlighted example: \\1 + 2\\⭐¶3 + 4\\", - "The highlighted example is shown as the preview in the how-to space and the Guide." + "The highlighted example is shown as the preview in the @how space and the @guide." ] }, "ExternalExample": { @@ -459,7 +574,7 @@ "description": "Mention $name in markup", "emotion": "serious", "doc": [ - "I'm a reference to either terminology \\$program\\ or a dynamic input \\$name\\.", + "I'm a reference to either terminology \\¶@program¶\\ or a dynamic input \\¶$name¶\\.", "This is mostly an internal feature though, so you shouldn't need to know it." ] }, @@ -515,7 +630,7 @@ "\\pi: 3.1415926\\", "I name inputs to @FunctionDefinition and @StructureDefinition, I name values in @Block. I name everything!", "Oh, but did you know you can have one value *many names*?", - "I'm so excited to tell you about this! One value, many @Names. For example:", + "I'm so excited to tell you about this! One @value, many @Names. For example:", "\\joe,tess,amy: 5\\", "See what I did there? ", "One value, three names.", @@ -616,7 +731,7 @@ "See how @Bind made \\count\\? It's only named inside me. So this won't work:", "\\(count: 10 count ^ count) + count\\", "Because count was only named inside me.", - "If you give me one expression, I just give you back its value:", + "If you give me one @expression, I just give you back its @value:", "\\(1 + 1)\\", "If you give me more than one, I collect them into a list, in order:", "\\(1 2 3 4 5)\\", @@ -653,17 +768,17 @@ "bind": "name", "version": "version" }, - "doc": "I borrow @Bind that are shared by other @Source in your performance — just use their name and I'll bring in their name and value.", + "doc": "I borrow @Bind that are shared by other @Source in your performance — just use their name and I'll bring in their name and @value.", "start": "Borrowing $name from $source", "conflict": { "UnknownBorrow": { "name": "unknown borrow", - "explanation": "I don't know a $source by this name", + "explanation": "I don't know a source by this name", "resolution": "Remove this borrow" }, "BorrowCycle": { "name": "self-referencing borrow", - "explanation": "this depends on $borrow, which depends on this $source, so the program can't be evaluated", + "explanation": "this depends on $borrow, which depends on this @Source, so the program can't be evaluated", "resolution": "Remove this borrow to break the cycle" } }, @@ -699,7 +814,7 @@ "no": "no" }, "doc": [ - "Should I give you one value or another, depending on a condition? Like this?", + "Should I give you one @value or another, depending on a @condition? Like this?", "\\number: -100\nnumber < 0 ? 'negative' 'positive'\\", "But have you ever thought about how we decide?", "Doesn't it seem like decisions should be more nuanced than just yes or no? Is deciding between \\⊤\\ and \\⊥\\ all there is?", @@ -737,10 +852,10 @@ "expression": "expression" }, "doc": [ - "Dude, I define conversions from one type to another! I go in @Block, something like this:", + "Dude, I define conversions from one @type to another! I go in @Block, something like this:", "\\→ #kitty #cat ⬚ ÷ 2\n6kitty→#cat\\", "See how I turned kitties into cats? Wicked!", - "You might be wondering what that \\⬚\\ is doing there. That represents the value being converted. I use that because the value has no name otherwise." + "You might be wondering what that \\⬚\\ is doing there. That represents the @value being converted. I use that because the value has no name otherwise." ], "start": "Awesome, a new conversion!", "conflict": { @@ -756,7 +871,7 @@ "description": "Convert value to another type", "emotion": "cheerful", "doc": [ - "I turn values from one type to another. Yo — check it out:", + "I turn values from one @type to another. Yo — check it out:", "\\1 → \"\"\\", "\\5s → #ms\\", "\\\"hello\" → []\\", @@ -790,7 +905,7 @@ "\\[1 2 3] ↦ ⬚ + 1\\ gives you \\[2 3 4]\\. BOOM! Every number, plus one, no loops, no @FunctionDefinition, no fuss!", "I am AMAZING for making @Phrase output from data! Got a list of names? \\['Ada' 'Mac' 'Tim'] ↦ Phrase(⬚)\\ and now there's a phrase for EVERY name!!", "I work on @List, @Set, @Map (I change the map's VALUES — the keys stay right where they are!), and even @Table!", - "Want something fancier — like the index of each item, or a function you can reuse in lots of places? Go visit my calmer, grown-up cousins like @List.translate. But for quick everyday mapping, I'm here!!" + "Want something fancier — like the @index of each item, or a function you can reuse in lots of places? Go visit my calmer, grown-up cousins like @List.translate. But for quick everyday mapping, I'm here!!" ], "start": "Hang on, lemme grab everything in $expression!", "finish": "TA-DA! Here's your brand new collection: $value", @@ -811,7 +926,7 @@ "description": "Remove matching rows from table", "emotion": "angry", "doc": [ - "I remove the rows of a @Table that match a condition. Sometimes a table JUST HAS TOO MUCH IN IT!", + "I remove the rows of a @Table that match a @condition. Sometimes a table JUST HAS TOO MUCH IN IT!", "Like if you had some players in a game and one left and you just wanted to say GO AWAY PLAYER, GET OUT OF MY TABLE!", "\\players: ⎡name•'' team•'' points•#⎦\n⎡'jen' 'red' 8⎦\n⎡'joan' 'blue' 11⎦\n⎡'jeff' 'red' 9⎦\n⎡'janet' 'blue' 7⎦\nplayers ⎡- name = 'jeff'\\", "Phew, Jeff is gone. BYE JEFF. Just remember that I don't change the original table, I make a new one, without JEFF. You decide where it goes." @@ -824,7 +939,7 @@ "description": "Attach documentation to an expression", "emotion": "eager", "doc": [ - "I'm any expression, but with a @Doc!", + "I'm any @expression, but with a @Doc!", "To make me, just put a @Doc before an expression, and you'll get me:", "\\doubleplus: 1\n(2 × doubleplus) + \n¶Let's make it just a little bit bigger¶\n1\\", "I'm useful for making a comment on some part of a program.", @@ -906,7 +1021,7 @@ }, "SeparatedEvaluate": { "name": "ambiguous evaluate", - "explanation": "Is $name the name of a $structure[$structure|$function] you're trying to evaluate? Try removing the space after me, so I know it's an @Evaluate and not a separate @Block.", + "explanation": "Is $name the name of a $structure[$structure|@FunctionDefinition] you're trying to evaluate? Try removing the space after me, so I know it's an @Evaluate and not a separate @Block.", "resolution": "Close the space between *$name* and its inputs" } }, @@ -970,12 +1085,12 @@ "expression": "expression" }, "doc": [ - "I take some inputs, then evaluate an expression using them, producing an output. Hi again!", + "I take some inputs, then evaluate an @expression using them, producing an output. Hi again!", "Here's a simple example:", "\\ƒ repeat(message•'') message × 5\nrepeat('hi')\\", "That function takes one input, \\message\\, and uses the @Text.repeat function to repeat the message five times.", "I'm really helpful if you want to evaluate something over and over, but with different inputs!", - "I do have lots of other little tricks. For example, I don't have to have a name. Here, I'm just going directly to @Evaluate as a value.", + "I do have lots of other little tricks. For example, I don't have to have a name. Here, I'm just going directly to @Evaluate as a @value.", "\\(ƒ(message•'') message × 5)('hi')\\", "Or, here's a function that takes any number of inputs, using the \\…\\ character after an input name.", "\\ƒ yes(messages…•'') messages.sans('no')\nyes('yes' 'yes' 'no' 'yes' 'no')\\", @@ -1034,7 +1149,7 @@ "description": "Check if value is type $type", "emotion": "curious", "doc": [ - "I check whether a value is a particular type, giving you a @Boolean. There are so many kinds of values that mean so many different things — I help figure out what they are.", + "I check whether a @value is a particular @type, giving you a @Boolean. There are so many kinds of values that mean so many different things — I help figure out what they are.", "For example, suppose you had a mystery value. I can tell you whether it's a @Number, giving you a @Boolean:", "\\mystery: 'secret!'\nmystery•#\\", "It's not a number, so I made \\⊥\\. But if we check if it's @TextType?", @@ -1064,7 +1179,7 @@ "description": "Return true if language is selected", "emotion": "kind", "doc": [ - "I'll help you check if the audience has selected a particular language or region:", + "I'll help you check if the audience has selected a particular @language or @region:", "\\🌍/en\\", "\\🌍/es-MX\\", "This is helpful if you want to change your performance based on the language chosen." @@ -1098,7 +1213,7 @@ "value": "value" }, "doc": [ - "I apply a locale to some text you compute, so it keeps its language even after you change it. Like this:", + "I apply a locale to some text you compute, so it keeps its @language even after you change it. Like this:", "\\(\"hello\" + \"!\")/en\\", "Most text operations already keep locales, but I let you set one yourself when you want to override them.", "I only work on text, so make sure you give me some!" @@ -1129,8 +1244,8 @@ "Refine a class with a @PatternProperty, like \\⣿_/greek⣿\\ for a Greek letter or \\⣿◌/emoji⣿\\.", "Name a piece with a @PatternCapture, like \\⣿year:(4 #)⣿\\, then reuse it later with a @PatternBackref by writing its bare name. Each @Result reports captured pieces by name.", "Anchor to the edges with a @PatternAnchor: \\⣿⊢⣿\\ start, \\⣿⊣⣿\\ end. Peek without consuming using a @PatternLook: \\⣿▸(#)⣿\\ ahead, \\⣿◂(#)⣿\\ behind.", - "Match whole words with a @PatternWord \\⣿▭/en⣿\\ or word boundaries with a @PatternWordEdge \\⣿┊/en⣿\\, segmented for a language. Ignore letter case inside a @PatternCaseFold \\⣿Aa(\"hi\")⣿\\.", - "My type is a @PatternType — annotate a name with it, like \\p•⣿⣿: ⣿◌⣿\\ — so you can store me in a name and reuse me across many texts." + "Match whole words with a @PatternWord \\⣿▭/en⣿\\ or word boundaries with a @PatternWordEdge \\⣿┊/en⣿\\, segmented for a @language. Ignore letter case inside a @PatternCaseFold \\⣿Aa(\"hi\")⣿\\.", + "My @type is a @PatternType — annotate a name with it, like \\p•⣿⣿: ⣿◌⣿\\ — so you can store me in a name and reuse me across many texts." ], "start": "Let me see what this text looks like.", "step": { @@ -1178,7 +1293,7 @@ "I match a single character of a kind: \\⣿◌⣿\\ any grapheme, \\⣿_⣿\\ a letter, \\⣿#⣿\\ a digit, \\⣿␣⣿\\ a space.", "For example, pull each letter-then-digit code out of a list:", "\\codes: 'a1 b2 c3'\ncodes ⌕ ⣿_ #⣿\\", - "Count me with a quantifier like \\⣿>0 #⣿\\ (one or more digits), or narrow me with a property like \\⣿_/greek⣿\\ (a Greek letter)." + "Count me with a quantifier like \\⣿>0 #⣿\\ (one or more digits), or narrow me with a @property like \\⣿_/greek⣿\\ (a Greek letter)." ] }, "PatternProperty": { @@ -1186,11 +1301,11 @@ "description": "Match a Unicode property", "emotion": "curious", "doc": [ - "I narrow a character class to a Unicode property. Use a script, like \\⣿_/greek⣿\\, \\⣿_/han⣿\\, or \\⣿_/arabic⣿\\.", + "I narrow a character class to a Unicode @property. Use a script, like \\⣿_/greek⣿\\, \\⣿_/han⣿\\, or \\⣿_/arabic⣿\\.", "For example, find the run of Greek letters in some mixed text:", "\\text: 'hello αβγ world'\ntext ⌕ ⣿>0 _/greek⣿\\", "Or a category, like \\⣿◌/punctuation⣿\\, \\⣿◌/currency⣿\\, \\⣿◌/Nd⣿\\ (a digit), or \\⣿_/Lu⣿\\ (an uppercase letter).", - "Or a yes-or-no property, like \\⣿◌/emoji⣿\\ or \\⣿_/uppercase⣿\\ — and you can spell one out as a name and value, like \\⣿◌/Script=Greek⣿\\.", + "Or a yes-or-no property, like \\⣿◌/emoji⣿\\ or \\⣿_/uppercase⣿\\ — and you can spell one out as a name and @value, like \\⣿◌/Script=Greek⣿\\.", "Exclude a property with a @PatternComplement, like \\⣿~◌/emoji⣿\\ (anything that isn't an emoji)." ], "conflict": { @@ -1254,9 +1369,9 @@ "emotion": "curious", "doc": [ "I match a single grapheme that is NOT what follows. Negate a class like \\⣿~#⣿\\ (not a digit), or a set like \\⣿~{\":\" ␣}⣿\\ (not a colon or space).", - "For example, grab the key and value around a colon by matching runs that aren't a colon or space:", + "For example, grab the @key and @value around a colon by matching runs that aren't a colon or space:", "\\pair: 'color: blue'\npair ⌕ ⣿>0 ~{\":\" ␣}⣿\\", - "Negate a group like \\⣿~(◌ | #)⣿\\, or a property like \\⣿~◌/emoji⣿\\ (not an emoji).", + "Negate a group like \\⣿~(◌ | #)⣿\\, or a @property like \\⣿~◌/emoji⣿\\ (not an emoji).", "Put me before a @PatternLook to flip it: \\⣿~▸(#)⣿\\ means \"not followed by a digit\", and \\⣿~◂(\"$\")⣿\\ means \"not preceded by a dollar sign\"." ] }, @@ -1320,7 +1435,7 @@ "description": "Match a whole word", "emotion": "curious", "doc": [ - "I match a whole word, found by a language's own rules.", + "I match a whole word, found by a @language's own rules.", "For example, list the words in a sentence:", "\\sentence: 'the cat sat'\nsentence ⌕ ⣿▭/en⣿\\", "Name the language, like \\⣿▭/en⣿\\ for English or \\⣿▭/zh⣿\\ for Chinese, and bound a specific word with edges, like \\⣿┊/en \"cat\" ┊/en⣿\\." @@ -1337,7 +1452,7 @@ "description": "Match a word boundary", "emotion": "curious", "doc": [ - "I match a word boundary for a language without consuming anything.", + "I match a word boundary for a @language without consuming anything.", "For example, find \"cat\" as a whole word, not inside \"category\":", "\\text: 'a cat in a category'\ntext ⌕ ⣿┊/en \"cat\" ┊/en⣿\\", "Wrap a word with me, like \\⣿┊/en \"cat\" ┊/en⣿\\, so it only matches whole." @@ -1392,7 +1507,7 @@ "I match my parts ignoring upper and lower case.", "For example, check a word ignoring its capitalization:", "\\shout: 'HELLO'\nshout ≈ ⣿Aa(\"hello\")⣿\\", - "Wrap my parts like \\⣿Aa(\"hi\")⣿\\ (matches \"HI\", \"Hi\", or \"hi\"). Add a language for its rules, like \\⣿Aa/tr(\"i\")⣿\\ (Turkic i), and I scope a @PatternBackref too, like \\⣿Aa(w:(2 _) w)⣿\\." + "Wrap my parts like \\⣿Aa(\"hi\")⣿\\ (matches \"HI\", \"Hi\", or \"hi\"). Add a @language for its rules, like \\⣿Aa/tr(\"i\")⣿\\ (Turkic i), and I scope a @PatternBackref too, like \\⣿Aa(w:(2 _) w)⣿\\." ] }, "PatternType": { @@ -1400,8 +1515,8 @@ "description": "The type of a pattern", "emotion": "curious", "doc": [ - "I'm the type of a pattern value, annotated like \\p•⣿⣿: ⣿◌⣿\\.", - "For example, store a pattern in a name and reuse it across many texts:", + "I'm the @type of a pattern @value, annotated like \\p•⣿⣿: ⣿◌⣿\\.", + "For example, store a pattern in a @name and reuse it across many texts:", "\\digits: ⣿>0 #⣿\n'I have 3 cats and 12 fish' ⌕ digits\\", "Once stored, use me anywhere a pattern fits, like \\text ⌕ digits\\ or \\text ≈ digits\\." ] @@ -1433,10 +1548,10 @@ "other": "default" }, "doc": [ - "I am the most glorious of conditional checks: I take a value, compare it against any number of cases, and evaluate the one that matches!", + "I am the most glorious of conditional checks: I take a @value, compare it against any number of cases, and evaluate the one that matches!", "For example, if you had a @Number and wanted to convert it to a @Text, you might do something like this:", "\\number: 2\nnumber ??? 1: 'one' 2: 'two' 3: 'three' 'bigger!'\\", - "If none match, I evaluate the default expression you give me.", + "If none match, I evaluate the default @expression you give me.", "I'm really helpful for converting one of many possible @Number, @Text, or more complex values into something else.", "You can use it for @Boolean or @None, but they can't really be that many things, so I'm not as useful for those simple values." ], @@ -1462,14 +1577,14 @@ "name": "internal expression", "description": "Run a built-in creator expression", "emotion": "neutral", - "doc": "I'm an expression that only the original creators use. How did you find me? To learn more about me, you'll need to talk to them.", + "doc": "I'm an @expression that only the original creators use. How did you find me? To learn more about me, you'll need to talk to them.", "start": "Secret expression" }, "NoneLiteral": { "name": "none literal", "description": "Create the nothing value", "emotion": "neutral", - "doc": "I make @None, the one-of-a-kind nothing value. See @None to learn more about them.", + "doc": "I make @None, the one-of-a-kind nothing @value. See @None to learn more about them.", "start": "… ø" }, "Otherwise": { @@ -1477,7 +1592,7 @@ "description": "Provide backup value if none", "emotion": "curious", "doc": [ - "I check whether a value is @None, and if it is, I give you a backup value instead.", + "I check whether a @value is @None, and if it is, I give you a backup value instead.", "/For example, if you had a value that could be a @Number or @None, @Otherwise helps you give a default number:", "\\maybeNumber•#|ø: 1 maybeNumber ?? 0\\" ], @@ -1512,7 +1627,7 @@ "doc": [ "I'm where a performance begins and ends, containing all of the other characters that choregraph a show.", "You know how @Block evaluates a list of expressions, and evaluates to the last one in its list? ", - "I'm the same, but rather than giving my value to whatever expression I'm in, I put the value on @Stage.", + "I'm the same, but rather than giving my @value to whatever @expression I'm in, I put the value on @Stage.", "The value can be anything: a @Number, @Text, or @Boolean, a @List, @Set, @Map, or even something more complex, like a @Phrase, @Group, or @Stage.", "If you don't give me a value to show on stage, I'll ask you for one.", "If there's a problem during a performance, I'll show that problem.", @@ -1563,7 +1678,7 @@ "value": "value" }, "doc": [ - "I make a copy of a @StructureDefinition value with just one property changed — no need to rebuild the whole thing with all the same values.", + "I make a copy of a @StructureDefinition @value with just one @property changed — no need to rebuild the whole thing with all the same values.", "For example, what if you were keeping a record of cats, but then wanted to create a copy of a cat with a different hobby? I can help you change it:", "\\•Cat(name•'' color•'' hobby•'')\n\nkitty: Cat('sprinkles' 'orange' 'licking')\nkitty.hobby:'purring'\\", "That's so much easier than making a whole new \\Cat\\ with the same values except for the hobby, isn't it?" @@ -1586,7 +1701,7 @@ "property": "property" }, "doc": [ - "I get one of a @StructureDefinition value's properties for you.", + "I get one of a @StructureDefinition @value's properties for you.", "Like if you had a structure about cities, you could get its values with me like this:", "\\•City(name•'' population•#people)\n\nportland: City('Portland' 800000people)\n\nportland.population\\" ], @@ -1624,9 +1739,9 @@ "description": "Reference name $name", "emotion": "shy", "doc": [ - "I refer to a value by its @Name: I find the @Bind with that name and give you its value. Like this:", + "I refer to a @value by its @Name: I find the @Bind with that name and give you its value. Like this:", "\\parrot: 'polly'\nparrot\\", - "If I don't find the name, then I don't know what to do.", + "If I don't find the @name, then I don't know what to do.", "\\parrot: 'polly'\nperry\\" ], "start": "What value does $name have?", @@ -1661,7 +1776,7 @@ "description": "Filter matching rows from table", "emotion": "excited", "doc": [ - "I pick the rows of a @Table that match a condition. Sometimes you just want part of a table — I can get it for you!", + "I pick the rows of a @Table that match a @condition. Sometimes you just want part of a table — I can get it for you!", "Like what if you had a table of players in a game and you wanted to find the ones with 10 or more points to see who won:", "\\players: ⎡name•'' team•'' points•#⎦\n⎡'jen' 'red' 8⎦\n⎡'joan' 'blue' 11⎦\n⎡'jeff' 'red' 9⎦\n⎡'janet' 'blue' 7⎦\nplayers ⎡? name ⎦ points ≥ 10\\", "Just like that, I got a list of rows of winners! Just remember that I don't change the table, I make a new one. You'll have to decide where to keep it." @@ -1689,7 +1804,7 @@ "description": "Check if set or map contains", "emotion": "kind", "doc": [ - "I can see if a @Set or @Map has a value or key.", + "I can see if a @Set or @Map has a @value or @key.", "It's not too hard. Like this:", "\\faves: {'duck' 'goose' 'monkey'}\nfaves{'mouse'}\\", "Or this, with a @Map:", @@ -1720,7 +1835,7 @@ "description": "Name a program source file", "emotion": "curious", "doc": [ - "I'm the named window around a @Program — I give it a name and hold its code. Oh, you know @Program? I help you name them.", + "I'm the named window around a @Program — I give it a name and hold its @code. Oh, you know @Program? I help you name them.", "You can also make other @Source @UI/addSource, with other @Program, and @Borrow things from those other @Program for use in another program.", "This can be a nice way of organizing a big performance into separate documents." ] @@ -1752,7 +1867,7 @@ "\\•Pizza(\ningredients•['']\nsize•#in\n)\\", "I can also have @Bind inside, so we could evaluate the cost in advance.", "\\•Pizza(\ningredients•['']\nsize•#in\n) (\n\tcost: size × 10dollars/in\n)\n\nPizza(['pepperoni' 'peppers'] 12in).cost\\", - "Mark a function or value with ↑ to make it belong to the structure itself, not to instances. Then you can use the name of the structure to reach it, without making one.", + "Mark a function or @value with ↑ to make it belong to the structure itself, not to instances. Then you can use the @name of the structure to reach it, without making one.", "\\•Math() (\n\t↑ pi: 3.14159\n\t↑ ƒ square(n•#) n · n\n)\n\nMath.pi\nMath.square(5)\\" ], "start": "Let's define this lovely structure", @@ -1871,7 +1986,7 @@ "description": "Refer to current structure value", "emotion": "serious", "doc": [ - "I'm \\⬚\\, a little square that stands for whatever value the surrounding code is working on, so you don't have to name it.", + "I'm \\⬚\\, a little square that stands for whatever @value the surrounding code is working on, so you don't have to name it.", "Inside a @ConversionDefinition, I'm the value being converted:", "\\→ #rainbows #joys ⬚ × 1000000joys\n2rainbows → #joys\\", "Inside a @Reaction, I'm the most recent value:", @@ -1899,7 +2014,7 @@ "input": "input" }, "doc": [ - "I evaluate a @FunctionDefinition that takes a single value, with its one-symbol name written right before the input — like \\-x\\ or \\~b\\. Did you know you could do that?", + "I evaluate a @FunctionDefinition that takes a single @value, with its one-symbol name written right before the input — like \\-x\\ or \\~b\\. Did you know you could do that?", "Like this:", "\\-(1 + 1)\\", "Or this:", @@ -1918,7 +2033,7 @@ "doc": [ "I'm a piece of code that doesn't make sense, so no one knows what it means.", "jkwel fjiwojvioao jjiweo jrfe", - "/Not every expression has meaning on stage./", + "/Not every @expression has meaning on stage./", "s w ieorjwei iojwi jfkdlsfdsk", "/In fact, there are all kinds of things you can say that don't make any sense at all./", "dsk sdlk jdkfiewipapweiurb,v kdsfdsf", @@ -1952,10 +2067,10 @@ "description": "Revise matching rows in table", "emotion": "kind", "doc": [ - "I help revise a @Table, finding the rows that match a condition, and then creating revised rows with new values.", + "I help revise a @Table, finding the rows that match a @condition, and then creating revised rows with new values.", "So like, if you had a table of characters and points, and you wanted to give every character on a team a point, you might do this:", "\\players: ⎡name•'' team•'' points•#⎦\n⎡'jen' 'red' 1⎦\n⎡'joan' 'blue' 0⎦\n⎡'jeff' 'red' 3⎦\n⎡'janet' 'blue' 2⎦\nplayers ⎡: points: points + 1 ⎦ team = 'blue'\\", - "You can use a @Bind to say which columns to change, and you can use any of the column names or other names in scope in the condition." + "You can use a @Bind to say which columns to change, and you can use any of the column names or other names in @scope in the condition." ], "start": "Let's get the table first", "finish": "Evaluated to a new table with revised rows!", @@ -1986,14 +2101,14 @@ "name": "any", "description": "Any", "emotion": "curious", - "doc": "I represent any possible type. Sometimes I show up because I don't know what kind of value something is, so it could be anything." + "doc": "I represent any possible @type. Sometimes I show up because I don't know what kind of @value something is, so it could be anything." }, "BooleanType": { "name": "boolean", "description": "Type a boolean true/false value", "emotion": "kind", "doc": [ - "I work with @Bind to declare that a name is a @Boolean value. Like this:", + "I work with @Bind to declare that a name is a @Boolean @value. Like this:", "\\hungry•?: 'jello'\\", "If you want to be sure that something is @Boolean, use me, and I'll check!" ] @@ -2003,7 +2118,7 @@ "description": "Type a conversion definition", "emotion": "serious", "doc": [ - "I work with @Bind to indicate that a name is a @ConversionDefinition. You probably don't need to use me, because not a lot of people pass me around as a value, but if you did, I would look like this:", + "I work with @Bind to indicate that a name is a @ConversionDefinition. You probably don't need to use me, because not a lot of people pass me around as a @value, but if you did, I would look like this:", "\\magic•?→'': → ? '' ⬚ ? 'yep' 'nope'\\" ] }, @@ -2012,7 +2127,7 @@ "description": "Type a formatted text value", "emotion": "serious", "doc": [ - "I work with @Bind to note that a name is a @FormattedLiteral value. Like this:", + "I work with @Bind to note that a name is a @FormattedLiteral @value. Like this:", "\\hungry•`…`: `I am so /fancy/!`\\", "Want to make sure something is a @FormattedLiteral value? This is how you make sure." ] @@ -2079,7 +2194,7 @@ "name": "never", "description": "Represent an impossible type", "emotion": "curious", - "doc": "I represent a type that is impossible. Like when you ask @Is if something is a @Number, but it can never be a number." + "doc": "I represent a @type that is impossible. Like when you ask @Is if something is a @Number, but it can never be a number." }, "NoneType": { "name": "none", @@ -2118,14 +2233,14 @@ "name": "structure", "description": "Represent structure $name", "emotion": "kind", - "doc": "I am an internal type to represent the type of default value types." + "doc": "I am an internal @type to represent the type of default @value types." }, "UnknownType": { "name": "unknown", "description": "Unknown", "connector": ", because ", "emotion": "curious", - "doc": "I'm a type no one has figured out yet. Umm… do you know what I represent? You might need to tell us if we can't figure it out." + "doc": "I'm a @type no one has figured out yet. Umm… do you know what I represent? You might need to tell us if we can't figure it out." }, "TableType": { "name": "table", @@ -2145,7 +2260,7 @@ "description": "$text['$text'|Text]", "emotion": "happy", "doc": [ - "I fabulously represent the most fabulous kind of value there is, @Text.", + "I fabulously represent the most fabulous kind of @value there is, @Text.", "\\story•'': 'Once upon a time...'\\" ] }, @@ -2153,7 +2268,7 @@ "name": "placeholder", "description": "Hold a placeholder for a type", "emotion": "eager", - "doc": "I hope to represent a type some day, kind of like my bestie @ExpressionPlaceholder represents an expression! Will you help me decide what kind?" + "doc": "I hope to represent a @type some day, kind of like my bestie @ExpressionPlaceholder represents an @expression! Will you help me decide what kind?" }, "UnionType": { "name": "option", @@ -2161,7 +2276,7 @@ "elidedSuffix": " or $omitted other options", "emotion": "curious", "doc": [ - "I represent a value that could be one of several types — A or B or something else? I can never decide!", + "I represent a @value that could be one of several types — A or B or something else? I can never decide!", "\\indecision•''|#|{ø}: \"I don't know!\"\\" ] }, @@ -2179,55 +2294,55 @@ "name": "unparsable", "description": "Type an unparsable expression", "emotion": "curious", - "doc": "I represent the type of an unknown expression. I show up when you try to use that expression for something." + "doc": "I represent the @type of an unknown @expression. I show up when you try to use that expression for something." }, "VariableType": { "name": "variable type", "description": "Represent a type variable in use", "emotion": "curious", - "doc": "I stand in for a @TypeVariable — an unknown kind of value — in all negotiations between values." + "doc": "I stand in for a @TypeVariable — an unknown kind of @value — in all negotiations between values." }, "CycleType": { "name": "cycle", "description": "Depend on itself", "emotion": "curious", - "doc": "I represent a value whose type depends on itself, so we can't tell what kind of value it is." + "doc": "I represent a @value whose @type depends on itself, so we can't tell what kind of value it is." }, "UnknownVariableType": { "name": "unknown variable", "description": "Represent an unknown type variable", "emotion": "curious", - "doc": "Sometimes we try to guess what kind of value something is; I show up when we don't know." + "doc": "Sometimes we try to guess what kind of @value something is; I show up when we don't know." }, "NotAType": { "name": "unexpected", "description": "Mark $type as an invalid type", "emotion": "curious", - "doc": "I represent a value whose type isn't the one we expected — like when @ListAccess needs a @Number but gets something else." + "doc": "I represent a @value whose @type isn't the one we expected — like when @ListAccess needs a @Number but gets something else." }, "NoExpressionType": { "name": "no expression", "description": "Type an empty block", "emotion": "angry", - "doc": "I'm the type you get when a @Block has no expression in it. You know it needs at least one — so give one!" + "doc": "I'm the @type you get when a @Block has no @expression in it. You know it needs at least one — so give one!" }, "NotEnclosedType": { "name": "not in structure, conversion, or reaction", "description": "Mark this used outside a structure", "emotion": "curious", - "doc": "I show up when @This is used somewhere it doesn't belong, so no one knows what value it represents." + "doc": "I show up when @This is used somewhere it doesn't belong, so no one knows what @value it represents." }, "NotImplementedType": { "name": "unimplemented", "description": "Type an unfilled placeholder", "emotion": "curious", - "doc": "When you use @ExpressionPlaceholder, but don't say what type they are, I'm the type you get. Deal with it!" + "doc": "When you use @ExpressionPlaceholder, but don't say what @type they are, I'm the type you get. Deal with it!" }, "UnknownNameType": { "name": "unknown name", "description": "$name[$name isn't defined yet|The name isn't given]", "emotion": "curious", - "doc": "I represent a name that @Reference or @PropertyReference couldn't find, when we don't know who you're talking about." + "doc": "I represent a @name that @Reference or @PropertyReference couldn't find, when we don't know who you're talking about." }, "NonFunctionType": { "name": "non-function", @@ -2264,7 +2379,7 @@ }, "or": { "doc": [ - "I evaluate to \\⊤\\ when *either* value are \\⊤\\. Helpful for determining if one of many things are true. There are only four possible outcomes", + "I evaluate to \\⊤\\ when *either* @value are \\⊤\\. Helpful for determining if one of many things are true. There are only four possible outcomes", "\\⊤ | ⊤\\", "\\⊤ | ⊥\\", "\\⊥ | ⊤\\", @@ -2288,7 +2403,7 @@ "names": ["=", "equals"], "inputs": [ { - "doc": "The other value to check.", + "doc": "The other @value to check.", "names": "value" } ] @@ -2298,7 +2413,7 @@ "names": ["≠", "notequal"], "inputs": [ { - "doc": "The other value to check.", + "doc": "The other @value to check.", "names": "value" } ] @@ -2310,27 +2425,27 @@ }, "None": { "doc": [ - "I represent the absence of a value, written \\ø\\.", + "I represent the absence of a @value, written \\ø\\.", "I am @None. Invoke me with \\ø\\." ], "name": ["ø", "None"], "function": { "equals": { - "doc": "Is another value also nothing? It better be, otherwise, \\⊥\\.", + "doc": "Is another @value also nothing? It better be, otherwise, \\⊥\\.", "names": ["=", "equals"], "inputs": [ { - "doc": "The other value.", + "doc": "The other @value.", "names": "value" } ] }, "notequals": { - "doc": "Is another value /not/ nothing?", + "doc": "Is another @value /not/ nothing?", "names": ["≠", "notequal"], "inputs": [ { - "doc": "The other value.", + "doc": "The other @value.", "names": "value" } ] @@ -2342,7 +2457,7 @@ }, "Text": { "doc": [ - "I can represent any text, from any language. Just put the text between opening and closing text symbols: \\\"\"\\, \\“”\\, \\„“\\, \\''\\, \\‘’\\, \\‹›\\, \\«»\\, \\「」\\, or \\『』\\.", + "I can represent any text, from any @language. Just put the text between opening and closing text symbols: \\\"\"\\, \\“”\\, \\„“\\, \\''\\, \\‘’\\, \\‹›\\, \\«»\\, \\「」\\, or \\『』\\.", "To illustrate, consider these beautiful phrases", "\\“There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.”\\", "\\『一日三秋』\\", @@ -2522,7 +2637,7 @@ "inputs": [] }, "equals": { - "doc": "\\⊤\\ if I am the same formatted text as the given value.", + "doc": "\\⊤\\ if I am the same formatted text as the given @value.", "names": ["=", "equals"], "inputs": [ { @@ -2532,7 +2647,7 @@ ] }, "notequals": { - "doc": "\\⊤\\ if I am /not/ the same formatted text as the given value.", + "doc": "\\⊤\\ if I am /not/ the same formatted text as the given @value.", "names": "≠", "inputs": [ { @@ -2948,15 +3063,15 @@ "List": { "doc": [ "I'm a sequence of values, of any kind!", - "You can put anything in me: @Boolean, @Number, @Text, @None, even other @List, @Set, @Map, or any expression. Here's a simple one:", + "You can put anything in me: @Boolean, @Number, @Text, @None, even other @List, @Set, @Map, or any @expression. Here's a simple one:", "\\['apple' 'banana' 'mango']\\", "What makes me special is that I keep things in order and I number everything from 1 to however many items are in me.", "My items are numbered, starting from 1. You can get values that I'm storing with @ListAccess, using their number:", - "For example, the second value in this list is \\['banana']\\", + "For example, the second @value in this list is \\['banana']\\", "\\['apple' 'banana' 'mango'][2]\\", "I can have anything in me. Look at this list, with @Text, @Number, and @Time!", "\\['apple' 10 + 10 Time()]\\", - "When you give me a list of many things, I'll generalize them if they have a type in common. But sometimes you might literally mean those specific things. If you do, just put a ! after me, and I'll make sure that I represent a list of specifically only those values.", + "When you give me a list of many things, I'll generalize them if they have a @type in common. But sometimes you might literally mean those specific things. If you do, just put a ! after me, and I'll make sure that I represent a list of specifically only those values.", "\\['apple' 'banana' 'mango']!\\", "That's kind of it. But I can do all kinds of exciting things with my @FunctionDefinition!" ], @@ -2973,7 +3088,7 @@ "names": ["with", "add"], "inputs": [ { - "doc": "I am the value you want to add.", + "doc": "I am the @value you want to add.", "names": "item" } ] @@ -2995,17 +3110,17 @@ }, "replace": { "doc": [ - "I create a new list that replaces the value at the given index with the given value.", + "I create a new list that replaces the @value at the given @index with the given value.", "\\['apple' 'banana' 'mango'].replace(1 'kiwi')\\" ], "names": ["replace"], "inputs": [ { - "doc": "The index of the value to replace", + "doc": "The @index of the @value to replace", "names": "index" }, { - "doc": "The replacement value", + "doc": "The replacement @value", "names": "value" } ] @@ -3055,7 +3170,7 @@ "names": "has", "inputs": [ { - "doc": "The value to search for.", + "doc": "The @value to search for.", "names": "item" } ] @@ -3075,7 +3190,7 @@ }, "subsequence": { "doc": [ - "I get a list within this list, starting at the index you provide, and ending with the last item, or if you provide one, a particular item.", + "I get a list within this list, starting at the @index you provide, and ending with the last item, or if you provide one, a particular item.", "\\['apple' 'banana' 'mango'].subsequence(2)\\", "\\['apple' 'banana' 'mango'].subsequence(1 2)\\", "And look! If you provide numbers out of order, I give you the reverse", @@ -3088,11 +3203,11 @@ "names": "subsequence", "inputs": [ { - "doc": "The index of the first item of the subsequence you want.", + "doc": "The @index of the first item of the subsequence you want.", "names": "start" }, { - "doc": "The optional index of the last item of the subsequence you want. If you don't give one, your list will end with the last item in the list.", + "doc": "The optional @index of the last item of the subsequence you want. If you don't give one, your list will end with the last item in the list.", "names": "end" } ] @@ -3115,26 +3230,26 @@ }, "sans": { "doc": [ - "Me, but without the first occurrences of the given value.", + "Me, but without the first occurrences of the given @value.", "\\['apple' 'banana' 'mango' 'apple'].sans('apple')\\" ], "names": ["without", "sans"], "inputs": [ { - "doc": "The value to remove the first occurence of.", + "doc": "The @value to remove the first occurence of.", "names": "value" } ] }, "sansAll": { "doc": [ - "Me, but without all occurrences of the given value.", + "Me, but without all occurrences of the given @value.", "\\['apple' 'banana' 'mango' 'apple'].sans('apple')\\" ], "names": ["withoutAll", "sansAll"], "inputs": [ { - "doc": "The value to remove all occurences of from the list.", + "doc": "The @value to remove all occurences of from the list.", "names": "value" } ] @@ -3175,7 +3290,7 @@ }, "translate": { "doc": [ - "Give me a @FunctionDefinition that takes a value and optional index as input and produces a value, and I'll evaluate it on each of my items, translating my values into new values.", + "Give me a @FunctionDefinition that takes a @value and optional @index as input and produces a value, and I'll evaluate it on each of my items, translating my values into new values.", "For example, imagine I was a list of @Number and you wanted to double all of them:", "\\[2 4 6 8].translate(ƒ(num•#) num × 2)\\" ], @@ -3192,7 +3307,7 @@ "names": "item" }, { - "doc": "The index of the item being translated.", + "doc": "The @index of the item being translated.", "names": "index" }, { @@ -3203,7 +3318,7 @@ }, "filter": { "doc": [ - "Give me a @FunctionDefinition that takes a value and optional index as input and produces a @Boolean, and I'll create a new list that only includes the items that result \\⊤\\.", + "Give me a @FunctionDefinition that takes a @value and optional @index as input and produces a @Boolean, and I'll create a new list that only includes the items that result \\⊤\\.", "For example, imagine I was a list of @Number and you only wanted the positive ones:", "\\[2 -4 8 -16].filter(ƒ(num•#) num ≥ 0)\\" ], @@ -3220,7 +3335,7 @@ "names": "item" }, { - "doc": "The index of the item being checked.", + "doc": "The @index of the item being checked.", "names": "index" }, { @@ -3231,14 +3346,14 @@ }, "all": { "doc": [ - "Give me a @FunctionDefinition that takes a value as input and produces a @Boolean if it matches some condition. I'll create \\⊤\\ if all the items match the condition.", + "Give me a @FunctionDefinition that takes a @value as input and produces a @Boolean if it matches some @condition. I'll create \\⊤\\ if all the items match the condition.", "For example, imagine I was a list of @Number and you wanted to know if everything was positive:", "\\[2 -4 8 -16].all(ƒ(num•#) num ≥ 0)\\" ], "names": "all", "inputs": [ { - "doc": "The @FunctionDefinition that produces \\⊤\\ if an item satisfies your condition.", + "doc": "The @FunctionDefinition that produces \\⊤\\ if an item satisfies your @condition.", "names": "checker" } ], @@ -3248,7 +3363,7 @@ "names": "item" }, { - "doc": "The index of the item being checked.", + "doc": "The @index of the item being checked.", "names": "index" }, { @@ -3259,7 +3374,7 @@ }, "until": { "doc": [ - "Give me a @FunctionDefinition that takes a value as input and produces a @Boolean if it matches some condition. I'll create a new @List that contains all the items until the condition isn't met.", + "Give me a @FunctionDefinition that takes a @value as input and produces a @Boolean if it matches some @condition. I'll create a new @List that contains all the items until the condition isn't met.", "For example, imagine I was a list of @Text animals and you wanted everything up until \\'rat'\\ was found:", "\\['cat' 'dog' 'rat' 'mouse' 'pony'].until(ƒ(animal•'') animal = 'rat')\\" ], @@ -3276,7 +3391,7 @@ "names": "item" }, { - "doc": "The index of the item being checked.", + "doc": "The @index of the item being checked.", "names": "index" }, { @@ -3287,7 +3402,7 @@ }, "find": { "doc": [ - "Give me a @FunctionDefinition that takes a value as input and produces a @Boolean if it matches some criteria, and I'll evaluate to the matching item.", + "Give me a @FunctionDefinition that takes a @value as input and produces a @Boolean if it matches some criteria, and I'll evaluate to the matching item.", "For example, imagine you wanted to find the first animal that had the vowel \\'e'\\:", "\\['cat' 'dog' 'rat' 'mouse' 'pony'].find(ƒ(animal•'') animal.has('e'))\\" ], @@ -3304,7 +3419,7 @@ "names": "item" }, { - "doc": "The index of the item being checked.", + "doc": "The @index of the item being checked.", "names": "index" }, { @@ -3315,7 +3430,7 @@ }, "combine": { "doc": [ - "Give me a @FunctionDefinition that takes the most recent combination and a next value, and creates a next combination, and I'll move from the first to last of my items, creating successive combinations, and evaluating to the final combination your @FunctionDefinition evaluates to.", + "Give me a @FunctionDefinition that takes the most recent combination and a next @value, and creates a next combination, and I'll move from the first to last of my items, creating successive combinations, and evaluating to the final combination your @FunctionDefinition evaluates to.", "This is really helpful for combining all of the items in me into a single value. For example, imagine you wanted to add a list of numbers:", "\\[3 9 2 8 1 4].combine(0 ƒ(sum•# number•#) sum + number)\\" ], @@ -3326,7 +3441,7 @@ "names": "initial" }, { - "doc": "The @FunctionDefinition that takes the latest combination and the next value and produces the next combination.", + "doc": "The @FunctionDefinition that takes the latest combination and the next @value and produces the next combination.", "names": "combiner" } ], @@ -3340,7 +3455,7 @@ "names": "next" }, { - "doc": "The index of the next item", + "doc": "The @index of the next item", "names": "index" }, { @@ -3361,13 +3476,13 @@ "names": "sorted", "inputs": [ { - "doc": "The optional @FunctionDefinition to use to sort a list value. It should turn the value into a @Number that can be used for sorting the list.", + "doc": "The optional @FunctionDefinition to use to sort a list @value. It should turn the value into a @Number that can be used for sorting the list.", "names": "sequencer" } ], "sequencer": [ { - "doc": "The value to turn into a @Number.", + "doc": "The @value to turn into a @Number.", "names": "value" } ] @@ -3387,9 +3502,9 @@ "For example, this set has many duplicates:", "\\{1 1 2 2 3 3}\\", "I evaluate it to just \\{1 2 3}\\.", - "If you want to see if I have a value in me, @SetOrMapAccess can help:", + "If you want to see if I have a @value in me, @SetOrMapAccess can help:", "\\{'jar' 'bottle' 'glass'}{'cup'}\\", - "Usually, if you give me a bunch of values that are of a common type, I'll assume they're a list of that type. Like this set is \\{''}\\, because it's all @Text.", + "Usually, if you give me a bunch of values that are of a common @type, I'll assume they're a list of that type. Like this set is \\{''}\\, because it's all @Text.", "\\{'hey' 'hi' 'hello'}\\", "But you might want to indicate that I'm a set of /only/ those values, so I can tell you when you're trying to use one that's not allowed. If so, just add a ! at the end of me.", "\\{'hey' 'hi' 'hello'}!{'yo'}\\", @@ -3554,15 +3669,15 @@ "doc": [ "I bring values together, mapping *keys* to *values*. For example:", "\\{'amy': 6points 'tony':3points 'shiela': 8points}\\", - "My keys can be any kind of value, and my values can be any kind of value.", - "Some people like to think of my like an index, or a dictionary, where you give me something, and I give you what it's mapped to.", - "If you wanted to check what something is mapped to, you can give @SetOrMapAccess, a key and they'll give you the value:", + "My keys can be any kind of @value, and my values can be any kind of value.", + "Some people like to think of my like an @index, or a dictionary, where you give me something, and I give you what it's mapped to.", + "If you wanted to check what something is mapped to, you can give @SetOrMapAccess, a @key and they'll give you the value:", "\\{'amy': 6points 'tony':3points 'shiela': 8points}{'amy'}\\", "If there is no matching key, I'll give you @None.", "\\{'amy': 6points 'tony':3points 'shiela': 8points}{'jen'}\\", "You can also make an empty map like this:", "\\{:}\\", - "Usually, I'll see whatever kinds of keys and values you give me and just come up with a type to represent all of them. Like this is a map from numbers to numbers:", + "Usually, I'll see whatever kinds of keys and values you give me and just come up with a @type to represent all of them. Like this is a map from numbers to numbers:", "\\{1:1 2:2 3:3}\\", "But say you wanted to ensure it was specifically only those values; just add ! at the end of me, and I won't generalize. That'll help you know if you're trying to get a value you didn't intend.", "\\{1:1 2:2 3:3}!{4}\\", @@ -3606,50 +3721,50 @@ }, "set": { "doc": [ - "I'll make a new @Map with all the same pairings, but with the new pairing you give me. If I already have the key, I'll pair it to the new value.", + "I'll make a new @Map with all the same pairings, but with the new pairing you give me. If I already have the @key, I'll pair it to the new @value.", "\\{'amy': 6points 'tony':3points}.pair('jen' 0points)\\" ], "names": "pair", "inputs": [ { - "doc": "Key to pair with a value.", + "doc": "@key to pair with a @value.", "names": "key" }, { - "doc": "The value to pair with the key", + "doc": "The @value to pair with the @key", "names": "value" } ] }, "unset": { "doc": [ - "I'll make a new @Map without the key you give me, removing its pairing.", + "I'll make a new @Map without the @key you give me, removing its pairing.", "\\{'amy': 6points 'tony':3points}.unpair('amy')\\" ], "names": "unpair", "inputs": [ { - "doc": "The key to forget.", + "doc": "The @key to forget.", "names": "key" } ] }, "remove": { "doc": [ - "I'll create a new @Map without any keys that have the value.", + "I'll create a new @Map without any keys that have the @value.", "\\{'amy': 0points 'jen': 0points 'tony':3points}.remove(0points)\\" ], "names": "remove", "inputs": [ { - "doc": "The value to remove from me, along with any keys its paired to.", + "doc": "The @value to remove from me, along with any keys its paired to.", "names": "value" } ] }, "filter": { "doc": [ - "Give me a @FunctionDefinition that takes a key and value and evaluates to \\⊤\\ if a pairing should be kept. I'll create a new @Map that meets your criteria.", + "Give me a @FunctionDefinition that takes a @key and @value and evaluates to \\⊤\\ if a pairing should be kept. I'll create a new @Map that meets your criteria.", "For example, here we want to keep any pairings that are Amy or have more than zero points.", "\\{'amy': 0points 'jen': 0points 'tony':3points}.filter(ƒ(key•'' value•#points) (key = 'amy') | (value > 0points))\\" ], @@ -3662,11 +3777,11 @@ ], "checker": [ { - "doc": "The key being checked.", + "doc": "The @key being checked.", "names": "key" }, { - "doc": "The value being checked.", + "doc": "The @value being checked.", "names": "value" }, { @@ -3677,24 +3792,24 @@ }, "translate": { "doc": [ - "Give me a @FunctionDefinition that takes a key and value and evaluates the value to a new value. I'll create a new @Map with the same keys but updated values.", + "Give me a @FunctionDefinition that takes a @key and @value and evaluates the value to a new value. I'll create a new @Map with the same keys but updated values.", "For example, let's give everyone one point since they've been so nice.", "\\{'amy': 5points 'jen': 3points 'tony': 0points}.translate(ƒ(key•'' value•#points) value + 1points)\\" ], "names": "translate", "inputs": [ { - "doc": "The @FunctionDefinition that translates each value.", + "doc": "The @FunctionDefinition that translates each @value.", "names": "translator" } ], "translator": [ { - "doc": "The key of the value being translated.", + "doc": "The @key of the @value being translated.", "names": "key" }, { - "doc": "The value being translated.", + "doc": "The @value being translated.", "names": "value" }, { @@ -3717,7 +3832,7 @@ "\\⎡name•'' color•''⎦\n⎡'obsidian' 'black'⎦\n⎡'pumice' 'grey'⎦\n⎡'citrine' 'yellow'⎦\\", "@Bind can help you name it! And then you can do things like make a revised table with an new row @Insert:", "\\rocks: ⎡name•'' color•''⎦\n⎡'obsidian' 'black'⎦\n⎡'pumice' 'grey'⎦\n⎡'citrine' 'yellow'⎦\nrocks ⎡+ 'quartz' 'white'⎦\\", - "Or if you want to find rows that match, you can @Select rows that match a condition:", + "Or if you want to find rows that match, you can @Select rows that match a @condition:", "\\rocks: ⎡name•'' color•''⎦\n⎡'obsidian' 'black'⎦\n⎡'pumice' 'grey'⎦\n⎡'citrine' 'yellow'⎦\nrocks ⎡?⎦ color = 'grey'\\", "Or maybe you want to make a revised table that has different values for rows that match a condition:", "\\rocks: ⎡name•'' color•''⎦\n⎡'obsidian' 'black'⎦\n⎡'pumice' 'grey'⎦\n⎡'citrine' 'yellow'⎦\nrocks ⎡: color: 'black' ⎦ name = 'pumice'\\", @@ -3756,7 +3871,7 @@ } }, "Structure": { - "doc": "I'm a structured value with named, typed fields, created from a @StructureDefinition. See @StructureDefinition.", + "doc": "I'm a structured @value with named, typed fields, created from a @StructureDefinition. See @StructureDefinition.", "name": ["Structure"], "function": { "equals": { @@ -3793,7 +3908,7 @@ "This creates a wonderful chaos that comes with unpredictability.", "By default, it gives @Number values between \\0\\ and \\1\\:", "\\Random()\\", - "But you can give it a value, and it will generate values between \\0\\ and the value:", + "But you can give it a @value, and it will generate values between \\0\\ and the value:", "\\Random(10)\\", "And if you give it two values, it will generate values between and including the two values:", "\\Random(-10 10)\\", @@ -3806,11 +3921,11 @@ "inputs": [ { "names": "min", - "doc": "The minimum value that will be created, or if it's larger than 0, the maximum value. If @None is provided, then the minimum is \\0\\." + "doc": "The minimum @value that will be created, or if it's larger than 0, the maximum value. If @None is provided, then the minimum is \\0\\." }, { "names": "max", - "doc": "The maximum value that will created, or if its smaller than the minimum provided, the minimum. If @None is provided, then the maximum is \\1\\." + "doc": "The maximum @value that will created, or if its smaller than the minimum provided, the minimum. If @None is provided, then the maximum is \\1\\." } ] }, @@ -3833,7 +3948,7 @@ "But if you /really/ need to listen to a mouse button, this is the way to do it. It will provide a stream of @Boolean, representing whether the primary button is up \\⊥\\ or down \\⊤\\.", "Here's a simple example:", "\\Phrase(Button() → '')\\", - "This just makes a @Phrase that is the value of the stream as text. If you copy it into the editor and click, you'll see it toggle back and forth between \\⊥\\ and \\⊤\\." + "This just makes a @Phrase that is the @value of the stream as text. If you copy it into the editor and click, you'll see it toggle back and forth between \\⊥\\ and \\⊤\\." ], "names": ["🖱️", "Button"], "down": { @@ -3854,12 +3969,12 @@ }, "Key": { "doc": [ - "It tells you which keyboard key your audience is pressing and releasing.", + "It tells you which keyboard @key your audience is pressing and releasing.", "/clickety/", "Try this", "\\Key()\\", "See how when you type a key, it shows up on @Stage? Every time a key is pressed down, a new @Text is added to the stream, describing the key that was pressed.", - "For a key that represents a character, the value will be the character as @Text.", + "For a key that represents a character, the @value will be the character as @Text.", "For special keys, like the /Escape/ key, it will be @Text that describes the key, using a , unfortunately available only in English.", "If you only want to know about a particular key, you can provide it:", "\\Key('a')\\", @@ -3873,7 +3988,7 @@ }, "down": { "names": "down", - "doc": "IF @None, then key down generates inputs. If \\⊤\\, then only down inputs are provided, and if \\⊥\\, then only release inputs are provided." + "doc": "IF @None, then @key down generates inputs. If \\⊤\\, then only down inputs are provided, and if \\⊥\\, then only release inputs are provided." }, "keys": { "Shift": ["Shift"], @@ -3933,7 +4048,7 @@ "doc": [ "It ticks forward at a frequency you choose.", "/tick tick tick/", - "Each time it does, @Program reevaluates with the new time value.", + "Each time it does, @Program reevaluates with the new time @value.", "For example:", "\\Time()\\", "If you provide time a @Time.frequency, it will tick at that rate. For example:", @@ -3985,7 +4100,7 @@ "/hello there!/", "It uses your browser's speech recognition to listen to what you say and turn it into words (isn't this so cool?!).", "\\Speech()\\", - "This is perfect for those who can speak a language but are still learning to read or write it (and more)...", + "This is perfect for those who can speak a @language but are still learning to read or write it (and more)...", "You can specify a language code to recognize speech in a particular language:", "\\Speech(language: 'es-MX')\\", "If no language is specified, it defaults to your preferred language.", @@ -3994,11 +4109,11 @@ "names": ["🎙️", "Speech", "Voice"], "reset": { "names": ["reset", "clear"], - "doc": "When this value changes, clear the accumulated speech and start fresh." + "doc": "When this @value changes, clear the accumulated speech and start fresh." }, "language": { "names": ["language", "lang"], - "doc": "A BCP 47 language code (e.g., 'en-US'). If it's not supported by your browser's speech recognition, you'll get an error. It defaults to your preferred language." + "doc": "A BCP 47 @language code (e.g., 'en-US'). If it's not supported by your browser's speech recognition, you'll get an error. It defaults to your preferred language." }, "limit": { "names": ["limit", "words"], @@ -4030,7 +4145,7 @@ }, "height": { "names": ["height"], - "doc": "The number of @Color to sample in a column." + "doc": "The number of @Color to sample in a @column." }, "frequency": { "names": ["frequency"], @@ -4174,7 +4289,7 @@ "But sometimes you want to give the /audience/ control over where @Output goes on @Stage.", "Here's how it works: make a @Placement and give it to an @Output's @Place:", "\\Phrase('hi' place: Placement())\\", - "Then, any time the audience uses an arrow key or clicks or taps on stage, the @Placement will make a new @Place that moves in the desired direction.", + "Then, any time the audience uses an arrow @key or clicks or taps on stage, the @Placement will make a new @Place that moves in the desired direction.", "Try copying this to your program and moving the @Output with the pointer or keyboard.", "You can customize the @Placement, enabling and disabling movement on certain dimensions, changing how far a @Place moves, and the initial @Place the stream starts with." ], @@ -4208,7 +4323,7 @@ "The internet is fascinating — a whole world of connected computers sharing documents with each other.", "This stream connects to it — give it a URL, like this:", "\\Webpage('https://wordplay.dev')\\", - "And there's apparently this thing called , which lets you query things on a web page? Give it a CSS selection query and it'll get only the text that matches that query. Like this example, which gets the level one headers.", + "And there's apparently this thing called , which lets you @query things on a web page? Give it a CSS selection query and it'll get only the text that matches that query. Like this example, which gets the level one headers.", "\\Webpage('https://wordplay.dev' 'h1')\\", "Lots of things can go wrong with this one. If you lose your internet connection, or the URL doesn't resolve to anything, or the URL isn't public, or the URL isn't an HTML page… All of these can lead to exception. If if you find a page that works, you'll get some @Number indicating a percent complete and then a @List of the words on the page." ], @@ -4218,7 +4333,7 @@ "names": "url" }, "query": { - "doc": "The CSS query to evaluate on the HTML", + "doc": "The CSS @query to evaluate on the HTML", "names": "query" }, "frequency": { @@ -4238,7 +4353,7 @@ "doc": [ "It's a stream of @Rebound values for when named @Output bump into each other.", "This is a great way to do something when @Output bump into each other, other than the normal bouncing off each other they might do.", - "Just give it a name of @Output, and it'll make a new @Rebound value whenever that name bumps into another. A @Rebound has info about the names that collided and the direction of their collision.", + "Just give it a name of @Output, and it'll make a new @Rebound @value whenever that name bumps into another. A @Rebound has info about the names that collided and the direction of their collision.", "And if you give it two names, it'll only make a new value when the two names bump into each other.", "Right after it makes a new value, it'll make a \\ø\\ since the collision is done after it happens. This indicates that there's no more collision." ], @@ -4445,7 +4560,7 @@ "First, if I have a name, I'll use it to describe myself in screen reader descriptions.", "Second, when animating, you might have multiple different expressions that are supposed to represent the same content on stage; give them the same name and they will animate as one.", "Finally, I'm helpful with @Choice: the names you give me appear in that stream.", - "You can give me many different names, each in a different language, if it's helpful. I'll always use the name in the first selected language." + "You can give me many different names, each in a different @language, if it's helpful. I'll always use the name in the first selected language." ], "names": "name" }, @@ -4526,7 +4641,7 @@ "names": ["➡", "Row"], "description": "row of $count phrases and groups", "alignment": { - "doc": "Whether to align text at the start, center, or end on each column.", + "doc": "Whether to align text at the start, center, or end on each @column.", "names": "alignment" }, "padding": { @@ -4548,7 +4663,7 @@ } }, "Grid": { - "doc": "I am grid of @Output. Give me a row and column count and I'll make a tidy arrangement with optional padding and cell sizes.", + "doc": "I am grid of @Output. Give me a row and @column count and I'll make a tidy arrangement with optional padding and @cell sizes.", "names": ["▦", "Grid"], "description": "$rows row $columns column grid", "rows": { @@ -4663,7 +4778,7 @@ "I speak text aloud instead of showing it on @Stage. Hi!", "Just give me something to say, and I'll say it every time your program evaluates:", "\\Say('blah blah blah')\\", - "I'll use the @Language of the @TextLiteral if it has one, otherwise I'll use the selected language.", + "I'll use the @Language of the @TextLiteral if it has one, otherwise I'll use the selected @language.", "You can select a voice in settings." ], "names": ["🔊", "Say"], @@ -4804,7 +4919,7 @@ "For the eleven most common color names, you don't have to make me from scratch. I have shortcuts on my name:", "\\Color.red\\", "\\Color.blue\\", - "These shortcuts work in your language too, so a French speaker can write \\couleur.rouge\\ and a Japanese speaker can write \\色.赤\\. The shortcuts are: red, orange, yellow, green, blue, purple, brown, pink, black, white, and gray." + "These shortcuts work in your @language too, so a French speaker can write \\couleur.rouge\\ and a Japanese speaker can write \\色.赤\\. The shortcuts are: red, orange, yellow, green, blue, purple, brown, pink, black, white, and gray." ], "names": ["🌈", "Color"], "lightness": { @@ -4925,7 +5040,7 @@ "Sequence": { "doc": [ "I animate @Output through a series of @Pose over time! Do you want to dance with me? It's easy.", - "You just need to give me a @Map, where each key represents what percent along we are in the dance, and each value of those keys is a @Pose to be.", + "You just need to give me a @Map, where each @key represents what percent along we are in the dance, and each @value of those keys is a @Pose to be.", "There are /so/ many different ways you can animate with this! For example, here's a simple one:", "\\Phrase('hi' resting:Sequence({0%: Pose(rotation: 360°) 100%: Pose(rotation: 0°)})\\", "This says, /at the beginning (0%), start at tilt 360, and end at tilt 0/. That'll spin us around in circles forever, since I'm set as the @Phrase's rest pose!", @@ -4974,7 +5089,7 @@ } }, "Gesture": { - "doc": "I'm a snapshot of a hand pose detected by the camera, emitted by the @Hand stream. My place tells you where the hand is on stage, my fingers tells you how many are extended, my open is true when the hand is open (3+ fingers extended), and my thumb, index, middle, ring, and pinky each say which specific fingers are extended. My palm is true when the palm faces the camera, false when the back of the hand does.", + "doc": "I'm a snapshot of a hand pose detected by the camera, emitted by the @Hand stream. My place tells you where the hand is on stage, my fingers tells you how many are extended, my open is true when the hand is open (3+ fingers extended), and my thumb, @index, middle, ring, and pinky each say which specific fingers are extended. My palm is true when the palm faces the camera, false when the back of the hand does.", "names": ["✋", "Gesture"], "place": { "doc": "Where the hand is on stage.", @@ -4993,7 +5108,7 @@ "names": ["thumb"] }, "index": { - "doc": "@True if the index finger is extended.", + "doc": "@True if the @index finger is extended.", "names": ["index"] }, "middle": { @@ -5049,11 +5164,11 @@ "names": "groups" }, "starts": { - "doc": "Each capture's name to where it starts.", + "doc": "Each capture's @name to where it starts.", "names": "starts" }, "ends": { - "doc": "Each capture's name to where it ends.", + "doc": "Each capture's @name to where it ends.", "names": "ends" } }, @@ -5392,7 +5507,7 @@ "You know how projects can have more than one @Source file? I let you create a @Source based on your project's logic. This is really helpful if you want to save some data between different evaluations of your project.", "For example, imagine you wanted to make a simple counter that counts up by one each time you press a mouse button. You might use this to remember how many times you did something.", "\\↓ count\n[\n\tPhrase(`\\count\\ times!`)\n\tSource('count' count … ∆ Button() … count + 1 )\n]\\", - "Try copying it, making a new @Source called /count/ and typing 0 in it, to start the count at 0. This little project will get the value in the /count/ source and each time the mouse button is pressed, edits the /count/ @Source with to be the current /count/ value plus /1/." + "Try copying it, making a new @Source called /count/ and typing 0 in it, to start the count at 0. This little project will get the @value in the /count/ source and each time the mouse button is pressed, edits the /count/ @Source with to be the current /count/ value plus /1/." ], "name": { "names": "name", @@ -5400,7 +5515,7 @@ }, "value": { "names": "value", - "doc": "The data value that the source file should be created or updated with." + "doc": "The data @value that the source file should be created or updated with." }, "DynamicEditLimitException": { "description": "dynamic source edit limit", @@ -5522,7 +5637,7 @@ "tip": "copy project to clipboard as text", "label": "copy as text" }, - "addSource": "create a new $source", + "addSource": "create a new @Source", "duplicate": { "tip": "duplicate this project", "label": "duplicate" @@ -5920,7 +6035,7 @@ ], "confirm": { "delete": { - "description": "delete this $source", + "description": "delete this @Source", "prompt": "delete" } }, @@ -6276,10 +6391,11 @@ "mode": { "browse": { "label": "section", - "labels": ["code", "how-to"], + "labels": ["code", "how-to", "glossary"], "tips": [ "programming language concepts", - "reusable patterns for making projects" + "reusable patterns for making projects", + "definitions of key terms" ] }, "purpose": { @@ -6431,6 +6547,12 @@ "tip": "see to the how-to in the space" } }, + "glossary": { + "explain": { + "header": "Glossary", + "explanation": "/Terms and ideas used throughout the Wordplay programming language and platform./" + } + }, "tour": { "launch": "take a tour of the guide", "guide": "The /guide/ contains *documentation* for all of Wordplay's *programming language* concepts and *capabilities*.", @@ -7153,6 +7275,10 @@ "notSpamNote": "Wordplay is a not-for-profit project led by volunteers.", "requireLogin": "You must be logged in to contribute localization suggestions.", "singletonWordsWarning": "Some words in your draft only appear once across the entire language. Are you sure they are spelled correctly and consistent with other text?", + "literalTermsWarning": "Your draft uses glossary words as plain text. Tap one to turn it into a glossary link.", + "checkReadingLevel": "Check this string's reading level", + "readingLevelComplex": "This may be hard to read. Try shorter sentences and simpler words.", + "readingLevelOk": "This reads clearly for the target reading level.", "submit": { "description": "Submit this batch of edits", "prompt": "Submit" diff --git a/src/locale/getConceptName.ts b/src/locale/getConceptName.ts new file mode 100644 index 0000000000..c77b1ad510 --- /dev/null +++ b/src/locale/getConceptName.ts @@ -0,0 +1,78 @@ +import type LocaleText from '@locale/LocaleText'; +import type { NameText } from '@locale/LocaleText'; +import { withoutAnnotations } from '@locale/withoutAnnotations'; +import { BasisTypeSymbols } from '@parser/Symbols'; +import { OperatorRegEx } from '@parser/Tokenizer'; +import { EmojiTestRegex } from '@unicode/emoji'; + +/** + * Maps a former `term` id to the localized name of the documented concept it + * referred to. Each closure uses literal property access so it stays type-safe + * (no `as`), and uses the correct field per section — `name` for basis/node, + * `names` for output/input — avoiding the `name` (a concept's name *property*) + * vs `names` (the concept's own name) trap on output concepts. + * + * `satisfies` keeps the literal key types so `ConceptTermId` is the exact union + * of valid ids — passing any other string is a compile error. + */ +const CONCEPT_NAME = { + boolean: (l: LocaleText) => l.basis.Boolean.name, + none: (l: LocaleText) => l.basis.None.name, + text: (l: LocaleText) => l.basis.Text.name, + number: (l: LocaleText) => l.basis.Number.name, + list: (l: LocaleText) => l.basis.List.name, + set: (l: LocaleText) => l.basis.Set.name, + map: (l: LocaleText) => l.basis.Map.name, + table: (l: LocaleText) => l.basis.Table.name, + structure: (l: LocaleText) => l.basis.Structure.name, + output: (l: LocaleText) => l.output.Output.names, + phrase: (l: LocaleText) => l.output.Phrase.names, + group: (l: LocaleText) => l.output.Group.names, + stage: (l: LocaleText) => l.output.Stage.names, + row: (l: LocaleText) => l.output.Row.names, + source: (l: LocaleText) => l.output.Source.names, + scene: (l: LocaleText) => l.input.Scene.names, + function: (l: LocaleText) => l.node.FunctionDefinition.name, + stream: (l: LocaleText) => l.node.StreamDefinition.name, + exception: (l: LocaleText) => l.node.ExceptionType.name, + pattern: (l: LocaleText) => l.node.PatternLiteral.name, + input: (l: LocaleText) => l.node.Input.name, +} satisfies Record NameText>; + +/** The set of valid concept-term ids; an invalid id is a compile error. */ +export type ConceptTermId = keyof typeof CONCEPT_NAME; + +/** Mirrors `Name.isSymbolic()` (operator | emoji | basis-type delimiter) with + * pure parser/unicode checks. We deliberately avoid `@nodes/Name`/`Names` here: + * importing the nodes graph from this locale helper pulls in `Evaluate` → the + * values graph, forming an init-order cycle with `ExceptionValue` (a base value + * class whose `getDescription` calls `getConceptName`). */ +function isSymbolicName(name: string): boolean { + return ( + OperatorRegEx.test(name) || + EmojiTestRegex.test(name) || + BasisTypeSymbols.has(name) + ); +} + +/** Pick a readable name from a NameText: prefer a non-symbolic name, then fall + * back to the first (symbolic) name — never the developer-facing id, which + * `getConceptName` only uses when there are no written names. */ +function pickConceptName(name: NameText): string | undefined { + const names = (Array.isArray(name) ? name : [name]) + .map((n) => withoutAnnotations(n)) + .filter((n) => n !== ''); + return names.find((n) => !isSymbolicName(n)) ?? names[0]; +} + +/** + * The localized display name of the concept a former `term` id referred to, + * used for labeling nodes, values, and output now that the glossary no longer + * duplicates concept-named terms. + */ +export default function getConceptName( + locale: LocaleText, + id: ConceptTermId, +): string { + return pickConceptName(CONCEPT_NAME[id](locale)) ?? id; +} diff --git a/src/locale/getTranslatableLocales.ts b/src/locale/getTranslatableLocales.ts new file mode 100644 index 0000000000..554ec4e2cd --- /dev/null +++ b/src/locale/getTranslatableLocales.ts @@ -0,0 +1,18 @@ +import { TranslatableLocales } from '@locale/LanguageCode'; +import type Locale from '@locale/Locale'; + +/** + * The single, provider-agnostic source of target locales Wordplay offers for + * machine translation — used by in-app project translation (and future + * features like chat translation) instead of assuming a specific backend's + * supported set. + * + * The in-app default backend (Claude) has no `getLanguages()`-style endpoint + * and covers the full curated list, so the offered set is `TranslatableLocales`. + * Backends with a narrower set (e.g. the Google fallback) report their own + * coverage via `Translator.getSupportedLocales()`; if a fallback can't serve a + * requested target, callers surface a failure rather than silently degrading. + */ +export default function getTranslatableLocales(): Locale[] { + return TranslatableLocales; +} diff --git a/src/locale/glossaryScan.test.ts b/src/locale/glossaryScan.test.ts new file mode 100644 index 0000000000..e6e787482d --- /dev/null +++ b/src/locale/glossaryScan.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'vitest'; +import scanLiteralGlossaryTerms from './glossaryScan'; + +const glossary = [ + { id: 'value', word: 'value' }, + { id: 'list', word: 'list' }, +]; + +test('finds a literal glossary term and suggests the symbolic reference', () => { + const found = scanLiteralGlossaryTerms('the value is here', glossary); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ + id: 'value', + suggestion: 'the @value is here', + }); +}); + +test('skips a term already written as an @reference', () => { + expect(scanLiteralGlossaryTerms('the @value is here', glossary)).toEqual( + [], + ); +}); + +test('skips a term inside an @Concept reference', () => { + expect(scanLiteralGlossaryTerms('see @List for items', glossary)).toEqual( + [], + ); +}); + +test('skips a term inside a \\code\\ block', () => { + expect(scanLiteralGlossaryTerms('run \\value\\ now', glossary)).toEqual([]); +}); + +test('matches whole words only', () => { + // "values" should not match the term "value". + expect(scanLiteralGlossaryTerms('many values exist', glossary)).toEqual([]); +}); + +test('matches case-insensitively at a sentence start', () => { + const found = scanLiteralGlossaryTerms('Value matters', glossary); + expect(found).toHaveLength(1); + expect(found[0].suggestion).toBe('@value matters'); +}); diff --git a/src/locale/glossaryScan.ts b/src/locale/glossaryScan.ts new file mode 100644 index 0000000000..70efd6bddd --- /dev/null +++ b/src/locale/glossaryScan.ts @@ -0,0 +1,79 @@ +import type { GlossaryWord, LiteralTermFinding } from 'shared-types'; + +/** + * The free, client-side heuristic that powers the in-app localization + * workspace's live "use `@term`" suggestions. It finds glossary words written + * as literal prose that could be symbolic `@term` references, with a one-click + * fix. It's intentionally a heuristic (may over-suggest common words); the + * server analysis ([functions/src/analyzeLocalization.ts](functions/src/analyzeLocalization.ts)) + * has Claude judge genuine usages for the PR. Kept here (not in `shared-types`) + * because the client can't import a runtime value across the functions↔src wall. + */ + +/** The mention rule, matching `MENTION_RE` in `templateInputs.ts`: a `$` (not + * doubled) followed by an id, `?`, or `!`. */ +const MENTION_RE = /(? { + const ranges: Array<[number, number]> = []; + for (const re of [CODE_RE, CONCEPT_RE, MENTION_RE]) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + ranges.push([m.index, m.index + m[0].length]); + if (m[0].length === 0) re.lastIndex++; + } + } + return ranges; +} + +function inAnyRange(index: number, ranges: Array<[number, number]>): boolean { + return ranges.some(([start, end]) => index >= start && index < end); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Scan `text` for the first whole-word, case-insensitive occurrence of each + * glossary word that isn't inside a protected range, returning one finding per + * term whose `suggestion` replaces that occurrence with `@id`. + */ +export default function scanLiteralGlossaryTerms( + text: string, + glossary: GlossaryWord[], +): LiteralTermFinding[] { + const ranges = protectedRanges(text); + const findings: LiteralTermFinding[] = []; + + for (const { id, word } of glossary) { + if (word.trim().length === 0) continue; + const re = new RegExp(escapeRegExp(word), 'giu'); + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const start = m.index; + const end = start + m[0].length; + if (isWordChar(text[start - 1]) || isWordChar(text[end])) continue; + if (inAnyRange(start, ranges)) continue; + findings.push({ + term: m[0], + id, + suggestion: text.slice(0, start) + '@' + id + text.slice(end), + }); + break; // one finding per term keeps suggestions unambiguous + } + } + + return findings; +} diff --git a/src/locale/readingLevel.ts b/src/locale/readingLevel.ts new file mode 100644 index 0000000000..8202ee2018 --- /dev/null +++ b/src/locale/readingLevel.ts @@ -0,0 +1,22 @@ +/** + * Shared plain-language guidance for translation and reading-level analysis. + * + * Based on WCAG 2.2 Success Criterion 3.1.5, Reading Level (Level AAA): + * https://www.w3.org/WAI/WCAG22/Understanding/reading-level.html + * + * We target a *lower-secondary* reading level via concrete plain-language + * principles rather than a country-specific grade level — grade levels are + * culturally specific and don't suit Wordplay's multilingual audience. + * + * Keep in sync with functions/src/shared/readingLevel.ts (the functions↔src wall + * prevents a single shared module). + */ +export const PLAIN_LANGUAGE_GUIDANCE = `Write at a lower-secondary reading level, following WCAG 2.2 plain-language guidance (Success Criterion 3.1.5). Do not use country-specific grade levels. Apply these plain-language principles: +- Keep sentences short, each expressing a single idea. +- Use common, everyday words; avoid rare, technical, or unusual words unless they are defined (key terms are defined in the glossary). +- Keep each paragraph to a single topic. +- Spell out or explain abbreviations and acronyms. +- Use the active voice and direct action verbs. +- Avoid idioms, metaphors, and figurative language, which often do not translate across cultures. + +Proper names and titles are exempt (per WCAG SC 3.1.5): do not count them as unusual or complex words.`; diff --git a/src/locale/templateInputs.generated.ts b/src/locale/templateInputs.generated.ts index 6a3f199a7c..b2c037112a 100644 --- a/src/locale/templateInputs.generated.ts +++ b/src/locale/templateInputs.generated.ts @@ -516,53 +516,45 @@ export const DECLARED_INPUTS: Readonly> = { /** Names valid in `$name` mentions as terminology references. */ export const TERMINOLOGY_NAMES: readonly string[] = [ + 'abstraction', 'act', - 'bind', - 'boolean', + 'argument', + 'blocks', 'cell', - 'changed', + 'checkpoint', 'code', 'column', - 'convert', - 'decide', - 'document', + 'condition', + 'conflict', 'documentation', + 'element', 'entered', - 'evaluate', - 'exception', - 'feedback', - 'function', - 'group', - 'help', + 'expression', + 'gallery', + 'guide', 'how', 'index', - 'input', + 'iteration', 'key', 'language', - 'list', - 'map', + 'loop', 'markup', 'moved', 'name', - 'none', - 'number', - 'output', - 'pattern', - 'phrase', + 'operator', + 'parameter', + 'placeholder', 'project', + 'property', 'query', + 'recursion', 'region', - 'row', - 'scene', - 'set', - 'source', - 'stage', + 'scope', + 'sideEffect', 'start', - 'stream', - 'structure', - 'table', - 'text', + 'state', + 'tutorial', 'type', - 'unit', 'value', + 'variable', ]; diff --git a/src/locale/templateInputs.ts b/src/locale/templateInputs.ts index e2cd2cccbd..e13b9f609b 100644 --- a/src/locale/templateInputs.ts +++ b/src/locale/templateInputs.ts @@ -12,10 +12,7 @@ * counts as a mention; this module accepts the same set. */ import { isUnwritten } from '@locale/LocaleText'; -import { - DECLARED_INPUTS, - TERMINOLOGY_NAMES, -} from '@locale/templateInputs.generated'; +import { DECLARED_INPUTS } from '@locale/templateInputs.generated'; import { withoutAnnotations } from '@locale/withoutAnnotations'; /** Field path -> ordered list of declared input names. */ @@ -31,19 +28,6 @@ export function getDeclaredInputs(): InputsByField { return cache; } -let termsCache: Set | undefined; - -/** - * Returns the set of terminology keys defined in `LocaleText.term`. A `$name` - * Mention whose name is in this set is a terminology reference (resolved at - * render time from `term[]`), not a template input. - */ -export function getTerminologyNames(): Set { - if (termsCache) return termsCache; - termsCache = new Set(TERMINOLOGY_NAMES); - return termsCache; -} - /** * Mention regex mirroring `Tokenizer.MentionRegEx`: `$?` / `$!` placeholders * or `$`, with a negative lookbehind so escaped `$$N` doesn't @@ -55,12 +39,9 @@ const MENTION_RE = /(?` reference in a template: * - `named` — name appears in `declared`; matches a template input. * - `numeric` — bare `$N` digits; legacy positional ref (now disallowed). - * - `unknown` — name is neither declared nor a known terminology key, e.g. - * a translator typo like `$expecte`. Flagged so the translator can fix it. - * - * Terminology refs (`$program`, `$bind`, etc.) are resolved at render time - * and don't count as template inputs — they're filtered out silently when - * found in `getTerminologyNames()`. + * - `unknown` — name isn't a declared input, e.g. a translator typo like + * `$expecte`, or a stale `$term` glossary reference (glossary terms are now + * `@term`, resolved by ConceptLink). Flagged so it can be fixed. */ export function getTemplateReferences( template: string, @@ -69,7 +50,6 @@ export function getTemplateReferences( const named = new Set(); const numeric = new Set(); const unknown = new Set(); - const terms = getTerminologyNames(); for (const m of template.matchAll(MENTION_RE)) { const name = m[1]; if (name === '?' || name === '!') continue; @@ -78,7 +58,7 @@ export function getTemplateReferences( continue; } if (declared.has(name)) named.add(name); - else if (!terms.has(name)) unknown.add(name); + else unknown.add(name); } return { named, numeric, unknown }; } @@ -93,14 +73,11 @@ export function getTemplateReferences( export function checkTemplateInputs( fieldPath: string, template: string, -): - | { numeric: number[]; unused: string[]; unknown: string[] } - | undefined { +): { numeric: number[]; unused: string[]; unknown: string[] } | undefined { const declared = getDeclaredInputs().get(fieldPath); if (declared === undefined) return undefined; - if (isUnwritten(template)) - return { numeric: [], unused: [], unknown: [] }; + if (isUnwritten(template)) return { numeric: [], unused: [], unknown: [] }; const cleaned = withoutAnnotations(template); const declaredSet = new Set(declared); diff --git a/src/nodes/Bind.ts b/src/nodes/Bind.ts index ac0a80f364..f4d075691a 100644 --- a/src/nodes/Bind.ts +++ b/src/nodes/Bind.ts @@ -32,7 +32,10 @@ import type Definition from '@nodes/Definition'; import Docs from '@nodes/Docs'; import DocumentedExpression from '@nodes/DocumentedExpression'; import Evaluate from '@nodes/Evaluate'; -import Expression, { ExpressionKind, type GuardContext } from '@nodes/Expression'; +import Expression, { + ExpressionKind, + type GuardContext, +} from '@nodes/Expression'; import ExpressionPlaceholder from '@nodes/ExpressionPlaceholder'; import FunctionDefinition from '@nodes/FunctionDefinition'; import FunctionType from '@nodes/FunctionType'; @@ -146,7 +149,9 @@ export default class Bind extends Expression { Bind.make( undefined, Names.make([ - locales.getUnannotatedText((l) => l.term.name), + locales.getUnannotatedText( + (l) => l.glossary.name.word, + ), ]), undefined, node, @@ -166,7 +171,9 @@ export default class Bind extends Expression { Bind.make( undefined, Names.make([ - locales.getUnannotatedText((l) => l.term.name), + locales.getUnannotatedText( + (l) => l.glossary.name.word, + ), ]), TypePlaceholder.make(), ExpressionPlaceholder.make(), @@ -174,7 +181,9 @@ export default class Bind extends Expression { : Bind.make( undefined, Names.make([ - locales.getUnannotatedText((l) => l.term.name), + locales.getUnannotatedText( + (l) => l.glossary.name.word, + ), ]), undefined, ExpressionPlaceholder.make(), @@ -187,7 +196,7 @@ export default class Bind extends Expression { { name: 'docs', kind: any(node(Docs), none()), - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, { name: 'share', @@ -751,14 +760,12 @@ export default class Bind extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.Bind.start, - { - value: this.value === undefined - ? undefined - : new NodeRef(this.value, locales, context), - }, - ); + return locales.concretize((l) => l.node.Bind.start, { + value: + this.value === undefined + ? undefined + : new NodeRef(this.value, locales, context), + }); } getFinishExplanations( @@ -766,18 +773,15 @@ export default class Bind extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Bind.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - name: new NodeRef( + return locales.concretize((l) => l.node.Bind.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + name: new NodeRef( this.names, locales, context, locales.getName(this.names), ), - }, - ); + }); } getDescriptionInputs(locales: Locales) { diff --git a/src/nodes/Block.ts b/src/nodes/Block.ts index dbd119f8b7..f5af7a4d1d 100644 --- a/src/nodes/Block.ts +++ b/src/nodes/Block.ts @@ -23,13 +23,23 @@ import DefinitionExpression from '@nodes/DefinitionExpression'; import Docs from '@nodes/Docs'; import EvalCloseToken from '@nodes/EvalCloseToken'; import EvalOpenToken from '@nodes/EvalOpenToken'; -import Expression, { ExpressionKind, type GuardContext } from '@nodes/Expression'; +import Expression, { + ExpressionKind, + type GuardContext, +} from '@nodes/Expression'; import ExpressionPlaceholder from '@nodes/ExpressionPlaceholder'; import FunctionDefinition from '@nodes/FunctionDefinition'; import Names from '@nodes/Names'; import NoExpressionType from '@nodes/NoExpressionType'; import type Node from '@nodes/Node'; -import { any, list, node, none, type Grammar, type Replacement } from '@nodes/Node'; +import { + any, + list, + node, + none, + type Grammar, + type Replacement, +} from '@nodes/Node'; import ListType from '@nodes/ListType'; import Reference from '@nodes/Reference'; import StructureDefinition from '@nodes/StructureDefinition'; @@ -171,7 +181,7 @@ export default class Block extends Expression { { name: 'docs', kind: node(Docs), - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, { name: 'open', @@ -398,8 +408,7 @@ export default class Block extends Expression { const results: Value[] = []; for (let i = this.statements.length - 1; i >= 0; i--) { const value = evaluator.popValue(this); - if (!Block.isSideEffect(this.statements[i])) - results.unshift(value); + if (!Block.isSideEffect(this.statements[i])) results.unshift(value); } if (this.isStructure()) return new NoneValue(this); if (results.length === 0) return new NoneValue(this); @@ -473,12 +482,9 @@ export default class Block extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Block.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.Block.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getDescriptionInputs() { diff --git a/src/nodes/Branch.ts b/src/nodes/Branch.ts index c0a938c54d..3b02b94e88 100644 --- a/src/nodes/Branch.ts +++ b/src/nodes/Branch.ts @@ -7,7 +7,13 @@ import Characters from '../lore/BasisCharacters'; import Content from '@nodes/Content'; import Mention from '@nodes/Mention'; import type Node from '@nodes/Node'; -import { type Grammar, list, node, optional, type Replacement } from '@nodes/Node'; +import { + type Grammar, + list, + node, + optional, + type Replacement, +} from '@nodes/Node'; import { Sym } from '@nodes/Sym'; import Token from '@nodes/Token'; import Words from '@nodes/Words'; @@ -57,13 +63,13 @@ export default class Branch extends Content { { name: 'yes', kind: list(true, node(Words)), - label: () => (l) => l.term.markup, + label: () => (l) => l.glossary.markup.word, }, { name: 'bar', kind: optional(node(Sym.Union)), label: undefined }, { name: 'no', kind: list(true, node(Words)), - label: () => (l) => l.term.markup, + label: () => (l) => l.glossary.markup.word, }, { name: 'close', kind: node(Sym.ListClose), label: undefined }, ]; diff --git a/src/nodes/ConceptLink.test.ts b/src/nodes/ConceptLink.test.ts new file mode 100644 index 0000000000..93f3ba3729 --- /dev/null +++ b/src/nodes/ConceptLink.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; +import DefaultLocale from '@locale/DefaultLocale'; +import ConceptLink from '@nodes/ConceptLink'; +import parseDoc from '@parser/parseDoc'; +import { DOCS_SYMBOL } from '@parser/Symbols'; +import { toTokens } from '@parser/toTokens'; + +/** Build the ConceptLink from an `@ref` written inside a doc. */ +function link(ref: string): ConceptLink { + const found = parseDoc(toTokens(`${DOCS_SYMBOL} ${ref} ${DOCS_SYMBOL}`)) + .nodes() + .find((n): n is ConceptLink => n instanceof ConceptLink); + if (found === undefined) + throw new Error(`no ConceptLink parsed from ${ref}`); + return found; +} + +describe('ConceptLink.isValid', () => { + test('a glossary term that parses as a how-to keyword is valid (@how)', () => { + // `@how` parses as a HowToName but `how` is a glossary term ("how-to"); + // it must validate via the glossary fallback, not as a how-to id. + expect(link('@how').isValid(DefaultLocale)).toBe(true); + }); + + test('glossary terms validate (@value)', () => { + expect(link('@value').isValid(DefaultLocale)).toBe(true); + }); + + test('concept links validate (@Phrase)', () => { + expect(link('@Phrase').isValid(DefaultLocale)).toBe(true); + }); + + test('a concept with an unknown property does not validate', () => { + expect(link('@Phrase/notaprop').isValid(DefaultLocale)).toBe(false); + }); +}); diff --git a/src/nodes/ConceptLink.ts b/src/nodes/ConceptLink.ts index 5bf3eeff82..b4517d036f 100644 --- a/src/nodes/ConceptLink.ts +++ b/src/nodes/ConceptLink.ts @@ -3,9 +3,11 @@ import { HowToIDs, type HowToID } from '@concepts/HowTo'; import type Conflict from '@conflicts/Conflict'; import type { InsertContext, ReplaceContext } from '@edit/revision/EditContext'; import DefaultLocale from '@locale/DefaultLocale'; +import { getTermDefinition } from '@locale/Glossary'; import type Locales from '@locale/Locales'; import type { TemplateInput } from '@locale/Locales'; import type LocaleText from '@locale/LocaleText'; +import TermRef from '@locale/TermRef'; import type { NodeDescriptor } from '@locale/NodeTexts'; import { Purpose } from '@concepts/Purpose'; import Characters from '../lore/BasisCharacters'; @@ -33,6 +35,11 @@ export const ReservedConceptIDs = new Set([ ...Object.keys(DefaultLocale.output), ]); +/** Glossary term ids referenceable as `@term` (lowercase). Concept ids take + * precedence, so a `@id` resolves to a glossary term only when it isn't a + * concept id. */ +export const ReservedGlossaryIDs = new Set(Object.keys(DefaultLocale.glossary)); + export class ConceptName { readonly name: string; readonly property: string | undefined; @@ -67,6 +74,16 @@ export class HowToName { } } +/** A `@term` reference (lowercase id) that resolves to a glossary entry rather + * than a documented concept. */ +export class GlossaryName { + readonly id: string; + + constructor(id: string) { + this.id = id; + } +} + export class CharacterName { readonly username: string; readonly name: string; @@ -151,6 +168,10 @@ export default class ConceptLink extends Content { if (concept.toLowerCase() === 'how') return new HowToName(property); else if (ReservedConceptIDs.has(concept)) return new ConceptName(concept, property); + // A bare `@term` (no member/separator) whose id is a glossary term, and + // not a concept id, is a glossary reference. Concept ids take precedence. + else if (property === undefined && ReservedGlossaryIDs.has(concept)) + return new GlossaryName(concept); else return new CharacterName(concept, property); } @@ -166,8 +187,17 @@ export default class ConceptLink extends Content { concept instanceof CharacterName ) return true; + // A bare word like `@how` parses as a HowToName (a how-to reference uses + // a specific id, e.g. `@phrase-how-to`), but the same word can be a + // glossary term (`how` → "how-to"). Accept a valid how-to id OR, falling + // back to the link's literal name, a glossary term. if (concept instanceof HowToName) - return HowToIDs.includes(concept.name as HowToID); + return ( + HowToIDs.includes(concept.name as HowToID) || + this.getName() in locale.glossary + ); + if (concept instanceof GlossaryName) + return concept.id in locale.glossary; // See which section of the locale has the concept name, if any. const section = [ @@ -252,6 +282,9 @@ export default class ConceptLink extends Content { : parsed.username, }, ); + // A glossary term: describe it with its definition. + if (parsed instanceof GlossaryName) + return getTermDefinition(locales, parsed.id); // A documented concept (with an optional member), or an unparseable // reference: use the default concept description. return locales.concretize((l) => l.node.ConceptLink.description, { @@ -268,7 +301,14 @@ export default class ConceptLink extends Content { return Characters.Link; } - concretize(): ConceptLink { + concretize(locales: Locales): ConceptLink | TermRef { + // A `@term` glossary reference resolves to a TermRef so it renders as an + // interactive glossary link (via TermView), like an `@term` reference. + const parsed = ConceptLink.parse(this.getName()); + if (parsed instanceof GlossaryName) { + const word = locales.getTermByID(parsed.id); + if (word !== undefined) return new TermRef(parsed.id, word); + } return this; } diff --git a/src/nodes/Content.ts b/src/nodes/Content.ts index 9b95fe4b3c..f7595c27cb 100644 --- a/src/nodes/Content.ts +++ b/src/nodes/Content.ts @@ -2,6 +2,7 @@ import type ConceptRef from '@locale/ConceptRef'; import type Locales from '@locale/Locales'; import type { TemplateInput } from '@locale/Locales'; import type NodeRef from '@locale/NodeRef'; +import type TermRef from '@locale/TermRef'; import type ValueRef from '@locale/ValueRef'; import Node from '@nodes/Node'; import type Token from '@nodes/Token'; @@ -17,7 +18,7 @@ export default abstract class Content extends Node { inputs: Record, /** A mutable list of token replacements, to preserve preceding space after modifications */ replacements: [Node, Node][], - ): Content | Token | NodeRef | ValueRef | ConceptRef | undefined; + ): Content | Token | NodeRef | ValueRef | ConceptRef | TermRef | undefined; abstract toText(): string; } diff --git a/src/nodes/ConversionDefinition.ts b/src/nodes/ConversionDefinition.ts index 6231577856..c8c84f1e96 100644 --- a/src/nodes/ConversionDefinition.ts +++ b/src/nodes/ConversionDefinition.ts @@ -99,7 +99,7 @@ export default class ConversionDefinition extends DefinitionExpression { { name: 'docs', kind: any(node(Docs), none()), - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, { name: 'arrow', kind: node(Sym.Convert), label: undefined }, { diff --git a/src/nodes/ConversionType.ts b/src/nodes/ConversionType.ts index 2b43b96725..193f69c5ce 100644 --- a/src/nodes/ConversionType.ts +++ b/src/nodes/ConversionType.ts @@ -62,13 +62,13 @@ export default class ConversionType extends Type { { name: 'input', kind: node(Type), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, { name: 'output', kind: node(Type), space: true, - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, ]; } diff --git a/src/nodes/Convert.ts b/src/nodes/Convert.ts index 3fa9f31fc5..1622c8ec61 100644 --- a/src/nodes/Convert.ts +++ b/src/nodes/Convert.ts @@ -90,7 +90,7 @@ export default class Convert extends Expression { name: 'type', kind: node(Type), space: true, - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, ]; } @@ -139,7 +139,9 @@ export default class Convert extends Expression { seen.add(key); return true; }) - .map((output) => new Convert(this.expression, this.convert, output)); + .map( + (output) => new Convert(this.expression, this.convert, output), + ); } getConversionSequence( @@ -315,12 +317,9 @@ export default class Convert extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.Convert.start, - { - expression: new NodeRef(this.expression, locales, context), - }, - ); + return locales.concretize((l) => l.node.Convert.start, { + expression: new NodeRef(this.expression, locales, context), + }); } getFinishExplanations( @@ -328,12 +327,9 @@ export default class Convert extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Convert.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.Convert.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/Delete.ts b/src/nodes/Delete.ts index 7c0a3cce98..4fb15ca582 100644 --- a/src/nodes/Delete.ts +++ b/src/nodes/Delete.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import type { ReplaceContext } from '@edit/revision/EditContext'; import type Context from '@nodes/Context'; import type LocaleText from '@locale/LocaleText'; @@ -63,7 +64,7 @@ export default class Delete extends Expression { { name: 'table', kind: node(Expression), - label: () => (l) => l.term.table, + label: () => (l) => getConceptName(l, 'table'), }, { name: 'del', @@ -74,7 +75,7 @@ export default class Delete extends Expression { { name: 'query', kind: node(Expression), - label: () => (l) => l.term.query, + label: () => (l) => l.glossary.query.word, // Must be a boolean getType: () => BooleanType.make(), space: true, diff --git a/src/nodes/Doc.ts b/src/nodes/Doc.ts index 20c55bc1e0..8d8d74410c 100644 --- a/src/nodes/Doc.ts +++ b/src/nodes/Doc.ts @@ -88,7 +88,7 @@ export default class Doc extends LanguageTagged { { name: 'language', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, { name: 'separator', diff --git a/src/nodes/Docs.ts b/src/nodes/Docs.ts index 86b0fbc168..4f377762d8 100644 --- a/src/nodes/Docs.ts +++ b/src/nodes/Docs.ts @@ -47,7 +47,7 @@ export default class Docs extends Node { name: 'docs', kind: list(true, node(Doc)), newline: true, - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, ]; } diff --git a/src/nodes/DocumentedExpression.ts b/src/nodes/DocumentedExpression.ts index d5baad299c..bfcb304579 100644 --- a/src/nodes/DocumentedExpression.ts +++ b/src/nodes/DocumentedExpression.ts @@ -40,7 +40,7 @@ export default class DocumentedExpression extends SimpleExpression { { name: 'expression', kind: node(Expression), - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, ]; } diff --git a/src/nodes/FormattedLiteral.ts b/src/nodes/FormattedLiteral.ts index 1027cdcd01..4b3607bc7b 100644 --- a/src/nodes/FormattedLiteral.ts +++ b/src/nodes/FormattedLiteral.ts @@ -130,7 +130,7 @@ export default class FormattedLiteral extends Literal { { name: 'texts', kind: list(false, node(FormattedTranslation)), - label: () => (l) => l.term.markup, + label: () => (l) => l.glossary.markup.word, }, ]; } diff --git a/src/nodes/FormattedTranslation.ts b/src/nodes/FormattedTranslation.ts index 2d1a1e9aec..415ae15dce 100644 --- a/src/nodes/FormattedTranslation.ts +++ b/src/nodes/FormattedTranslation.ts @@ -104,7 +104,7 @@ export default class FormattedTranslation extends LanguageTagged { { name: 'language', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, { name: 'separator', diff --git a/src/nodes/FormattedType.ts b/src/nodes/FormattedType.ts index 08ecd46ee2..dd707b8cbf 100644 --- a/src/nodes/FormattedType.ts +++ b/src/nodes/FormattedType.ts @@ -62,7 +62,7 @@ export default class FormattedType extends BasisType { { name: 'language', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, ]; } diff --git a/src/nodes/FunctionDefinition.ts b/src/nodes/FunctionDefinition.ts index 3f905b31a7..f7bd0d51f1 100644 --- a/src/nodes/FunctionDefinition.ts +++ b/src/nodes/FunctionDefinition.ts @@ -33,7 +33,14 @@ import FunctionType from '@nodes/FunctionType'; import Names from '@nodes/Names'; import NameType from '@nodes/NameType'; import type Node from '@nodes/Node'; -import { any, list, node, none, type Grammar, type Replacement } from '@nodes/Node'; +import { + any, + list, + node, + none, + type Grammar, + type Replacement, +} from '@nodes/Node'; import NumberType from '@nodes/NumberType'; import PropertyReference from '@nodes/PropertyReference'; import Reference from '@nodes/Reference'; @@ -127,7 +134,9 @@ export default class FunctionDefinition extends DefinitionExpression { return [ FunctionDefinition.make( undefined, - Names.make([locales.getUnannotatedText((l) => l.term.name)]), + Names.make([ + locales.getUnannotatedText((l) => l.glossary.name.word), + ]), undefined, [], ExpressionPlaceholder.make(), @@ -261,7 +270,7 @@ export default class FunctionDefinition extends DefinitionExpression { { name: 'docs', kind: any(node(Docs), none()), - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, { name: 'share', diff --git a/src/nodes/FunctionType.ts b/src/nodes/FunctionType.ts index e078bbd651..f1c5616c9a 100644 --- a/src/nodes/FunctionType.ts +++ b/src/nodes/FunctionType.ts @@ -1,4 +1,5 @@ import { Purpose } from '@concepts/Purpose'; +import getConceptName from '@locale/getConceptName'; import type LocaleText from '@locale/LocaleText'; import type { NodeDescriptor } from '@locale/NodeTexts'; import { FUNCTION_SYMBOL } from '@parser/Symbols'; @@ -14,7 +15,13 @@ import type Expression from '@nodes/Expression'; import ExpressionPlaceholder from '@nodes/ExpressionPlaceholder'; import FunctionDefinition from '@nodes/FunctionDefinition'; import Names from '@nodes/Names'; -import { list, node, optional, type Grammar, type Replacement } from '@nodes/Node'; +import { + list, + node, + optional, + type Grammar, + type Replacement, +} from '@nodes/Node'; import { Sym } from '@nodes/Sym'; import Token from '@nodes/Token'; import Type from '@nodes/Type'; @@ -116,14 +123,14 @@ export default class FunctionType extends Type { kind: list(true, node(Bind)), space: true, indent: true, - label: () => (l) => l.term.input, + label: () => (l) => getConceptName(l, 'input'), }, { name: 'close', kind: node(Sym.EvalClose), label: undefined }, { name: 'output', kind: node(Type), space: true, - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, ]; } diff --git a/src/nodes/Input.ts b/src/nodes/Input.ts index 350ee75ebe..f7f99ec6ea 100644 --- a/src/nodes/Input.ts +++ b/src/nodes/Input.ts @@ -54,16 +54,14 @@ export default class Input extends Node { ); } - static getPossibleReplacements({ - node, - context, - locales, - }: ReplaceContext) { + static getPossibleReplacements({ node, context, locales }: ReplaceContext) { if (!(node instanceof Input)) return []; const parent = node.getParent(context); if (!(parent instanceof Evaluate)) return []; const mapping = parent.getInputMapping(context); - const expected = mapping?.inputs.find((i) => i.given === node)?.expected; + const expected = mapping?.inputs.find( + (i) => i.given === node, + )?.expected; if (expected === undefined) return []; const types = expected.type instanceof UnionType @@ -74,13 +72,12 @@ export default class Input extends Node { return ( types ?.map((t) => t.getDefaultExpression(context)) - .filter((e): e is Exclude => e !== undefined) + .filter( + (e): e is Exclude => e !== undefined, + ) .map( (value) => - new Refer( - (name) => Input.make(name, value), - expected, - ), + new Refer((name) => Input.make(name, value), expected), ) ?? [ new Refer( (name) => Input.make(name, ExpressionPlaceholder.make()), @@ -169,7 +166,7 @@ export default class Input extends Node { } return new NoExpressionType(this.value); }, - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, { name: 'separator', kind: node(Sym.Separator), label: undefined }, ]; diff --git a/src/nodes/Insert.ts b/src/nodes/Insert.ts index 50a04b9b74..ad71c3211f 100644 --- a/src/nodes/Insert.ts +++ b/src/nodes/Insert.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import type { ReplaceContext } from '@edit/revision/EditContext'; import type Context from '@nodes/Context'; import type LocaleText from '@locale/LocaleText'; @@ -70,12 +71,12 @@ export default class Insert extends Expression { { name: 'table', kind: node(Expression), - label: () => (l) => l.term.table, + label: () => (l) => getConceptName(l, 'table'), }, { name: 'row', kind: node(Row), - label: () => (l) => l.term.row, + label: () => (l) => getConceptName(l, 'row'), space: true, }, ]; diff --git a/src/nodes/Is.ts b/src/nodes/Is.ts index c0b04bb727..fcee29195d 100644 --- a/src/nodes/Is.ts +++ b/src/nodes/Is.ts @@ -69,10 +69,14 @@ export default class Is extends Expression { { name: 'expression', kind: node(Expression), - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, { name: 'operator', kind: node(Sym.Type), label: undefined }, - { name: 'type', kind: node(Type), label: () => (l) => l.term.type }, + { + name: 'type', + kind: node(Type), + label: () => (l) => l.glossary.type.word, + }, ]; } @@ -159,12 +163,9 @@ export default class Is extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.Is.start, - { - expression: new NodeRef(this.expression, locales, context), - }, - ); + return locales.concretize((l) => l.node.Is.start, { + expression: new NodeRef(this.expression, locales, context), + }); } getFinishExplanations( @@ -173,13 +174,10 @@ export default class Is extends Expression { evaluator: Evaluator, ) { const result = evaluator.peekValue(); - return locales.concretize( - (l) => l.node.Is.finish, - { - value: result instanceof BoolValue && result.bool, - type: new NodeRef(this.type, locales, context), - }, - ); + return locales.concretize((l) => l.node.Is.finish, { + value: result instanceof BoolValue && result.bool, + type: new NodeRef(this.type, locales, context), + }); } getCharacter() { diff --git a/src/nodes/IsLocale.ts b/src/nodes/IsLocale.ts index 6d6b296670..b8a95b996c 100644 --- a/src/nodes/IsLocale.ts +++ b/src/nodes/IsLocale.ts @@ -54,7 +54,7 @@ export default class IsLocale extends SimpleExpression { { name: 'locale', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, ]; } @@ -121,12 +121,9 @@ export default class IsLocale extends SimpleExpression { } getStartExplanations(locales: Locales) { - return locales.concretize( - (l) => l.node.IsLocale.start, - { - locale: this.locale?.toWordplay() ?? '-', - }, - ); + return locales.concretize((l) => l.node.IsLocale.start, { + locale: this.locale?.toWordplay() ?? '-', + }); } getCharacter() { diff --git a/src/nodes/KeyValue.ts b/src/nodes/KeyValue.ts index 60e94435ba..3c9646ec50 100644 --- a/src/nodes/KeyValue.ts +++ b/src/nodes/KeyValue.ts @@ -52,7 +52,7 @@ export default class KeyValue extends Node { { name: 'key', kind: node(Expression), - label: () => (l) => l.term.key, + label: () => (l) => l.glossary.key.word, space: true, }, { name: 'bind', kind: node(Sym.Bind), label: undefined }, @@ -60,7 +60,7 @@ export default class KeyValue extends Node { name: 'value', kind: node(Expression), space: true, - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, ]; } diff --git a/src/nodes/Language.ts b/src/nodes/Language.ts index 8d878ad918..21a275cf14 100644 --- a/src/nodes/Language.ts +++ b/src/nodes/Language.ts @@ -178,7 +178,10 @@ export default class Language extends Node { * first variants that extend this tag with another language/region, then * the full set of whole-locale replacements. */ getReplacementsForTokenAnchor(): Language[] { - return [...this.getPossibleExtensions(), ...Language.getPossibleLanguages()]; + return [ + ...this.getPossibleExtensions(), + ...Language.getPossibleLanguages(), + ]; } /** Variants of this tag with one more language or region added, drawn from @@ -243,7 +246,7 @@ export default class Language extends Node { { name: 'region', kind: optional(node(Sym.Name)), - label: () => (l) => l.term.region, + label: () => (l) => l.glossary.region.word, }, { name: 'regionExtras', @@ -405,9 +408,7 @@ export default class Language extends Node { /** True if any language in this tag matches the locale's language. */ isLocaleLanguage(locale: Locale) { - return this.getLanguageTexts().some( - (text) => text === locale.language, - ); + return this.getLanguageTexts().some((text) => text === locale.language); } /** True if this tag's regions and the given locale's regions match, where diff --git a/src/nodes/ListAccess.ts b/src/nodes/ListAccess.ts index acf1899318..8ea25d60a7 100644 --- a/src/nodes/ListAccess.ts +++ b/src/nodes/ListAccess.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import UnclosedDelimiter from '@conflicts/UnclosedDelimiter'; import type LocaleText from '@locale/LocaleText'; import NodeRef from '@locale/NodeRef'; @@ -84,7 +85,7 @@ export default class ListAccess extends Expression { { name: 'list', kind: node(Expression), - label: () => (l) => l.term.list, + label: () => (l) => getConceptName(l, 'list'), // Must be a list getType: () => ListType.make(), }, @@ -92,7 +93,7 @@ export default class ListAccess extends Expression { { name: 'index', kind: node(Expression), - label: () => (l) => l.term.index, + label: () => (l) => l.glossary.index.word, // Must be a number getType: () => NumberType.make(), }, @@ -139,7 +140,8 @@ export default class ListAccess extends Expression { if ( !context.isUnknownDownstream(this.index) && (!(indexType instanceof NumberType) || - (indexType.unit instanceof Unit && !indexType.unit.isUnitless())) + (indexType.unit instanceof Unit && + !indexType.unit.isUnitless())) ) conflicts.push( new IncompatibleInput(this.index, indexType, NumberType.make()), @@ -282,12 +284,9 @@ export default class ListAccess extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.ListAccess.start, - { - list: new NodeRef(this.list, locales, context), - }, - ); + return locales.concretize((l) => l.node.ListAccess.start, { + list: new NodeRef(this.list, locales, context), + }); } getFinishExplanations( @@ -295,12 +294,9 @@ export default class ListAccess extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.ListAccess.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.ListAccess.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/ListLiteral.ts b/src/nodes/ListLiteral.ts index 694e475b6e..4508d7d587 100644 --- a/src/nodes/ListLiteral.ts +++ b/src/nodes/ListLiteral.ts @@ -78,7 +78,7 @@ export default class ListLiteral extends CompositeLiteral { { name: 'values', kind: list(true, node(Expression), node(Spread)), - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, // Only allow types to be inserted that are of the surrounding field's expected type. getType: (context) => { // What is the field of this list? @@ -280,12 +280,9 @@ export default class ListLiteral extends CompositeLiteral { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.ListLiteral.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.ListLiteral.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getDescriptionInputs() { diff --git a/src/nodes/ListType.ts b/src/nodes/ListType.ts index 4367642628..02efc5267c 100644 --- a/src/nodes/ListType.ts +++ b/src/nodes/ListType.ts @@ -70,7 +70,7 @@ export default class ListType extends BasisType { { name: 'type', kind: optional(node(Type)), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, { name: 'close', kind: node(Sym.ListClose), label: undefined }, ]; @@ -131,7 +131,9 @@ export default class ListType extends BasisType { getDescriptionInputs(locales: Locales, context: Context) { return { - type: this.type ? new NodeRef(this.type, locales, context) : undefined, + type: this.type + ? new NodeRef(this.type, locales, context) + : undefined, }; } diff --git a/src/nodes/MapType.ts b/src/nodes/MapType.ts index 7d7180f065..a540862e8d 100644 --- a/src/nodes/MapType.ts +++ b/src/nodes/MapType.ts @@ -82,7 +82,7 @@ export default class MapType extends BasisType { node(Type), none(['value', () => ExpressionPlaceholder.make()]), ), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, { name: 'bind', kind: node(Sym.Bind), label: undefined }, { @@ -91,7 +91,7 @@ export default class MapType extends BasisType { node(Type), none(['key', () => ExpressionPlaceholder.make()]), ), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, { name: 'close', kind: node(Sym.SetClose), label: undefined }, ]; @@ -176,7 +176,9 @@ export default class MapType extends BasisType { getDescriptionInputs(locales: Locales, context: Context) { return { key: this.key ? new NodeRef(this.key, locales, context) : undefined, - value: this.value ? new NodeRef(this.value, locales, context) : undefined, + value: this.value + ? new NodeRef(this.value, locales, context) + : undefined, }; } diff --git a/src/nodes/Match.ts b/src/nodes/Match.ts index 706f5dd3d8..203d799fd5 100644 --- a/src/nodes/Match.ts +++ b/src/nodes/Match.ts @@ -104,7 +104,7 @@ export default class Match extends Expression { { name: 'value', kind: node(Expression), - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, { name: 'question', @@ -285,12 +285,9 @@ export default class Match extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.Match.start, - { - value: new NodeRef(this.value, locales, context), - }, - ); + return locales.concretize((l) => l.node.Match.start, { + value: new NodeRef(this.value, locales, context), + }); } getFinishExplanations(locales: Locales) { diff --git a/src/nodes/Mention.ts b/src/nodes/Mention.ts index d6ea813141..01492ce2f6 100644 --- a/src/nodes/Mention.ts +++ b/src/nodes/Mention.ts @@ -14,16 +14,14 @@ import { Sym } from '@nodes/Sym'; import Token from '@nodes/Token'; /** - * To refer to an input, use a $, followed by the number of the input desired, - * starting from 1. + * A `$` mention substitutes a template input by name (the `$?`/`$!` placeholders + * are handled specially below): * - * "Hello, my name is $1" + * "I expected $expected, but received $given" * - * To indicate that you want to reuse a common phrase defined in a locale's "terminology" dictionary, - * use a $ followed by any number of word characters (in regex, /\$\w/). This allows - * for terminology to be changed globally without search and replace. - * - * "To create a new $program, click here." + * `$` is only for input substitution. Glossary terms are referenced with `@term` + * (resolved by ConceptLink, alongside `@Concept` links), so a `$name` that isn't + * a declared input resolves to nothing here. */ export default class Mention extends Content { readonly name: Token; @@ -110,15 +108,9 @@ export default class Mention extends Content { return replacement; } - // Try to resolve terminology. - else { - const term = locales.getTermByID(name); - const replacement = term ? new Token(term, Sym.Words) : undefined; - - if (replacement instanceof Token) - replacements.push([this, replacement]); - return replacement; - } + // Not a placeholder or a declared input: nothing to substitute. Glossary + // terms are `@term` now (resolved by ConceptLink), not `$term`. + else return undefined; } getDescriptionInputs() { diff --git a/src/nodes/Name.ts b/src/nodes/Name.ts index faec6c8b6e..85c72cb6f3 100644 --- a/src/nodes/Name.ts +++ b/src/nodes/Name.ts @@ -53,7 +53,7 @@ export default class Name extends LanguageTagged { { name: 'language', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, { name: 'separator', @@ -78,7 +78,9 @@ export default class Name extends LanguageTagged { /** Suggest names for insertion. */ static getPossibleInsertions({ locales }: InsertContext) { - return [Name.make(locales.getUnannotatedText((l) => l.term.name))]; + return [ + Name.make(locales.getUnannotatedText((l) => l.glossary.name.word)), + ]; } simplify() { diff --git a/src/nodes/NumberLiteral.ts b/src/nodes/NumberLiteral.ts index cce2b7baa0..aa307509cb 100644 --- a/src/nodes/NumberLiteral.ts +++ b/src/nodes/NumberLiteral.ts @@ -133,7 +133,7 @@ export default class NumberLiteral extends Literal { { name: 'unit', kind: optional(node(Unit)), - label: () => (l) => l.term.unit, + label: () => (l) => l.node.Unit.name, }, ]; } @@ -203,22 +203,24 @@ export default class NumberLiteral extends Literal { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.NumberLiteral.start, - { - value: new NodeRef(this.number, locales, context), - }, - ); + return locales.concretize((l) => l.node.NumberLiteral.start, { + value: new NodeRef(this.number, locales, context), + }); } getCharacter() { return Characters.Number; } - getDescriptionInputs(locales: Locales, context: Context): Record { + getDescriptionInputs( + locales: Locales, + context: Context, + ): Record { return { number: this.number.getText(), - unit: this.unit ? new NodeRef(this.unit, locales, context) : undefined, + unit: this.unit + ? new NodeRef(this.unit, locales, context) + : undefined, }; } diff --git a/src/nodes/Otherwise.ts b/src/nodes/Otherwise.ts index a09e0e5d8d..6fc6640d18 100644 --- a/src/nodes/Otherwise.ts +++ b/src/nodes/Otherwise.ts @@ -71,7 +71,7 @@ export default class Otherwise extends SimpleExpression { { name: 'left', kind: node(Expression), - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, { name: 'question', @@ -83,7 +83,7 @@ export default class Otherwise extends SimpleExpression { name: 'right', kind: node(Expression), space: true, - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, ]; } @@ -184,12 +184,9 @@ export default class Otherwise extends SimpleExpression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Otherwise.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.Otherwise.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/Paragraph.ts b/src/nodes/Paragraph.ts index 38ff18d849..be0281804c 100644 --- a/src/nodes/Paragraph.ts +++ b/src/nodes/Paragraph.ts @@ -80,7 +80,7 @@ export default class Paragraph extends Content { node(Mention), node(Branch), ), - label: () => (l) => l.term.markup, + label: () => (l) => l.glossary.markup.word, }, ]; } diff --git a/src/nodes/Previous.ts b/src/nodes/Previous.ts index 694f74b3bc..196b297126 100644 --- a/src/nodes/Previous.ts +++ b/src/nodes/Previous.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import type { InsertContext } from '@edit/revision/EditContext'; import type LocaleText from '@locale/LocaleText'; import NodeRef from '@locale/NodeRef'; @@ -87,7 +88,7 @@ export default class Previous extends Expression { { name: 'number', kind: node(Expression), - label: () => (l) => l.term.index, + label: () => (l) => l.glossary.index.word, // Must be a number getType: () => NumberType.make(), space: true, @@ -95,7 +96,7 @@ export default class Previous extends Expression { { name: 'stream', kind: node(Expression), - label: () => (l) => l.term.stream, + label: () => (l) => getConceptName(l, 'stream'), // Must be a stream getType: () => StreamType.make(new AnyType()), space: true, @@ -216,12 +217,9 @@ export default class Previous extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.Previous.start, - { - stream: new NodeRef(this.stream, locales, context), - }, - ); + return locales.concretize((l) => l.node.Previous.start, { + stream: new NodeRef(this.stream, locales, context), + }); } getFinishExplanations( @@ -229,12 +227,9 @@ export default class Previous extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Previous.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.Previous.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/Program.ts b/src/nodes/Program.ts index 2e734ebbe5..1c180dcd95 100644 --- a/src/nodes/Program.ts +++ b/src/nodes/Program.ts @@ -78,7 +78,7 @@ export default class Program extends Expression { { name: 'docs', kind: any(node(Docs), none()), - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, { name: 'borrows', @@ -239,17 +239,14 @@ export default class Program extends Expression { const reaction = evaluator.getReactionPriorTo(evaluator.getStepIndex()); const change = reaction && reaction.changes.length > 0; - return locales.concretize( - (l) => l.node.Program.start, - { - stream: change + return locales.concretize((l) => l.node.Program.start, { + stream: change ? new ValueRef(reaction.changes[0].stream, locales, context) : undefined, - value: change + value: change ? new ValueRef(reaction.changes[0].value, locales, context) : undefined, - }, - ); + }); } getFinishExplanations( @@ -257,12 +254,9 @@ export default class Program extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Program.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.Program.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/Row.ts b/src/nodes/Row.ts index 318a2f0f24..d922cec98b 100644 --- a/src/nodes/Row.ts +++ b/src/nodes/Row.ts @@ -65,7 +65,7 @@ export default class Row extends Node { name: 'cells', kind: list(true, node(Input), node(Expression)), space: true, - label: () => (l) => l.term.cell, + label: () => (l) => l.glossary.cell.word, }, { name: 'close', kind: node(Sym.TableClose), label: undefined }, ]; diff --git a/src/nodes/Select.ts b/src/nodes/Select.ts index d3828d84dc..358d722811 100644 --- a/src/nodes/Select.ts +++ b/src/nodes/Select.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import ExpectedSelectName from '@conflicts/ExpectedSelectName'; import UnknownColumn from '@conflicts/UnknownColumn'; import type { ReplaceContext } from '@edit/revision/EditContext'; @@ -81,18 +82,18 @@ export default class Select extends Expression { { name: 'table', kind: node(Expression), - label: () => (l) => l.term.table, + label: () => (l) => getConceptName(l, 'table'), }, { name: 'row', kind: node(Row), - label: () => (l) => l.term.row, + label: () => (l) => getConceptName(l, 'row'), space: true, }, { name: 'query', kind: node(Expression), - label: () => (l) => l.term.query, + label: () => (l) => l.glossary.query.word, space: true, }, ]; diff --git a/src/nodes/SetLiteral.ts b/src/nodes/SetLiteral.ts index d543328cbf..f398d31d99 100644 --- a/src/nodes/SetLiteral.ts +++ b/src/nodes/SetLiteral.ts @@ -75,7 +75,7 @@ export default class SetLiteral extends CompositeLiteral { { name: 'values', kind: list(true, node(Expression)), - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, // Only allow types to be inserted that are of the list's type, if provided. getType: (context) => this.getItemType(context) ?? new AnyType(), @@ -206,12 +206,9 @@ export default class SetLiteral extends CompositeLiteral { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.SetLiteral.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.SetLiteral.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getDescriptionInputs() { diff --git a/src/nodes/SetOrMapAccess.ts b/src/nodes/SetOrMapAccess.ts index beb6090fb2..5e2ed10b12 100644 --- a/src/nodes/SetOrMapAccess.ts +++ b/src/nodes/SetOrMapAccess.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import { IncompatibleKey } from '@conflicts/IncompatibleKey'; import UnclosedDelimiter from '@conflicts/UnclosedDelimiter'; import type LocaleText from '@locale/LocaleText'; @@ -84,7 +85,7 @@ export default class SetOrMapAccess extends Expression { { name: 'setOrMap', kind: node(Expression), - label: () => (l) => l.term.set, + label: () => (l) => getConceptName(l, 'set'), // Must be a number getType: () => UnionType.make(SetType.make(), MapType.make()), }, @@ -92,7 +93,7 @@ export default class SetOrMapAccess extends Expression { { name: 'key', kind: node(Expression), - label: () => (l) => l.term.key, + label: () => (l) => l.glossary.key.word, }, { name: 'close', kind: node(Sym.SetClose), label: undefined }, ]; @@ -295,12 +296,9 @@ export default class SetOrMapAccess extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.SetOrMapAccess.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.SetOrMapAccess.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/SetType.ts b/src/nodes/SetType.ts index 645d3d5245..0b282abbf8 100644 --- a/src/nodes/SetType.ts +++ b/src/nodes/SetType.ts @@ -58,7 +58,7 @@ export default class SetType extends BasisType { { name: 'key', kind: optional(node(Type)), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, { name: 'close', kind: node(Sym.SetClose), label: undefined }, ]; @@ -126,7 +126,9 @@ export default class SetType extends BasisType { getDescriptionInputs(locales: Locales, context: Context) { return { - type: this.key ? new NodeRef(this.key, locales, context) : undefined, + type: this.key + ? new NodeRef(this.key, locales, context) + : undefined, }; } diff --git a/src/nodes/Spread.ts b/src/nodes/Spread.ts index 0101a474e2..45f16cc6c3 100644 --- a/src/nodes/Spread.ts +++ b/src/nodes/Spread.ts @@ -1,4 +1,5 @@ import type { ReplaceContext } from '@edit/revision/EditContext'; +import getConceptName from '@locale/getConceptName'; import type LocaleText from '@locale/LocaleText'; import type { NodeDescriptor } from '@locale/NodeTexts'; import type { BasisTypeName } from '@basis/BasisConstants'; @@ -56,7 +57,7 @@ export default class Spread extends Node { name: 'list', kind: optional(node(Expression)), getType: () => ListType.make(new AnyType()), - label: () => (l) => l.term.list, + label: () => (l) => getConceptName(l, 'list'), }, ]; } diff --git a/src/nodes/StreamDefinition.ts b/src/nodes/StreamDefinition.ts index c061fc87a7..0a8cddc11b 100644 --- a/src/nodes/StreamDefinition.ts +++ b/src/nodes/StreamDefinition.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import type LocaleText from '@locale/LocaleText'; import type { NodeDescriptor } from '@locale/NodeTexts'; import { STREAM_SYMBOL } from '@parser/Symbols'; @@ -113,7 +114,7 @@ export default class StreamDefinition extends DefinitionExpression { { name: 'docs', kind: optional(node(Docs)), - label: () => (l) => l.term.documentation, + label: () => (l) => l.glossary.documentation.word, }, { name: 'dots', kind: node(Sym.Stream), label: undefined }, { name: 'names', kind: node(Names), label: undefined }, @@ -123,7 +124,7 @@ export default class StreamDefinition extends DefinitionExpression { kind: list(true, node(Bind)), space: true, indent: true, - label: () => (l) => l.term.input, + label: () => (l) => getConceptName(l, 'input'), }, { name: 'close', kind: node(Sym.EvalClose), label: undefined }, { @@ -137,7 +138,7 @@ export default class StreamDefinition extends DefinitionExpression { { name: 'output', kind: any(node(Type), none(['dot', () => new TypeToken()])), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, ]; } diff --git a/src/nodes/StreamType.ts b/src/nodes/StreamType.ts index 4fb5d5e2e2..58e788dd1e 100644 --- a/src/nodes/StreamType.ts +++ b/src/nodes/StreamType.ts @@ -45,7 +45,11 @@ export default class StreamType extends Type { getGrammar(): Grammar { return [ { name: 'stream', kind: node(Sym.Stream), label: undefined }, - { name: 'type', kind: node(Type), label: () => (l) => l.term.type }, + { + name: 'type', + kind: node(Type), + label: () => (l) => l.glossary.type.word, + }, ]; } diff --git a/src/nodes/TableLiteral.ts b/src/nodes/TableLiteral.ts index 95be2ce324..dd0ebeec21 100644 --- a/src/nodes/TableLiteral.ts +++ b/src/nodes/TableLiteral.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import type LocaleText from '@locale/LocaleText'; import type { NodeDescriptor } from '@locale/NodeTexts'; import type Evaluator from '@runtime/Evaluator'; @@ -183,13 +184,13 @@ export default class TableLiteral extends CompositeLiteral { { name: 'type', kind: node(TableType), - label: () => (l) => l.term.table, + label: () => (l) => getConceptName(l, 'table'), }, { name: 'rows', kind: list(true, node(Row)), newline: true, - label: () => (l) => l.term.row, + label: () => (l) => getConceptName(l, 'row'), }, ]; } @@ -348,12 +349,9 @@ export default class TableLiteral extends CompositeLiteral { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.TableLiteral.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.TableLiteral.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/TableType.ts b/src/nodes/TableType.ts index c1c23c0e23..0c2a5abb07 100644 --- a/src/nodes/TableType.ts +++ b/src/nodes/TableType.ts @@ -67,7 +67,7 @@ export default class TableType extends BasisType { { name: 'columns', kind: list(true, node(Bind)), - label: () => (l) => l.term.column, + label: () => (l) => l.glossary.column.word, space: true, }, { name: 'close', kind: node(Sym.TableClose), label: undefined }, diff --git a/src/nodes/TextType.ts b/src/nodes/TextType.ts index 68f11c900e..4d2ca57d1d 100644 --- a/src/nodes/TextType.ts +++ b/src/nodes/TextType.ts @@ -80,7 +80,7 @@ export default class TextType extends BasisType { { name: 'language', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, ]; } diff --git a/src/nodes/Translate.ts b/src/nodes/Translate.ts index 41f01b688f..122ae0ee36 100644 --- a/src/nodes/Translate.ts +++ b/src/nodes/Translate.ts @@ -141,7 +141,7 @@ export default class Translate extends Expression { name: 'translation', kind: node(Expression), space: true, - label: () => (l) => l.term.value, + label: () => (l) => l.glossary.value.word, }, ]; } @@ -161,8 +161,7 @@ export default class Translate extends Expression { /** The type of `.` (This) inside the translation body: the element/value/row type of the left collection. */ getItemType(context: Context): Type { const leftType = this.expression.getType(context); - if (leftType instanceof ListType) - return leftType.type ?? new AnyType(); + if (leftType instanceof ListType) return leftType.type ?? new AnyType(); if (leftType instanceof SetType) return leftType.key ?? new AnyType(); if (leftType instanceof MapType) return leftType.value ?? new AnyType(); if (leftType instanceof TableType) @@ -302,12 +301,9 @@ export default class Translate extends Expression { } getStartExplanations(locales: Locales, context: Context) { - return locales.concretize( - (l) => l.node.Translate.start, - { - expression: new NodeRef(this.expression, locales, context), - }, - ); + return locales.concretize((l) => l.node.Translate.start, { + expression: new NodeRef(this.expression, locales, context), + }); } getFinishExplanations( @@ -315,12 +311,9 @@ export default class Translate extends Expression { context: Context, evaluator: Evaluator, ) { - return locales.concretize( - (l) => l.node.Translate.finish, - { - value: this.getValueIfDefined(locales, context, evaluator), - }, - ); + return locales.concretize((l) => l.node.Translate.finish, { + value: this.getValueIfDefined(locales, context, evaluator), + }); } getCharacter() { diff --git a/src/nodes/Translation.ts b/src/nodes/Translation.ts index 0d478b8c2e..24545c0885 100644 --- a/src/nodes/Translation.ts +++ b/src/nodes/Translation.ts @@ -88,7 +88,7 @@ export default class Translation extends LanguageTagged { { name: 'language', kind: optional(node(Language)), - label: () => (l) => l.term.language, + label: () => (l) => l.glossary.language.word, }, { name: 'separator', diff --git a/src/nodes/TypeInputs.ts b/src/nodes/TypeInputs.ts index f20ba03477..615b50840e 100644 --- a/src/nodes/TypeInputs.ts +++ b/src/nodes/TypeInputs.ts @@ -54,7 +54,7 @@ export default class TypeInputs extends Node { { name: 'types', kind: list(true, node(Type)), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, { name: 'close', diff --git a/src/nodes/TypePlaceholder.ts b/src/nodes/TypePlaceholder.ts index 6e30633dff..d343f1534d 100644 --- a/src/nodes/TypePlaceholder.ts +++ b/src/nodes/TypePlaceholder.ts @@ -47,7 +47,7 @@ export default class TypePlaceholder extends Type { { name: 'placeholder', kind: node(Sym.Placeholder), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, ]; } diff --git a/src/nodes/UnionType.ts b/src/nodes/UnionType.ts index cb9b665cd2..7d62a4f1f4 100644 --- a/src/nodes/UnionType.ts +++ b/src/nodes/UnionType.ts @@ -65,7 +65,11 @@ export default class UnionType extends Type { getGrammar(): Grammar { return [ - { name: 'left', kind: node(Type), label: () => (l) => l.term.type }, + { + name: 'left', + kind: node(Type), + label: () => (l) => l.glossary.type.word, + }, { name: 'or', kind: node(Sym.Union), @@ -74,7 +78,7 @@ export default class UnionType extends Type { { name: 'right', kind: node(Type), - label: () => (l) => l.term.type, + label: () => (l) => l.glossary.type.word, }, ]; } @@ -293,13 +297,10 @@ export default class UnionType extends Type { .join(', '); const head = filtered.length >= 3 ? `${headText},` : headText; const tail = last.getDescription(locales, context).toText(); - return locales.concretize( - (l) => l.node.UnionType.description, - { - first: head, - second: tail, - }, - ); + return locales.concretize((l) => l.node.UnionType.description, { + first: head, + second: tail, + }); } concretize(context: Context) { diff --git a/src/nodes/Unit.ts b/src/nodes/Unit.ts index 6b9af821c2..e0fa084086 100644 --- a/src/nodes/Unit.ts +++ b/src/nodes/Unit.ts @@ -198,7 +198,7 @@ export default class Unit extends Node { { name: 'numerator', kind: list(true, node(Dimension)), - label: () => (l) => l.term.unit, + label: () => (l) => l.node.Unit.name, }, { name: 'slash', @@ -208,7 +208,7 @@ export default class Unit extends Node { { name: 'denominator', kind: list(true, node(Dimension)), - label: () => (l) => l.term.unit, + label: () => (l) => l.node.Unit.name, }, ]; } diff --git a/src/nodes/Update.ts b/src/nodes/Update.ts index fa2a44c7ae..ce7dae3894 100644 --- a/src/nodes/Update.ts +++ b/src/nodes/Update.ts @@ -1,4 +1,5 @@ import type Conflict from '@conflicts/Conflict'; +import getConceptName from '@locale/getConceptName'; import ExpectedColumnBind from '@conflicts/ExpectedColumnBind'; import IncompatibleCellType from '@conflicts/IncompatibleCellType'; import UnknownColumn from '@conflicts/UnknownColumn'; @@ -80,18 +81,18 @@ export default class Update extends Expression { { name: 'table', kind: node(Expression), - label: () => (l) => l.term.table, + label: () => (l) => getConceptName(l, 'table'), }, { name: 'row', kind: node(Row), - label: () => (l) => l.term.row, + label: () => (l) => getConceptName(l, 'row'), space: true, }, { name: 'query', kind: node(Expression), - label: () => (l) => l.term.query, + label: () => (l) => l.glossary.query.word, space: true, }, ]; diff --git a/src/nodes/WebLink.ts b/src/nodes/WebLink.ts index b16979071a..8bfb1af805 100644 --- a/src/nodes/WebLink.ts +++ b/src/nodes/WebLink.ts @@ -75,7 +75,7 @@ export default class WebLink extends Content { { name: 'description', kind: node(Sym.Words), - label: () => (l) => l.term.markup, + label: () => (l) => l.glossary.markup.word, }, { name: 'at', kind: node(Sym.Link), label: undefined }, { name: 'url', kind: node(Sym.URL), label: () => (l) => 'url' }, diff --git a/src/nodes/Words.ts b/src/nodes/Words.ts index 8727f3ab35..e045f4c64e 100644 --- a/src/nodes/Words.ts +++ b/src/nodes/Words.ts @@ -87,7 +87,7 @@ export default class Words extends Content { node(Mention), node(Branch), ), - label: () => (l) => l.term.markup, + label: () => (l) => l.glossary.markup.word, }, { name: 'close', diff --git a/src/routes/ErrorText.ts b/src/routes/ErrorText.ts index cda1aa61b7..410e0a5c7a 100644 --- a/src/routes/ErrorText.ts +++ b/src/routes/ErrorText.ts @@ -1,7 +1,7 @@ type ErrorText = { - /** The header for the unknown route */ + /** [plain] The header for the unknown route */ header: string; - /** The message for the unknown route */ + /** [plain] The message for the unknown route */ message: string; }; diff --git a/src/routes/[[locale]]/PageText.ts b/src/routes/[[locale]]/PageText.ts index e4b5154a60..264d91add8 100644 --- a/src/routes/[[locale]]/PageText.ts +++ b/src/routes/[[locale]]/PageText.ts @@ -3,7 +3,7 @@ import type { FormattedText } from '@locale/LocaleText'; type PageText = { /** [formatted] The value proposition for the site */ value: FormattedText; - /** A description of the platform's features */ + /** [formatted] A description of the platform's features */ description: FormattedText | FormattedText[]; /** [formatted] The landing page beta warning */ beta: FormattedText[]; @@ -31,9 +31,9 @@ type PageText = { rights: string; /** [plain] What content is on the updates page */ updates: string; - /** The community link */ + /** [plain] The community link */ community: { label: string; subtitle: string }; - /** The contributor link */ + /** [plain] The contributor link */ contribute: { label: string; subtitle: string }; /** [plain] What content is on the design page */ design: string; diff --git a/src/routes/[[locale]]/character/[id]/PageText.ts b/src/routes/[[locale]]/character/[id]/PageText.ts index f21039acbd..3e454591d8 100644 --- a/src/routes/[[locale]]/character/[id]/PageText.ts +++ b/src/routes/[[locale]]/character/[id]/PageText.ts @@ -7,7 +7,9 @@ import type { } from '@locale/UITexts'; type PageText = { + /** [plain] The character editor page header */ header: string; + /** [plain] Guidance shown in the character editor for each tool and selection state */ instructions: { empty: string; unselected: string; @@ -19,6 +21,7 @@ type PageText = { path: string; emoji: string; }; + /** [plain] Names of the character editor shape tools */ shape: { shape: string; eraser: string; @@ -33,6 +36,7 @@ type PageText = { button: ButtonText; delete: ButtonText; public: ModeText; + /** [plain] Label for the character's collaborators list */ collaborators: string; }; field: { @@ -47,15 +51,15 @@ type PageText = { none: string; /** [plain] What to call inherited color */ inherit: string; - /** Labels for the stroke width slider*/ + /** [plain] Labels for the stroke width slider*/ strokeWidth: { label: string; tip: string }; - /** Labels for the border radius slider */ + /** [plain] Labels for the border radius slider */ radius: { label: string; tip: string }; - /** Labels for the rotation slider */ + /** [plain] Labels for the rotation slider */ angle: { label: string; tip: string }; - /** Width slider */ + /** [plain] Width slider */ width: { label: string; tip: string }; - /** Height slider */ + /** [plain] Height slider */ height: { label: string; tip: string }; /** [plain] Closed path label */ closed: string; diff --git a/src/routes/[[locale]]/gallery/[galleryid]/+page.svelte b/src/routes/[[locale]]/gallery/[galleryid]/+page.svelte index 307ab46410..1da3bff0e7 100644 --- a/src/routes/[[locale]]/gallery/[galleryid]/+page.svelte +++ b/src/routes/[[locale]]/gallery/[galleryid]/+page.svelte @@ -3,6 +3,7 @@ import AddProject from '@components/app/AddProject.svelte'; import Link from '@components/app/Link.svelte'; import Loading from '@components/app/Loading.svelte'; + import HeaderAndExplanation from '@components/app/HeaderAndExplanation.svelte'; import Notice from '@components/app/Notice.svelte'; import PageHeader from '@components/app/PageHeader.svelte'; import ProjectPreviewSet from '@components/app/ProjectPreviewSet.svelte'; @@ -275,11 +276,9 @@
{#if editable || gallery.getCurators().length > 0} - l.ui.gallery.subheader.curators.header} - /> - l.ui.gallery.subheader.curators.explanation} + l.ui.gallery.subheader.curators} + sub /> 0} - l.ui.gallery.subheader.creators.header} - /> - l.ui.gallery.subheader.creators.explanation} + l.ui.gallery.subheader.creators} + sub /> 0} - l.ui.gallery.subheader.classes.header} - /> - l.ui.gallery.subheader.classes.explanation} + l.ui.gallery.subheader.classes} + sub />
    @@ -361,9 +356,9 @@ : undefined; }} /> - l.ui.gallery.subheader.delete.header} /> - l.ui.gallery.subheader.delete.explanation} + l.ui.gallery.subheader.delete} + sub />

    diff --git a/src/routes/[[locale]]/gallery/[galleryid]/howto/PageText.ts b/src/routes/[[locale]]/gallery/[galleryid]/howto/PageText.ts index d0b47df306..b44a16620c 100644 --- a/src/routes/[[locale]]/gallery/[galleryid]/howto/PageText.ts +++ b/src/routes/[[locale]]/gallery/[galleryid]/howto/PageText.ts @@ -110,7 +110,9 @@ type PageText = { countDisplay: Template<['count']>; /** [plain] Options text for selecting which projects and how-tos used this how-to */ selector: string; + /** [plain] Button to remove a project/how-to from those using this how-to */ removeButton: string; + /** [plain] Button to add a project/how-to to those using this how-to */ addButton: string; }; /** [plain] Prompt for reaction summary */ @@ -145,7 +147,7 @@ type PageText = { subheader: HeaderAndExplanationText; /** [plain] Guiding questions description */ descriptor: string; - /** Guiding questions default text */ + /** [plain] Guiding questions default text */ default: string[]; }; /** Subheaders and descriptions for configuring reaction options */ @@ -160,14 +162,15 @@ type PageText = { addReactionTip: string; /** [plain] Remove reaction tip */ removeReactionTip: string; - /** Default reactions */ + /** [plain] Default reactions */ default: Record; }; submit: ButtonText & { + /** [plain] Error shown when saving how-to configuration fails */ error: string; }; }; - /** For announcing changes to the canvas or to how-to positions */ + /** [formatted] For announcing changes to the canvas or to how-to positions */ announce: { howToPosition: Template<['title', 'x', 'y']>; canvasPosition: Template<['x', 'y']>; diff --git a/src/routes/[[locale]]/localize/+page.svelte b/src/routes/[[locale]]/localize/+page.svelte index dd37fa881d..0ad6c55412 100644 --- a/src/routes/[[locale]]/localize/+page.svelte +++ b/src/routes/[[locale]]/localize/+page.svelte @@ -35,6 +35,7 @@ } from '@locale/LocaleText'; import { checkTemplateInputs } from '@locale/templateInputs'; import { withoutAnnotations } from '@locale/withoutAnnotations'; + import LocalizationQuality from '@components/localization/LocalizationQuality.svelte'; import { CANCEL_SYMBOL, CONFIRM_SYMBOL, @@ -47,7 +48,7 @@ import { debounced } from '@util/debounce.svelte'; import { localizeFields } from './localizeSearch'; import { httpsCallable } from 'firebase/functions'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; import { Emotion } from '../../../lore/Emotion'; import { isTutorialKey } from '../../../tutorial/TutorialPath'; @@ -312,6 +313,10 @@ /** The search results for the current query, or null when there's no query * (meaning "everything matches, in the default order"). */ const searchMatched = $derived.by(() => { + // Clearing the filter takes effect immediately (don't wait out the + // debounce); this also lets editFromBundle reveal a path without the + // visibility guard racing the debounce and dropping the selection. + if (filterQuery.trim() === '') return null; const q = debouncedFilter.current.trim(); return q === '' ? null : searchItems(searchRecords, q, searchLanguages); }); @@ -740,7 +745,7 @@ /** Jump from the bundle viewer back to the editor for that entry, and scroll the * workspace area into view so the editor is visible. */ - function editFromBundle(overrideKey: string) { + async function editFromBundle(overrideKey: string) { const { path, index } = parseOverrideKey(overrideKey); // Clear filters so the path is reachable. filterQuery = ''; @@ -752,6 +757,9 @@ // dance required. manualIndex = index; workspaceTop?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Focus the editor so it's clearly in edit mode for the chosen entry. + await tick(); + (editorView ?? textAreaView ?? textInputView)?.focus(); } /** Submit the current bundle to the backend. On success, the function opens @@ -940,6 +948,16 @@ {#if selectedPath !== undefined} + {#if currentEnglishText !== ''} +

    +

    + l.ui.localize.reference} + /> +

    +

    {currentEnglishText}

    +
    + {/if} {#if arrayLength > 1}