diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb1966fe..70b9ca5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: pull_request: jobs: - lint: + eslint: name: Eslint runs-on: ubuntu-latest steps: @@ -22,7 +22,7 @@ jobs: run: npm ci - name: Run eslint - run: npm run lint + run: npm run eslint test-unit: name: Unit Tests diff --git a/HACKING.md b/HACKING.md index 6a0f1d5a..dad58309 100644 --- a/HACKING.md +++ b/HACKING.md @@ -1,38 +1,47 @@ # Hacking + Here are some internal info that might be useful for new contributors trying to understand the codebase and how to get some work done. ## Toolchain + To work on this project, you will just need Node 16+ (and Docker to run tests). We use `npm` to manage dependencies, and [prettier](https://github.com/prettier/prettier) to lint our code. ## Scripts + These are the runnable scripts with `npm run`: General: - - `run-dev`: Run the CLI (with `ts-node`). Use `--` to pass arguments to the CLI rather than NPM: \ - `npm run run-dev -- extract print --extractor react src/**/*.tsx` - - `build`: Build the CLI. - - `prettier`: Run Prettier. - - `lint`: Run Prettier but does not update files (report-only). - - `schema`: Generate REST API schemas (see [REST Client](#rest-client)) + +- `run-dev`: Run the CLI (with `ts-node`). Use `--` to pass arguments to the CLI rather than NPM: \ + `npm run run-dev -- extract print --extractor react src/**/*.tsx` +- `build`: Build the CLI. +- `prettier`: Run Prettier. +- `lint`: Run Prettier but does not update files (report-only). +- `schema`: Generate REST API schemas (see [REST Client](#rest-client)) Tests: - - `test`: Run all tests (Unit & E2E). Will start (and stop) the E2E Tolgee test instance - - `test:unit`: Run unit tests only. - - `test:e2e`: Run e2e tests only. Will start (and stop) the E2E Tolgee test instance + +- `test`: Run all tests (Unit & E2E). Will start the E2E Tolgee test instance +- `test:unit`: Run unit tests only. +- `test:e2e`: Run e2e tests only. Will start the E2E Tolgee test instance E2E test instance: - - `tolgee:start`: Start the E2E testing instance. Will be available on port 22222. - - `tolgee:stop`: Stop the E2E testing instance. + +- `tolgee:start`: Start the E2E testing instance. Will be available on port 22222. +- `tolgee:stop`: Stop the E2E testing instance. ## Code & internals overview + ### Command parsing + The CLI uses [commander.js](https://github.com/tj/commander.js) to handle the whole command parsing & routing logic. As the way we deal with arguments is more complex than what the library can do by itself, we have some extra validation logic. ### Config loading & validation + We use [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) to handle the loading of the `.tolgeerc` file. There is also a module that manages the authentication token store (`~/.tolgee/authentication.json`). These modules can be found in `src/config`. @@ -41,31 +50,42 @@ The `.tolgeerc` file is loaded at program startup, and the tokens (which depend custom validation logic. ### REST Client -The REST Client to interact with the Tolgee API is a light abstraction that uses types generated from our OpenAPI -specifications. Feel free to add new methods in the client if you need them. It can be found in `src/config`. + +ApiClient uses `openapi-typescript` to generate typescript schema and `openapi-fetch` for fetching, so it is fully typed client. Endpoints that use `multipart/form-data` are a bit problematic (check `ImportClient.ts`). ### Extractor -The Tolgee Extractor/Code Analyzer is one of the biggest components of the CLI. Tolgee uses TextMate grammars to -parse source code files, and then uses states machines powered by [XState](https://github.com/statelyai/xstate) to -perform the actual extraction. + +The Tolgee Extractor/Code Analyzer is one of the biggest components of the CLI, it has following layers: + +1. TextMate grammars to parse source code files and generate tokens +2. Mappers (generalMapper, jsxMapper, vueMapper), which rename tokens to general tolgee tokens (which are typed) + 1. Because tokens are abstracted to general ones, we can reuse many pieces of logic across different file types +3. Mergers allow merging multiple tokens into one, this has two usecases: + 1. Simplifying tokens (e.g. there are three tokens specifying a string, which can be merged into one) + 2. Generating trigger tokens (e.g. `=14" } @@ -11143,6 +11142,7 @@ "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -11644,15 +11644,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, - "node_modules/xstate": { - "version": "4.38.1", - "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.38.1.tgz", - "integrity": "sha512-1gBUcFWBj/rv/pRcP2Bedl5sNRGX2d36CaOx9z7fE9uSiHaOEHIWzLg1B853q2xdUHUA9pEiWKjLZ3can4SJaQ==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/xstate" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12074,7 +12065,8 @@ "@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==" + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true }, "@humanwhocodes/config-array": { "version": "0.11.14", @@ -19393,6 +19385,7 @@ "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, "requires": { "@fastify/busboy": "^2.0.0" } @@ -19690,11 +19683,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, - "xstate": { - "version": "4.38.1", - "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.38.1.tgz", - "integrity": "sha512-1gBUcFWBj/rv/pRcP2Bedl5sNRGX2d36CaOx9z7fE9uSiHaOEHIWzLg1B853q2xdUHUA9pEiWKjLZ3can4SJaQ==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 5a402271..fc8342a4 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:package": "node scripts/validatePackage.js", "tolgee:start": "node scripts/startDocker.js", "tolgee:stop": "docker stop tolgee_cli_e2e", - "lint": "eslint --ext .ts --ext .js --ext .cjs ./src ./scripts vitest.config.ts", + "eslint": "eslint --max-warnings 0 --ext .ts --ext .js --ext .cjs ./src ./scripts vitest.config.ts", "prettier": "prettier --write ./src ./scripts vitest.config.ts", "run-dev": "cross-env NODE_OPTIONS=\"--import=./scripts/registerTsNode.js\" node ./src/cli.ts", "schema": "openapi-typescript http://localhost:22222/v3/api-docs/All%20Internal%20-%20for%20Tolgee%20Web%20application --output src/client/internal/schema.generated.ts", @@ -37,10 +37,8 @@ "json5": "^2.2.3", "jsonschema": "^1.4.1", "openapi-fetch": "^0.9.7", - "undici": "^5.22.1", "vscode-oniguruma": "^1.7.0", "vscode-textmate": "^9.0.0", - "xstate": "^4.38.1", "yauzl": "^2.10.0" }, "devDependencies": { diff --git a/schema.json b/schema.json index e7cb17a7..50077962 100644 --- a/schema.json +++ b/schema.json @@ -5,14 +5,17 @@ "description": "Project ID. Only required when using a Personal Access Token.", "type": ["number", "string"] }, - "extractor": { - "description": "A path to a custom extractor to use instead of the default one.", - "type": "string" - }, "apiUrl": { "description": "The url of Tolgee API.", "type": "string" }, + "format": { + "$ref": "#/$defs/format" + }, + "extractor": { + "description": "A path to a custom extractor to use instead of the default one.", + "type": "string" + }, "patterns": { "description": "File glob patterns to your source code, used for keys extraction.", "type": "array", @@ -20,8 +23,17 @@ "type": "string" } }, - "format": { - "$ref": "#/$defs/format" + "strictNamespace": { + "description": "Require namespace to be reachable, turn off if you don't use namespaces. (Default: true)", + "type": "boolean" + }, + "defaultNamespace": { + "description": "Default namespace used in extraction if not specified otherwise.", + "type": "string" + }, + "parser": { + "description": "Override parser detection.", + "enum": ["react", "vue", "svelte"] }, "push": { "type": "object", diff --git a/scripts/configType.mjs b/scripts/configType.mjs index 6574be49..3b4a48fe 100644 --- a/scripts/configType.mjs +++ b/scripts/configType.mjs @@ -2,6 +2,6 @@ import { writeFileSync } from 'fs'; import { compileFromFile } from 'json-schema-to-typescript'; // compile from file -compileFromFile('schema.json', {}).then((ts) => +compileFromFile('schema.json', { additionalProperties: false }).then((ts) => writeFileSync('./src/schema.d.ts', ts) ); diff --git a/scripts/validatePackage.js b/scripts/validatePackage.js index e1743d2e..d64fd6f2 100644 --- a/scripts/validatePackage.js +++ b/scripts/validatePackage.js @@ -59,29 +59,29 @@ console.log('OK: tolgee help works'); // 2. ensure `tolgee extract` works // this test is to ensure textmate grammars have been imported work console.log('TEST: tolgee extract print works'); -const TEST_EXTRACTOR_FILE = join(PACKAGE_DEST, 'test.js'); +const TEST_EXTRACTOR_FILE = join(PACKAGE_DEST, 'test.tsx'); await writeFile( TEST_EXTRACTOR_FILE, `import '@tolgee/react'\nReact.createElement(T, { keyName: 'owo' })` ); const tolgeeExtract = execOrError( - 'npx --no tolgee extract print --patterns test.js', + 'npx --no tolgee extract print --patterns test.tsx', { cwd: PACKAGE_DEST, } ); -ok(tolgeeExtract.toString().includes('1 key found in test.js:')); +ok(tolgeeExtract.toString().includes('1 key found in test.tsx:')); console.log('OK: tolgee extract print works'); // 3. ensure `tolgee-cli/extractor` types are importable console.log('TEST: tolgee-cli/extractor types are importable'); -const TEST_TYPE_FILE = join(PACKAGE_DEST, 'test.ts'); +const TEST_TYPE_FILE = join(PACKAGE_DEST, 'test.tsx'); await writeFile( TEST_TYPE_FILE, `import type { ExtractionResult } from '@tolgee/cli/extractor'` ); execOrError('npm i typescript', { cwd: PACKAGE_DEST }); -const tsc = execOrError('npx --no tsc -- --noEmit --lib es2022 test.ts', { +const tsc = execOrError('npx --no tsc -- --noEmit --lib es2022 test.tsx', { cwd: PACKAGE_DEST, }); ok(!tsc.length); diff --git a/src/cli.ts b/src/cli.ts index 46467e78..0a462e55 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,10 +12,15 @@ import { API_KEY_OPT, API_URL_OPT, CONFIG_OPT, + DEFAULT_NAMESPACE, EXTRACTOR, FILE_PATTERNS, FORMAT_OPT, + STRICT_NAMESPACE, + PARSER, PROJECT_ID_OPT, + STRICT_NAMESPACE_NEGATION, + VERBOSE, } from './options.js'; import { API_KEY_PAK_PREFIX, @@ -135,52 +140,40 @@ const preHandler = (config: Schema) => } // Apply verbosity - setDebug(prog.opts().verbose); + setDebug(Boolean(prog.opts().verbose)); }; const program = new Command('tolgee') .version(VERSION) .configureOutput({ writeErr: error }) - .description('Command Line Interface to interact with the Tolgee Platform') - .option('-v, --verbose', 'Enable verbose logging.'); - + .description('Command Line Interface to interact with the Tolgee Platform'); // get config path to update defaults const configPath = getSingleOption(CONFIG_OPT, process.argv); async function loadConfig(program: Command) { const tgConfig = await loadTolgeeRc(configPath); - if (tgConfig) { - [program, ...program.commands].forEach((cmd) => - cmd.options.forEach((opt) => { - const key = opt.attributeName(); - const value = (tgConfig as any)[key]; - if (value) { - const parsedValue = opt.parseArg - ? opt.parseArg(value, undefined) - : value; - cmd.setOptionValueWithSource(key, parsedValue, 'config'); - } - }) - ); - } - return tgConfig ?? {}; } async function run() { try { + const config = await loadConfig(program); + program.hook('preAction', preHandler(config)); + // Global options + program.addOption(VERBOSE); program.addOption(CONFIG_OPT); - program.addOption(API_URL_OPT.default(DEFAULT_API_URL)); + program.addOption(API_URL_OPT.default(config.apiUrl ?? DEFAULT_API_URL)); program.addOption(API_KEY_OPT); - program.addOption(PROJECT_ID_OPT.default(-1)); - program.addOption(FORMAT_OPT.default('JSON_TOLGEE')); - program.addOption(EXTRACTOR); - program.addOption(FILE_PATTERNS); - - const config = await loadConfig(program); - program.hook('preAction', preHandler(config)); + program.addOption(PROJECT_ID_OPT.default(config.projectId ?? -1)); + program.addOption(FORMAT_OPT.default(config.format ?? 'JSON_TOLGEE')); + program.addOption(EXTRACTOR.default(config.extractor)); + program.addOption(FILE_PATTERNS.default(config.patterns)); + program.addOption(PARSER.default(config.parser)); + program.addOption(STRICT_NAMESPACE.default(config.strictNamespace ?? true)); + program.addOption(STRICT_NAMESPACE_NEGATION); + program.addOption(DEFAULT_NAMESPACE.default(config.defaultNamespace)); // Register commands program.addCommand(Login); diff --git a/src/client/internal/requester.ts b/src/client/internal/requester.ts deleted file mode 100644 index 6fd65750..00000000 --- a/src/client/internal/requester.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { Dispatcher } from 'undici'; -import type { Blob } from 'buffer'; -import type { components } from './schema.generated.js'; - -import { STATUS_CODES } from 'http'; -import { request } from 'undici'; -import FormData from 'form-data'; - -import { debug } from '../../utils/logger.js'; -import { USER_AGENT } from '../../constants.js'; - -export type RequesterParams = - | { apiUrl: string | URL; apiKey: `tgpat_${string}`; projectId: number } - | { apiUrl: string | URL; apiKey: string; projectId?: number }; - -// I'd love to strictly type the path & stuff... -// But it's a pain to do so with the generated schema unless request code is written in a super specific way. -// Considering this is internal, it's not that big of a deal. ¯\_(ツ)_/¯ -type Primitive = string | boolean | number; -export type RequestData = { - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - path: string; - body?: any; - query?: Record; - headers?: Record; - headersTimeout?: number; - bodyTimeout?: number; -}; - -export type PaginatedView = { - data: T; - page: components['schemas']['PageMetadata']; - - hasNext: () => boolean; - next: () => Promise | null>; - - hasPrevious: () => boolean; - previous: () => Promise | null>; -}; - -// Helper API type -type PagedView = { - _embedded: any; - _links: Record; - page: components['schemas']['PageMetadata']; -}; - -export default class Requester { - constructor(private params: RequesterParams) {} - - get projectUrl() { - return `/v2/projects/${this.params.projectId}`; - } - - /** - * Performs an HTTP request to the API - * - * @param req Request data - * @returns The response - */ - async request(req: RequestData): Promise { - const url = new URL(req.path, this.params.apiUrl); - - if (req.query) { - for (const param in req.query) { - if (param in req.query) { - const val = req.query[param]; - if (val !== undefined) { - if (Array.isArray(val)) { - for (const v of val) { - url.searchParams.append(param, String(v)); - } - } else { - url.searchParams.set(param, String(val)); - } - } - } - } - } - - const headers: Record = { - ...(req.headers || {}), - 'user-agent': USER_AGENT, - 'x-api-key': this.params.apiKey, - }; - - let body: any = undefined; - - if (req.body) { - if (req.body instanceof FormData) { - const header = `multipart/form-data; boundary=${req.body.getBoundary()}`; - headers['content-type'] = header; - body = req.body.getBuffer(); - } else { - headers['content-type'] = 'application/json'; - body = JSON.stringify(req.body); - } - } - - debug(`[HTTP] Requesting: ${req.method} ${url}`); - - const response = await request(url, { - method: req.method, - headers: headers, - body: body, - headersTimeout: req.headersTimeout ?? 300_000, - bodyTimeout: req.bodyTimeout ?? 300_000, - }); - - debug( - `[HTTP] ${req.method} ${url} -> ${response.statusCode} ${ - STATUS_CODES[response.statusCode] - }` - ); - - return response; - } - - /** - * Performs an HTTP request to the API and returns the result as a JSON object - * - * @param req Request data - * @returns The response data - */ - async requestJson(req: RequestData): Promise { - return >this.request(req).then((r) => r.body.json()); - } - - /** - * Performs an HTTP request to the API and returns the result as a Blob - * - * @param req Request data - * @returns The response blob - */ - async requestBlob(req: RequestData): Promise { - return this.request(req).then((r) => r.body.blob()); - } - - /** - * Performs an HTTP request to the API. - */ - async requestVoid(req: RequestData): Promise { - await this.request(req); - } - - /** - * Performs an HTTP request to the API to a resource which is paginated. - * The returned result is a view with helpers to get next (or previous) data. - * - * @param req Request data - */ - async requestPaginatedResource( - req: RequestData - ): Promise> { - const res = await this.requestJson(req); - - const _this = this; - const view: PaginatedView = { - data: res._embedded, - page: res.page, - hasNext() { - return !!res._links.next?.href; - }, - hasPrevious() { - return !!res._links.prev?.href; - }, - async next() { - if (!this.hasNext()) return null; - return _this.requestPaginatedResource({ - ...req, - path: res._links.next.href, - query: undefined, - }); - }, - async previous() { - if (!this.hasPrevious()) return null; - return _this.requestPaginatedResource({ - ...req, - path: res._links.prev.href, - query: undefined, - }); - }, - }; - - return view; - } -} diff --git a/src/commands/extract/check.ts b/src/commands/extract/check.ts index 19e16da9..cf1a15ec 100644 --- a/src/commands/extract/check.ts +++ b/src/commands/extract/check.ts @@ -6,7 +6,7 @@ import { WarningMessages, emitGitHubWarning, } from '../../extractor/warnings.js'; -import { exitWithError, loading } from '../../utils/logger.js'; +import { loading } from '../../utils/logger.js'; import { Schema } from '../../schema.js'; import type { BaseOptions } from '../../options.js'; @@ -15,13 +15,9 @@ type ExtractLintOptions = BaseOptions; const lintHandler = (config: Schema) => async function (this: Command) { const opts: ExtractLintOptions = this.optsWithGlobals(); - const patterns = opts.patterns; - if (!patterns?.length) { - exitWithError('Missing option --patterns or config.patterns option'); - } const extracted = await loading( 'Analyzing code...', - extractKeysOfFiles(patterns, opts.extractor) + extractKeysOfFiles(opts) ); let warningCount = 0; diff --git a/src/commands/extract/print.ts b/src/commands/extract/print.ts index d1fa1082..8c38f72e 100644 --- a/src/commands/extract/print.ts +++ b/src/commands/extract/print.ts @@ -3,7 +3,7 @@ import { Command } from 'commander'; import { extractKeysOfFiles } from '../../extractor/runner.js'; import { WarningMessages } from '../../extractor/warnings.js'; -import { exitWithError, loading } from '../../utils/logger.js'; +import { loading } from '../../utils/logger.js'; import { Schema } from '../../schema.js'; import type { BaseOptions } from '../../options.js'; @@ -13,14 +13,9 @@ const printHandler = (config: Schema) => async function (this: Command) { const opts: ExtractPrintOptions = this.optsWithGlobals(); - const patterns = opts.patterns; - if (!patterns?.length) { - exitWithError('Missing option --patterns or config.patterns option'); - } - const extracted = await loading( 'Analyzing code...', - extractKeysOfFiles(patterns, opts.extractor) + extractKeysOfFiles(opts) ); let warningCount = 0; diff --git a/src/commands/sync/compare.ts b/src/commands/sync/compare.ts index 515b5c16..160bac8a 100644 --- a/src/commands/sync/compare.ts +++ b/src/commands/sync/compare.ts @@ -7,7 +7,7 @@ import { filterExtractionResult, } from '../../extractor/runner.js'; import { dumpWarnings } from '../../extractor/warnings.js'; -import { exitWithError, loading } from '../../utils/logger.js'; +import { loading } from '../../utils/logger.js'; import { Schema } from '../../schema.js'; import { BaseOptions } from '../../options.js'; import { handleLoadableError } from '../../client/TolgeeClient.js'; @@ -18,15 +18,9 @@ const asyncHandler = (config: Schema) => async function (this: Command) { const opts: Options = this.optsWithGlobals(); - const patterns = opts.patterns?.length ? opts.patterns : config.patterns; - - if (!patterns?.length) { - exitWithError('Missing argument '); - } - const rawKeys = await loading( 'Analyzing code...', - extractKeysOfFiles(patterns, opts.extractor) + extractKeysOfFiles(opts) ); dumpWarnings(rawKeys); diff --git a/src/commands/sync/sync.ts b/src/commands/sync/sync.ts index eeb62c64..280fbc79 100644 --- a/src/commands/sync/sync.ts +++ b/src/commands/sync/sync.ts @@ -68,15 +68,9 @@ const syncHandler = (config: Schema) => async function (this: Command) { const opts: Options = this.optsWithGlobals(); - const patterns = opts.patterns?.length ? opts.patterns : config.patterns; - - if (!patterns?.length) { - exitWithError('Missing argument '); - } - const rawKeys = await loading( 'Analyzing code...', - extractKeysOfFiles(patterns, opts.extractor) + extractKeysOfFiles(opts) ); const warnCount = dumpWarnings(rawKeys); if (!opts.continueOnWarning && warnCount) { diff --git a/src/commands/tag.ts b/src/commands/tag.ts index 54bc010f..d8d2679d 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -31,14 +31,9 @@ const tagHandler = (config: Schema) => ); } - const patterns = opts.patterns; - if (!patterns?.length) { - exitWithError('Missing option --patterns or config.patterns option'); - } - const extracted = await loading( 'Analyzing code...', - extractKeysOfFiles(patterns, opts.extractor) + extractKeysOfFiles(opts) ); const keys = [...extracted.values()].flatMap((item) => item.keys); diff --git a/src/config/tolgeerc.ts b/src/config/tolgeerc.ts index 8d7756e0..629f6a3a 100644 --- a/src/config/tolgeerc.ts +++ b/src/config/tolgeerc.ts @@ -35,6 +35,7 @@ function parseConfig(input: Schema, configDir: string): Schema { } } + // convert relative paths in config to absolute if (rc.extractor !== undefined) { rc.extractor = resolve(configDir, rc.extractor); if (!existsSync(rc.extractor)) { @@ -44,13 +45,14 @@ function parseConfig(input: Schema, configDir: string): Schema { } } - if (rc.delimiter !== undefined) { - rc.delimiter = rc.delimiter || ''; + // convert relative paths in config to absolute + if (rc.patterns !== undefined) { + rc.patterns = rc.patterns.map((pattern: string) => + resolve(configDir, pattern) + ); } // convert relative paths in config to absolute - // so it's always relative to config location - if (rc.push?.files) { rc.push.files = rc.push.files.map((r) => ({ ...r, @@ -62,12 +64,6 @@ function parseConfig(input: Schema, configDir: string): Schema { rc.pull.path = resolve(configDir, rc.pull.path); } - if (rc.patterns !== undefined) { - rc.patterns = rc.patterns.map((pattern: string) => - resolve(configDir, pattern) - ); - } - return rc; } diff --git a/src/extractor/extractor.ts b/src/extractor/extractor.ts index 959af7da..aab6817c 100644 --- a/src/extractor/extractor.ts +++ b/src/extractor/extractor.ts @@ -1,88 +1,73 @@ -import { extname } from 'path'; -import { interpret } from 'xstate'; -import reactExtractorMachine from './machines/react.js'; -import vueExtractorMachine from './machines/vue/extract.js'; -import svelteExtractorMachine from './machines/svelte.js'; -import commentsExtractorMachine from './machines/comments.js'; -import vueSfcProcessor from './processors/vueSfc.js'; import tokenizer from './tokenizer.js'; +import { ParserReact } from './parserReact/ParserReact.js'; +import { Token } from './parser/types.js'; +import { tokensList } from './visualizers/printTokens.js'; +import { visualizeRules } from './visualizers/visualizeRules.js'; +import { ParserVue } from './parserVue/ParserVue.js'; +import { ParserSvelte } from './parserSvelte/ParserSvelte.js'; +import { ExtractOptions, ExtractionResult, ParserType } from './index.js'; +import { IteratorListener } from './parser/iterator.js'; -const REACT_EXTS = [ - '.js', - '.mjs', - '.cjs', - '.ts', - '.mts', - '.cts', - '.jsx', - '.tsx', -]; -const VUE_EXTS = REACT_EXTS; -const ALL_EXTS = [ - '.js', - '.mjs', - '.cjs', - '.ts', - '.mts', - '.cts', - '.jsx', - '.tsx', - '.svelte', -]; - -function pickMachine(code: string, ext: string) { - if (REACT_EXTS.includes(ext) && code.includes('@tolgee/react')) { - return reactExtractorMachine; - } - - if (VUE_EXTS.includes(ext) && code.includes('@tolgee/vue')) { - return vueExtractorMachine; - } - - if (ext === '.svelte' && code.includes('@tolgee/svelte')) { - return svelteExtractorMachine; - } - - if ( - ALL_EXTS.includes(ext) && - (code.includes('@tolgee-key') || code.includes('@tolgee-ignore')) - ) { - return commentsExtractorMachine; +function pickParser(format: ParserType) { + switch (format) { + case 'react': + return ParserReact(); + case 'vue': + return ParserVue(); + case 'svelte': + return ParserSvelte(); } - - return null; } -export default async function extractor(code: string, fileName: string) { - const ext = extname(fileName); +export async function extractTreeAndReport( + code: string, + fileName: string, + parserType: ParserType, + options: ExtractOptions +) { + const debug = options.verbose?.includes('extractor'); + const tokens = (await tokenizer(code, fileName)) as Token[]; - if ( - ext === '.vue' && - (code.includes('$t') || - code.includes('@tolgee/vue') || - code.includes('@tolgee-key') || - code.includes('@tolgee-ignore')) - ) { - return vueSfcProcessor(code, fileName); - } + const parser = pickParser(parserType); - const machineSpec = pickMachine(code, ext); - if (!machineSpec) { - return { warnings: [], keys: [] }; + const tokensMerged: Token[] = []; + const tokensWithRules: Token[] = []; + + let onAccept: IteratorListener | undefined = undefined; + if (debug) { + onAccept = (token, type) => { + tokensMerged.push(token); + tokensWithRules.push({ ...token, customType: type }); + }; } - const tokens = await tokenizer(code, fileName); - // @ts-ignore -- Types are whacky, complains about withConfig but it's not a problem here. - const machine = interpret(machineSpec); + const result = parser.parse({ + tokens, + onAccept, + options, + }); - machine.start(); - for (const token of tokens) { - machine.send(token); + if (debug) { + console.log(JSON.stringify(result.tree, null, 2)); + console.log(tokensList(tokensMerged)); + console.log(visualizeRules(tokensMerged, code)); + console.log(visualizeRules(tokensWithRules, code)); } - const snapshot = machine.getSnapshot(); - return { - warnings: snapshot.context.warnings, - keys: snapshot.context.keys, - }; + return result; +} + +export default async function extractor( + code: string, + fileName: string, + parserType: ParserType, + options: ExtractOptions +): Promise { + const result = await extractTreeAndReport( + code, + fileName, + parserType, + options + ); + return result.report; } diff --git a/src/extractor/index.ts b/src/extractor/index.ts index 3cc3d759..a19e1185 100644 --- a/src/extractor/index.ts +++ b/src/extractor/index.ts @@ -12,10 +12,21 @@ export type ExtractedKey = Key & { export type Warning = { warning: string; line: number }; +export type VerboseOption = 'extractor'; + +export type ExtractOptions = { + verbose?: VerboseOption[]; + strictNamespace: boolean; + defaultNamespace: string | undefined; +}; + +export type ParserType = 'react' | 'vue' | 'svelte'; + export type Extractor = ( fileContents: string, - fileName: string -) => ExtractionResult[]; + fileName: string, + options: ExtractOptions +) => ExtractionResult; export type ExtractionResult = { keys: ExtractedKey[]; warnings: Warning[] }; diff --git a/src/extractor/machines/comments.ts b/src/extractor/machines/comments.ts deleted file mode 100644 index f909a970..00000000 --- a/src/extractor/machines/comments.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { ExtractedKey, Warning } from '../index.js'; - -import { createMachine, assign, send } from 'xstate'; -import commentsService from './shared/comments.js'; - -type MachineCtx = { - keys: ExtractedKey[]; - warnings: Warning[]; -}; - -export default createMachine( - { - predictableActionArguments: true, - id: 'commentsExtractor', - context: { - keys: [], - warnings: [], - }, - invoke: { - id: 'comments', - src: () => commentsService, - }, - on: { - // Service messages - MAGIC_COMMENT: [ - { - actions: 'warnUnusedIgnore', - cond: (_ctx, evt) => evt.kind === 'ignore', - }, - { - actions: 'pushKey', - cond: (_ctx, evt) => evt.kind === 'key', - }, - ], - WARNING: { - actions: 'pushWarning', - }, - - // Code messages - 'comment.line.double-slash.ts': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - 'comment.block.ts': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - 'comment.block.svelte': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - }, - }, - { - actions: { - warnUnusedIgnore: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { warning: 'W_UNUSED_IGNORE', line: evt.line }, - ], - }), - pushKey: assign({ - keys: (ctx, evt) => [ - ...ctx.keys, - { - keyName: evt.keyName, - namespace: evt.namespace, - defaultValue: evt.defaultValue, - line: evt.line, - }, - ], - }), - pushWarning: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { warning: evt.kind, line: evt.line }, - ], - }), - }, - } -); diff --git a/src/extractor/machines/react.ts b/src/extractor/machines/react.ts deleted file mode 100644 index 984b423a..00000000 --- a/src/extractor/machines/react.ts +++ /dev/null @@ -1,766 +0,0 @@ -import type { ExtractedKey, Warning } from '../index.js'; - -import { createMachine, assign, send, forwardTo } from 'xstate'; -import translateCallMachine from './shared/translateCall.js'; -import propertiesMachine from './shared/properties.js'; -import commentsService from './shared/comments.js'; - -type HookVisibility = { depth: number; namespace?: string | false }; - -type KeyWithDynamicNs = Omit & { - namespace?: ExtractedKey['namespace'] | false; -}; - -type MachineCtx = { - blockDepth: number; - children: string; - line: number; - - key: KeyWithDynamicNs; - hooks: HookVisibility[]; - ignore: null | { type: 'key' | 'ignore'; line: number }; - - keys: ExtractedKey[]; - warnings: Warning[]; -}; - -const VOID_KEY = { keyName: '', line: -1 }; - -export default createMachine( - { - predictableActionArguments: true, - id: 'reactExtractor', - type: 'parallel', - context: { - blockDepth: 0, - children: '', - line: 0, - - key: VOID_KEY, - hooks: [], - ignore: null, - - keys: [], - warnings: [], - }, - states: { - comments: { - invoke: { - id: 'comments', - src: () => commentsService, - }, - on: { - // Service messages - MAGIC_COMMENT: [ - { - actions: 'ignoreNextLine', - cond: (_ctx, evt) => evt.kind === 'ignore', - }, - { - actions: 'pushImmediateKey', - cond: (_ctx, evt) => evt.kind === 'key', - }, - ], - WARNING: { - actions: 'pushWarning', - }, - - // Code messages - 'comment.line.double-slash.ts': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - 'comment.block.ts': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - newline: { - actions: 'warnUnusedIgnore', - cond: (ctx, evt) => - ctx.ignore?.type === 'ignore' && ctx.ignore.line === evt.line, - }, - }, - }, - useTranslate: { - initial: 'idle', - states: { - idle: { - on: { - 'entity.name.function.ts': { - target: 'func', - actions: 'storeLine', - cond: (_ctx, evt) => evt.token === 'useTranslate', - }, - }, - }, - func: { - on: { - '*': 'idle', - newline: undefined, - 'meta.block.ts': undefined, - 'meta.var.expr.ts': undefined, - 'meta.brace.round.ts': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && - ctx.ignore.type === 'ignore' && - evt.token === '(', - }, - { - target: 'call', - cond: (_ctx, evt) => evt.token === '(', - }, - ], - }, - }, - call: { - on: { - 'punctuation.definition.string.begin.ts': 'namespace', - 'punctuation.definition.string.template.begin.ts': 'namespace', - 'variable.other.readwrite.ts': { - target: 'idle', - actions: ['pushHook', 'markHookAsDynamic'], - }, - 'meta.brace.round.ts': { - target: 'idle', - cond: (_ctx, evt) => evt.token === ')', - actions: 'pushHook', - }, - }, - }, - namespace: { - on: { - '*': { - target: 'namespace_end', - actions: 'pushNamespacedHook', - }, - }, - }, - namespace_end: { - on: { - 'punctuation.separator.comma.ts': 'idle', - 'meta.brace.round.ts': 'idle', - 'punctuation.definition.template-expression.begin.ts': { - target: 'idle', - actions: 'markHookAsDynamic', - }, - 'keyword.operator.arithmetic.ts': { - target: 'idle', - actions: 'markHookAsDynamic', - }, - }, - }, - }, - on: { - 'punctuation.definition.block.ts': [ - { - cond: (_ctx, evt) => evt.token === '{', - actions: 'incrementDepth', - }, - { - cond: (_ctx, evt) => evt.token === '}', - actions: 'decrementDepth', - }, - ], - }, - }, - - createElement: { - initial: 'idle', - states: { - idle: { - on: { - 'variable.other.object.ts': { - target: 'func1', - actions: 'storeLine', - cond: (_ctx, evt) => evt.token === 'React', - }, - }, - }, - func1: { - on: { - '*': 'idle', - newline: undefined, - 'meta.function-call.ts': undefined, - 'punctuation.accessor.ts': { - target: 'func2', - cond: (_ctx, evt) => evt.token === '.', - }, - }, - }, - func2: { - on: { - '*': 'idle', - newline: undefined, - 'meta.function-call.ts': undefined, - 'support.function.dom.ts': { - target: 'func3', - cond: (_ctx, evt) => evt.token === 'createElement', - }, - }, - }, - func3: { - on: { - '*': 'idle', - newline: undefined, - 'meta.function-call.ts': undefined, - 'meta.brace.round.ts': { - target: 'call', - cond: (_ctx, evt) => evt.token === '(', - }, - }, - }, - call: { - on: { - '*': 'idle', - newline: undefined, - 'variable.other.constant.ts': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && evt.token === 'T', - }, - { - target: 'props', - cond: (_ctx, evt) => evt.token === 'T', - }, - ], - }, - }, - props: { - on: { - 'constant.language.null.ts': 'children', - 'punctuation.definition.block.ts': { - target: 'props_object', - cond: (_ctx, evt) => evt.token === '{', - }, - }, - }, - props_object: { - invoke: { - id: 'propertiesMachine', - src: propertiesMachine, - data: { - depth: 1, - }, - onDone: [ - { - target: 'idle', - actions: 'emitWarningFromParameters', - cond: 'isPropertiesDataDynamic', - }, - { - target: 'children', - actions: 'consumeParameters', - cond: (ctx, evt) => - (!ctx.key.keyName && !evt.data.keyName) || - (!ctx.key.defaultValue && !evt.data.defaultValue), - }, - { - target: 'idle', - actions: ['consumeParameters', 'pushKey'], - }, - ], - }, - on: { - '*': { - actions: forwardTo('propertiesMachine'), - }, - }, - }, - children: { - on: { - '*': [ - { - target: 'idle', - actions: 'pushKey', - cond: (ctx) => !!ctx.key.keyName, - }, - { - target: 'idle', - }, - ], - - 'variable.other.readwrite.ts': { - target: 'idle', - actions: ['dynamicKeyDefault', 'pushKey'], - }, - - // Void & punctuation - newline: undefined, - 'meta.objectliteral.ts': undefined, - 'punctuation.separator.comma.ts': undefined, - - // String - 'punctuation.definition.string.begin.ts': 'children_string', - 'punctuation.definition.string.template.begin.ts': - 'children_string', - }, - }, - children_string: { - on: { - '*': [ - { - target: 'children_end', - actions: 'storeKeyName', - cond: (ctx) => !ctx.key.keyName, - }, - { - target: 'children_end', - actions: 'storeKeyDefault', - cond: (ctx) => !!ctx.key.keyName, - }, - ], - }, - }, - children_end: { - on: { - 'punctuation.separator.comma.ts': { - target: 'idle', - actions: 'pushKey', - }, - 'meta.brace.round.ts': { - target: 'idle', - actions: 'pushKey', - }, - 'punctuation.definition.template-expression.begin.ts': { - target: 'idle', - actions: ['dynamicKeyDefault', 'pushKey'], - }, - 'keyword.operator.arithmetic.ts': { - target: 'idle', - actions: ['dynamicKeyDefault', 'pushKey'], - }, - }, - }, - }, - }, - jsx: { - initial: 'idle', - states: { - idle: { - on: { - 'punctuation.definition.tag.begin.tsx': { - target: 'tag', - actions: 'storeLine', - cond: (_ctx, evt) => evt.token === '<', - }, - }, - }, - tag: { - on: { - '*': 'idle', - newline: undefined, - 'meta.tag.ts': undefined, - 'support.class.component.tsx': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && evt.token === 'T', - }, - { - target: 'props', - cond: (_ctx, evt) => evt.token === 'T', - }, - ], - }, - }, - props: { - invoke: { - id: 'propertiesMachine', - src: propertiesMachine, - onDone: [ - { - target: 'idle', - actions: 'emitWarningFromParameters', - cond: 'isPropertiesDataDynamic', - }, - { - target: 'children', - actions: 'consumeParameters', - cond: (ctx, evt) => - evt.data.lastEvent.token !== '/>' && - ((!ctx.key.keyName && !evt.data.keyName) || - (!ctx.key.defaultValue && !evt.data.defaultValue)), - }, - { - target: 'idle', - actions: ['consumeParameters', 'pushKey'], - }, - ], - }, - on: { - '*': { - actions: forwardTo('propertiesMachine'), - }, - }, - }, - children: { - on: { - 'punctuation.definition.tag.begin.tsx': { - target: 'idle', - actions: ['consumeChildren', 'pushKey'], - }, - - 'meta.jsx.children.tsx': { - actions: 'appendChildren', - }, - 'string.quoted.single.ts': { - actions: 'appendChildren', - }, - 'string.quoted.double.ts': { - actions: 'appendChildren', - }, - 'string.template.ts': { - actions: 'appendChildren', - }, - - 'variable.other.readwrite.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'variable.other.object.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'entity.name.function.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'storage.type.function.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'storage.type.class.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'keyword.operator.new.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'punctuation.definition.block.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'punctuation.definition.template-expression.begin.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - 'keyword.operator.arithmetic.ts': { - target: 'idle', - actions: ['dynamicChildren', 'pushKey'], - }, - }, - }, - }, - }, - t: { - initial: 'idle', - states: { - idle: { - on: { - 'entity.name.function.ts': { - target: 'func', - actions: 'storeLine', - cond: (ctx, evt) => !!ctx.hooks.length && evt.token === 't', - }, - }, - }, - func: { - on: { - '*': 'idle', - newline: undefined, - 'meta.block.ts': undefined, - 'meta.var.expr.ts': undefined, - 'meta.brace.round.ts': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && evt.token === '(', - }, - { - target: 'call', - cond: (_ctx, evt) => evt.token === '(', - }, - ], - }, - }, - call: { - invoke: { - id: 'tCall', - src: translateCallMachine, - onDone: [ - { - target: 'idle', - actions: 'dynamicKeyName', - cond: (_, evt) => evt.data.keyName === false, - }, - { - target: 'idle', - actions: 'dynamicNamespace', - cond: (_, evt) => evt.data.namespace === false, - }, - { - target: 'idle', - actions: 'dynamicOptions', - cond: (_, evt) => evt.data.dynamicOptions, - }, - { - target: 'idle', - cond: (_, evt) => !evt.data.keyName, - }, - { - target: 'idle', - actions: 'consumeTranslateCall', - }, - ], - }, - on: { - '*': { - actions: forwardTo('tCall'), - }, - }, - }, - }, - }, - }, - }, - { - guards: { - isPropertiesDataDynamic: (_ctx, evt) => - evt.data.keyName === false || evt.data.namespace === false, - }, - actions: { - incrementDepth: assign({ - blockDepth: (ctx) => ctx.blockDepth + 1, - }), - decrementDepth: assign({ - blockDepth: (ctx) => ctx.blockDepth - 1, - hooks: (ctx) => ctx.hooks.filter((n) => n.depth !== ctx.blockDepth), - }), - storeLine: assign({ - line: (_ctx, evt) => evt.line, - }), - - ignoreNextLine: assign({ - ignore: (_ctx, evt) => ({ type: 'ignore', line: evt.line + 1 }), - }), - consumeIgnoredLine: assign({ - ignore: (_ctx, _evt) => null, - }), - warnUnusedIgnore: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { warning: 'W_UNUSED_IGNORE', line: evt.line - 1 }, - ], - }), - - pushHook: assign({ - hooks: (ctx) => [...ctx.hooks, { depth: ctx.blockDepth }], - }), - pushNamespacedHook: assign({ - hooks: (ctx, evt) => [ - ...ctx.hooks, - { namespace: evt.token, depth: ctx.blockDepth }, - ], - }), - markHookAsDynamic: assign({ - hooks: (ctx, _evt) => [ - ...ctx.hooks.slice(0, -1), - { namespace: false, depth: ctx.blockDepth }, - ], - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_NAMESPACE', line: ctx.line }, - ], - }), - - consumeTranslateCall: assign({ - warnings: (ctx, evt) => { - const utNamespace = ctx.hooks.length - ? ctx.hooks[ctx.hooks.length - 1].namespace - : undefined; - - if (!evt.data.namespace && utNamespace === false) { - return [ - ...ctx.warnings, - { warning: 'W_UNRESOLVABLE_NAMESPACE', line: ctx.line }, - ]; - } - if (evt.data.defaultValue === false) { - return [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: ctx.line }, - ]; - } - return ctx.warnings; - }, - keys: (ctx, evt) => { - const utNamespace = ctx.hooks.length - ? ctx.hooks[ctx.hooks.length - 1].namespace - : undefined; - - const ns = - evt.data.namespace === null ? utNamespace : evt.data.namespace; - if (!evt.data.keyName || ns === false) return ctx.keys; - return [ - ...ctx.keys, - { - keyName: evt.data.keyName, - namespace: ns || undefined, - defaultValue: evt.data.defaultValue || undefined, - line: ctx.line, - }, - ]; - }, - }), - consumeParameters: assign({ - key: (ctx, evt) => ({ - // We don't want the key and default value to be overridable - // But we DO want the namespace to be overridable - keyName: ctx.key.keyName || evt.data.keyName, - defaultValue: - ctx.key.defaultValue || evt.data.defaultValue || undefined, - namespace: - evt.data.namespace === '' - ? undefined - : evt.data.namespace ?? ctx.key.namespace, - line: ctx.line, - }), - warnings: (ctx, evt) => { - if (evt.data.defaultValue !== false) return ctx.warnings; - return [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: ctx.line }, - ]; - }, - }), - emitWarningFromParameters: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { - warning: - evt.data.keyName === false - ? 'W_DYNAMIC_KEY' - : 'W_DYNAMIC_NAMESPACE', - line: ctx.line, - }, - ], - key: VOID_KEY, - }), - - appendChildren: assign({ - children: (ctx, evt) => (ctx.children ?? '') + evt.token, - }), - consumeChildren: assign({ - key: (ctx, _evt) => ({ - ...ctx.key, - keyName: ctx.key.keyName ? ctx.key.keyName : ctx.children, - defaultValue: !ctx.key.keyName ? ctx.key.defaultValue : ctx.children, - }), - children: (_ctx, _evt) => '', - }), - - storeKeyName: assign({ - key: (ctx, evt) => ({ ...ctx.key, keyName: evt.token }), - }), - storeKeyDefault: assign({ - key: (ctx, evt) => ({ ...ctx.key, defaultValue: evt.token }), - }), - - dynamicKeyName: assign({ - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_KEY', line: ctx.line }, - ], - key: VOID_KEY, - }), - dynamicNamespace: assign({ - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_NAMESPACE', line: ctx.line }, - ], - key: VOID_KEY, - }), - dynamicKeyDefault: assign({ - key: (ctx, _evt) => ({ ...ctx.key, defaultValue: undefined }), - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: ctx.line }, - ], - }), - dynamicOptions: assign({ - key: VOID_KEY, - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_OPTIONS', line: ctx.line }, - ], - }), - dynamicChildren: assign({ - key: (ctx, _evt) => - ctx.key.keyName ? { ...ctx.key, defaultValue: undefined } : VOID_KEY, - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { - warning: ctx.key.keyName - ? 'W_DYNAMIC_DEFAULT_VALUE' - : 'W_DYNAMIC_KEY', - line: ctx.line, - }, - ], - }), - - pushKey: assign({ - keys: (ctx, _evt) => { - if (!ctx.key.keyName || ctx.key.namespace === false) return ctx.keys; - return [ - ...ctx.keys, - { - keyName: ctx.key.keyName.trim(), - namespace: ctx.key.namespace?.trim(), - defaultValue: ctx.key.defaultValue?.trim().replace(/\s+/g, ' '), - line: ctx.line, - }, - ]; - }, - key: (_ctx, _evt) => ({ keyName: '', line: 0 }), - }), - pushImmediateKey: assign({ - ignore: (_ctx, evt) => ({ type: 'key', line: evt.line + 1 }), - keys: (ctx, evt) => [ - ...ctx.keys, - { - keyName: evt.keyName, - namespace: evt.namespace, - defaultValue: evt.defaultValue, - line: evt.line, - }, - ], - }), - pushWarning: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { warning: evt.kind, line: evt.line }, - ], - }), - }, - } -); diff --git a/src/extractor/machines/shared/comments.ts b/src/extractor/machines/shared/comments.ts deleted file mode 100644 index b6e5ce76..00000000 --- a/src/extractor/machines/shared/comments.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { Sender, Receiver } from 'xstate'; -import JSON5 from 'json5'; - -export type MagicIgnoreComment = { - kind: 'ignore'; -}; - -export type MagicKeyComment = { - kind: 'key'; - keyName: string; - namespace?: string; - defaultValue?: string; -}; - -export type CommentEvent = { - type: 'COMMENT'; - data: string; - line: number; -}; - -export type MagicCommentEvent = { type: 'MAGIC_COMMENT'; line: number } & ( - | MagicIgnoreComment - | MagicKeyComment -); - -export type WarningEvent = { type: 'WARNING'; kind: string; line: number }; - -type KeyOverride = { key: string; ns?: string; defaultValue?: string }; - -function isValidKeyOverride(data: any): data is KeyOverride { - if (!('key' in data)) { - return false; - } - - if (typeof data.key !== 'string') { - return false; - } - - if ('ns' in data && typeof data.ns !== 'string') { - return false; - } - - if ('defaultValue' in data && typeof data.defaultValue !== 'string') { - return false; - } - - return true; -} - -// This service is responsible for emitting events when magic comments are encountered -export default function ( - callback: Sender, - onReceive: Receiver -) { - onReceive((evt) => { - const comment = evt.data.trim(); - if (comment.startsWith('@tolgee-ignore')) { - return callback({ - type: 'MAGIC_COMMENT', - kind: 'ignore', - line: evt.line, - }); - } - - if (comment.startsWith('@tolgee-key')) { - const data = comment.slice(11).trim(); - - // Data is escaped; extract all as string - if (data.startsWith('\\')) { - return callback({ - type: 'MAGIC_COMMENT', - kind: 'key', - keyName: data.slice(1), - line: evt.line, - }); - } - - // Data is a json5 struct - if (data.startsWith('{')) { - try { - const key = JSON5.parse(data); - if (!isValidKeyOverride(key)) { - // No key in the struct; invalid override - callback({ - type: 'WARNING', - kind: 'W_INVALID_KEY_OVERRIDE', - line: evt.line, - }); - } else { - callback({ - type: 'MAGIC_COMMENT', - kind: 'key', - keyName: key.key, - namespace: key.ns, - defaultValue: key.defaultValue, - line: evt.line, - }); - } - } catch { - callback({ - type: 'WARNING', - kind: 'W_MALFORMED_KEY_OVERRIDE', - line: evt.line, - }); - } - - return; - } - - callback({ - type: 'MAGIC_COMMENT', - kind: 'key', - keyName: data, - line: evt.line, - }); - } - }); -} diff --git a/src/extractor/machines/shared/properties.ts b/src/extractor/machines/shared/properties.ts deleted file mode 100644 index 033f53ed..00000000 --- a/src/extractor/machines/shared/properties.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { createMachine, send, assign } from 'xstate'; - -type PropertiesContext = { - property: string | null; - depth: number; - jsxDepth: number; - static: boolean; - nextDynamic: boolean; - - keyName: string | false | null; - defaultValue: string | false | null; - namespace: string | false | null; -}; - -// This state machine is responsible for extracting translation key properties from an object/props -export default createMachine( - { - predictableActionArguments: true, - id: 'properties', - initial: 'idle', - context: { - property: null, - depth: 0, - jsxDepth: 0, - static: false, - nextDynamic: false, - - keyName: null, - defaultValue: null, - namespace: null, - }, - states: { - idle: { - on: { - // JS/TS - 'meta.brace.square.ts': { - target: 'complex_key', - cond: 'isOpenSquare', - }, - 'meta.object-literal.key.ts': { - actions: 'storePropertyType', - cond: 'isRootLevel', - }, - 'punctuation.separator.key-value.ts': { - target: 'value', - cond: 'isRootLevel', - }, - 'variable.other.readwrite.ts': { - actions: 'markImmediatePropertyAsDynamic', - cond: (_ctx, evt) => - ['key', 'keyName', 'ns', 'defaultValue'].includes(evt.token), - }, - - // JSX/TSX - 'entity.other.attribute-name.tsx': { - actions: ['markPropertyAsDynamic', 'storePropertyType'], - }, - 'keyword.operator.assignment.ts': { - target: 'value', - actions: 'markAsStatic', - }, - - // Svelte - 'entity.other.attribute-name.svelte': { - actions: ['markPropertyAsDynamic', 'storePropertyType'], - }, - 'punctuation.separator.key-value.svelte': { - target: 'value', - }, - - // Vue - 'punctuation.attribute-shorthand.event.html.vue': [ - { - cond: (_, evt) => evt.token === '@', - actions: ['markPropertyAsDynamic', 'markNextPropertyAsDynamic'], - }, - ], - 'entity.other.attribute-name.html.vue': [ - { - cond: (ctx) => ctx.nextDynamic, - actions: 'markImmediatePropertyAsDynamic', - }, - { - actions: [ - 'markPropertyAsDynamic', - 'unmarkAsStatic', - 'storePropertyType', - ], - }, - ], - 'punctuation.separator.key-value.html.vue': { - target: 'value', - }, - 'entity.other.attribute-name.html': [ - { - actions: ['markPropertyAsDynamic', 'storePropertyType'], - }, - ], - 'punctuation.separator.key-value.html': { - target: 'value', - }, - }, - }, - complex_key: { - on: { - // On string - 'punctuation.definition.string.begin.ts': 'complex_key_string', - 'punctuation.definition.string.template.begin.ts': - 'complex_key_string', - - // Key end - 'meta.brace.square.ts': { - target: 'idle', - cond: 'isCloseSquare', - }, - }, - }, - complex_key_string: { - on: { - '*': { - target: 'idle', - actions: 'storePropertyType', - }, - }, - }, - value: { - on: { - // Extract strings - 'punctuation.definition.string.begin.ts': 'value_string', - 'punctuation.definition.string.template.begin.ts': 'value_string', - - 'punctuation.definition.string.begin.svelte': 'value_string', - 'punctuation.definition.string.template.begin.svelte': 'value_string', - - 'punctuation.definition.string.begin.html': 'value_string', - - // Variable - 'variable.other.readwrite.ts': { - target: 'idle', - actions: [ - 'unmarkAsStatic', - 'markPropertyAsDynamic', - 'clearPropertyType', - ], - }, - - // JSX Expression - 'punctuation.section.embedded.begin.tsx': { - actions: 'unmarkAsStatic', - }, - - // Svelte - 'string.unquoted.svelte': { - target: 'idle', - actions: [ - 'storePropertyValue', - 'clearPropertyType', - 'unmarkAsStatic', - ], - }, - - // Vue - 'string.unquoted.html': { - target: 'idle', - actions: [ - 'storePropertyValue', - 'clearPropertyType', - 'unmarkAsStatic', - ], - }, - - // Value end - 'punctuation.separator.comma.ts': { - target: 'idle', - actions: [ - 'unmarkAsStatic', - 'markPropertyAsDynamic', - 'clearPropertyType', - ], - }, - 'punctuation.definition.block.ts': { - target: 'idle', - // Replay the event to let depth update itself if necessary - actions: [ - 'unmarkAsStatic', - 'markPropertyAsDynamic', - 'clearPropertyType', - send((_ctx, evt) => evt), - ], - }, - }, - }, - value_string: { - on: { - 'punctuation.definition.string.end.ts': { - target: 'idle', - actions: ['storeEmptyPropertyValue', 'clearPropertyType'], - }, - 'punctuation.definition.string.template.end.ts': { - target: 'idle', - actions: ['storeEmptyPropertyValue', 'clearPropertyType'], - }, - - 'punctuation.definition.string.end.svelte': { - target: 'idle', - actions: ['storeEmptyPropertyValue', 'clearPropertyType'], - }, - 'punctuation.definition.string.template.end.svelte': { - target: 'idle', - actions: ['storeEmptyPropertyValue', 'clearPropertyType'], - }, - - 'punctuation.definition.string.end.html': { - target: 'idle', - actions: ['storeEmptyPropertyValue', 'clearPropertyType'], - }, - - '*': [ - { - target: 'idle', - actions: [ - 'storePropertyValue', - 'clearPropertyType', - 'unmarkAsStatic', - ], - cond: (ctx) => ctx.static, - }, - { - target: 'string_end', - actions: 'storePropertyValue', - }, - ], - }, - }, - - string_end: { - on: { - 'punctuation.separator.comma.ts': { - target: 'idle', - actions: 'clearPropertyType', - }, - 'punctuation.definition.template-expression.begin.ts': { - target: 'idle', - actions: ['markPropertyAsDynamic', 'clearPropertyType'], - }, - 'keyword.operator.arithmetic.ts': { - target: 'idle', - actions: ['markPropertyAsDynamic', 'clearPropertyType'], - }, - - // JSX - 'punctuation.section.embedded.end.tsx': { - target: 'idle', - actions: 'clearPropertyType', - }, - - // Svelte - 'punctuation.section.embedded.begin.svelte': { - target: 'idle', - actions: ['markPropertyAsDynamic', 'clearPropertyType'], - }, - 'punctuation.definition.string.end.svelte': { - target: 'idle', - actions: 'clearPropertyType', - }, - 'punctuation.section.embedded.end.svelte': { - target: 'idle', - actions: 'clearPropertyType', - }, - - // Vue - 'punctuation.definition.string.end.html.vue': { - target: 'idle', - actions: 'clearPropertyType', - }, - 'punctuation.definition.string.end.html': { - target: 'idle', - actions: 'clearPropertyType', - }, - }, - }, - end: { - type: 'final', - data: (ctx, evt) => ({ - keyName: ctx.keyName, - defaultValue: ctx.defaultValue, - namespace: ctx.namespace, - lastEvent: evt, - }), - }, - }, - on: { - 'punctuation.definition.block.ts': [ - { - actions: 'incrementDepth', - cond: 'isOpenCurly', - }, - { - target: 'end', - cond: 'isFinalCloseCurly', - }, - { - actions: 'decrementDepth', - cond: 'isCloseCurly', - }, - ], - 'punctuation.definition.tag.begin.tsx': { - actions: 'incrementJsxDepth', - cond: (_ctx, evt) => evt.token === '<', - }, - 'punctuation.definition.tag.end.tsx': [ - { - target: 'end', - actions: 'markPropertyAsDynamic', - cond: (ctx) => ctx.jsxDepth === 0, - }, - { - actions: 'decrementDoubleJsxDepth', - cond: (_ctx, e) => e.token === '/>', - }, - { - actions: 'decrementJsxDepth', - }, - ], - 'punctuation.definition.tag.end.svelte': { - target: 'end', - actions: 'markPropertyAsDynamic', - }, - 'punctuation.definition.tag.end.html.vue': { - target: 'end', - actions: 'markPropertyAsDynamic', - }, - 'punctuation.definition.tag.end.html': { - target: 'end', - actions: 'markPropertyAsDynamic', - }, - }, - }, - { - guards: { - isOpenCurly: (_ctx, evt) => - evt.token === '{' && - !evt.scopes.includes('meta.embedded.expression.tsx') && - !evt.scopes.includes('meta.embedded.expression.svelte') && - (!evt.scopes.includes('source.ts.embedded.html.vue') || - evt.scopes.includes('expression.embedded.vue')), - isCloseCurly: (_ctx, evt) => - evt.token === '}' && - !evt.scopes.includes('meta.embedded.expression.tsx') && - !evt.scopes.includes('meta.embedded.expression.svelte') && - (!evt.scopes.includes('source.ts.embedded.html.vue') || - evt.scopes.includes('expression.embedded.vue')), - isFinalCloseCurly: (ctx, evt) => evt.token === '}' && ctx.depth === 1, - isOpenSquare: (_ctx, evt) => evt.token === '[', - isCloseSquare: (_ctx, evt) => evt.token === ']', - isRootLevel: (ctx) => ctx.depth === 1, - }, - actions: { - storePropertyType: assign({ - property: (_ctx, evt) => evt.token, - }), - clearPropertyType: assign({ - property: (_ctx, _evt) => null, - }), - - storePropertyValue: assign({ - keyName: (ctx, evt) => - ctx.property === 'key' || ctx.property === 'keyName' - ? evt.token - : ctx.keyName, - defaultValue: (ctx, evt) => - ctx.property === 'defaultValue' ? evt.token : ctx.defaultValue, - namespace: (ctx, evt) => - ctx.property === 'ns' ? evt.token : ctx.namespace, - }), - storeEmptyPropertyValue: assign({ - keyName: (ctx) => - ctx.property === 'key' || ctx.property === 'keyName' - ? '' - : ctx.keyName, - defaultValue: (ctx) => - ctx.property === 'defaultValue' ? '' : ctx.defaultValue, - namespace: (ctx) => (ctx.property === 'ns' ? '' : ctx.namespace), - }), - - markNextPropertyAsDynamic: assign({ - nextDynamic: true, - }), - markPropertyAsDynamic: assign({ - nextDynamic: false, - keyName: (ctx, _evt) => - ctx.property === 'key' || ctx.property === 'keyName' - ? false - : ctx.keyName, - defaultValue: (ctx, _evt) => - ctx.property === 'defaultValue' ? false : ctx.defaultValue, - namespace: (ctx, _evt) => - ctx.property === 'ns' ? false : ctx.namespace, - }), - markImmediatePropertyAsDynamic: assign({ - nextDynamic: false, - keyName: (ctx, evt) => - evt.token === 'key' || evt.token === 'keyName' ? false : ctx.keyName, - defaultValue: (ctx, evt) => - evt.token === 'defaultValue' ? false : ctx.defaultValue, - namespace: (ctx, evt) => (evt.token === 'ns' ? false : ctx.namespace), - }), - - incrementDepth: assign({ - depth: (ctx, _evt) => ctx.depth + 1, - }), - decrementDepth: assign({ - depth: (ctx, _evt) => ctx.depth - 1, - }), - - incrementJsxDepth: assign({ - jsxDepth: (ctx, _evt) => ctx.jsxDepth + 2, - }), - decrementDoubleJsxDepth: assign({ - jsxDepth: (ctx, _evt) => ctx.jsxDepth - 2, - }), - decrementJsxDepth: assign({ - jsxDepth: (ctx, _evt) => ctx.jsxDepth - 1, - }), - - markAsStatic: assign({ - static: true, - }), - unmarkAsStatic: assign({ - static: false, - }), - }, - } -); diff --git a/src/extractor/machines/shared/translateCall.ts b/src/extractor/machines/shared/translateCall.ts deleted file mode 100644 index fb98362d..00000000 --- a/src/extractor/machines/shared/translateCall.ts +++ /dev/null @@ -1,161 +0,0 @@ -// This machine is responsible for extracting data from -// calls to `t`. It is shared across multiple machines since -// all t functions share the same signature. - -import { createMachine, assign, forwardTo } from 'xstate'; -import propertiesMachine from './properties.js'; - -type TranslateCallContext = { - keyName: string | false | null; - defaultValue: string | false | null; - namespace: string | false | null; - dynamicOptions: boolean; -}; - -export default createMachine( - { - predictableActionArguments: true, - id: 'tCall', - initial: 'idle', - context: { - keyName: null, - defaultValue: null, - namespace: null, - dynamicOptions: false, - }, - states: { - idle: { - on: { - 'punctuation.definition.string.begin.ts': 'string', - 'punctuation.definition.string.template.begin.ts': 'string', - 'variable.other.readwrite.ts': [ - { - target: 'done', - actions: 'dynamicOptions', - cond: (ctx) => !!ctx.keyName, - }, - { - target: 'done', - actions: 'dynamicKeyName', - }, - ], - 'punctuation.definition.block.ts': { - target: 'object', - cond: (_ctx, evt) => evt.token === '{', - }, - 'meta.brace.round.ts': { - target: 'done', - cond: (_ctx, evt) => evt.token === ')', - }, - }, - }, - - string: { - on: { - '*': [ - { - target: 'string_end', - actions: 'storeKeyName', - cond: (ctx) => !ctx.keyName, - }, - { - target: 'string_end', - actions: 'storeDefaultValue', - cond: (ctx) => !!ctx.keyName, - }, - ], - }, - }, - string_end: { - on: { - 'punctuation.separator.comma.ts': 'idle', - 'punctuation.definition.template-expression.begin.ts': [ - { - target: 'string_end_warn', - actions: 'dynamicDefaultValue', - cond: (ctx) => !!ctx.defaultValue, - }, - { - target: 'done', - actions: 'dynamicKeyName', - }, - ], - 'keyword.operator.arithmetic.ts': [ - { - target: 'string_end_warn', - actions: 'dynamicDefaultValue', - cond: (ctx) => !!ctx.defaultValue, - }, - { - target: 'done', - actions: 'dynamicKeyName', - }, - ], - 'meta.brace.round.ts': { - target: 'done', - cond: (_ctx, evt) => evt.token === ')', - }, - }, - }, - string_end_warn: { - on: { - 'punctuation.separator.comma.ts': 'idle', - 'meta.brace.round.ts': { - target: 'done', - cond: (_ctx, evt) => evt.token === ')', - }, - }, - }, - - object: { - invoke: { - id: 'propertiesMachine', - src: propertiesMachine, - data: { - depth: 1, - }, - onDone: { - target: 'done', - actions: 'consumeParameters', - }, - }, - on: { - '*': { - actions: forwardTo('propertiesMachine'), - }, - }, - }, - - done: { type: 'final', data: (ctx) => ctx }, - }, - }, - { - actions: { - storeKeyName: assign({ - keyName: (_, evt) => evt.token, - }), - storeDefaultValue: assign({ - defaultValue: (_, evt) => evt.token, - }), - - consumeParameters: assign({ - keyName: (ctx, evt) => - ctx.keyName === null ? evt.data.keyName : ctx.keyName, - namespace: (ctx, evt) => - ctx.namespace === null ? evt.data.namespace : ctx.namespace, - defaultValue: (ctx, evt) => - ctx.defaultValue === null ? evt.data.defaultValue : ctx.defaultValue, - }), - - dynamicKeyName: assign({ - keyName: () => false, - }), - dynamicDefaultValue: assign({ - defaultValue: () => false, - }), - dynamicOptions: assign({ - dynamicOptions: () => true, - }), - }, - } -); diff --git a/src/extractor/machines/svelte.ts b/src/extractor/machines/svelte.ts deleted file mode 100644 index df70696b..00000000 --- a/src/extractor/machines/svelte.ts +++ /dev/null @@ -1,476 +0,0 @@ -import type { ExtractedKey, Warning } from '../index.js'; - -import { createMachine, assign, send, forwardTo } from 'xstate'; -import translateCallMachine from './shared/translateCall.js'; -import propertiesMachine from './shared/properties.js'; -import commentsService from './shared/comments.js'; - -type KeyWithDynamicNs = Omit & { - namespace?: ExtractedKey['namespace'] | false; -}; - -type MachineCtx = { - children: string; - line: number; - - key: KeyWithDynamicNs; - getTranslate: string | null | false; - ignore: null | { type: 'key' | 'ignore'; line: number }; - - keys: ExtractedKey[]; - warnings: Warning[]; -}; - -const VOID_KEY = { keyName: '', line: -1 }; - -export default createMachine( - { - predictableActionArguments: true, - id: 'svelteExtractor', - type: 'parallel', - context: { - children: '', - line: 0, - - key: VOID_KEY, - getTranslate: null, - ignore: null, - - keys: [], - warnings: [], - }, - states: { - comments: { - invoke: { - id: 'comments', - src: () => commentsService, - }, - on: { - // Service messages - MAGIC_COMMENT: [ - { - actions: 'ignoreNextLine', - cond: (_ctx, evt) => evt.kind === 'ignore', - }, - { - actions: 'pushImmediateKey', - cond: (_ctx, evt) => evt.kind === 'key', - }, - ], - WARNING: { - actions: 'pushWarning', - }, - - // Code messages - 'comment.line.double-slash.ts': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - 'comment.block.ts': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - 'comment.block.svelte': { - actions: send( - (_ctx, evt) => ({ - type: 'COMMENT', - data: evt.token, - line: evt.line, - }), - { to: 'comments' } - ), - }, - newline: { - actions: 'warnUnusedIgnore', - cond: (ctx, evt) => - ctx.ignore?.type === 'ignore' && ctx.ignore.line === evt.line, - }, - }, - }, - getTranslate: { - initial: 'idle', - states: { - idle: { - on: { - 'entity.name.function.ts': { - target: 'func', - actions: 'storeLine', - cond: (_ctx, evt) => evt.token === 'getTranslate', - }, - }, - }, - func: { - on: { - '*': 'idle', - newline: undefined, - 'meta.block.ts': undefined, - 'meta.var.expr.ts': undefined, - 'meta.brace.round.ts': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && - ctx.ignore.type === 'ignore' && - evt.token === '(', - }, - { - target: 'call', - cond: (_ctx, evt) => evt.token === '(', - }, - ], - }, - }, - call: { - on: { - 'punctuation.definition.string.begin.ts': 'namespace', - 'punctuation.definition.string.template.begin.ts': 'namespace', - 'variable.other.readwrite.ts': { - target: 'idle', - actions: ['storeGetTranslate', 'markGetTranslateAsDynamic'], - }, - 'meta.brace.round.ts': { - target: 'idle', - cond: (_ctx, evt) => evt.token === ')', - actions: 'storeGetTranslate', - }, - }, - }, - namespace: { - on: { - '*': { - target: 'namespace_end', - actions: 'storeNamespacedGetTranslate', - }, - }, - }, - namespace_end: { - on: { - 'punctuation.separator.comma.ts': 'idle', - 'meta.brace.round.ts': 'idle', - 'punctuation.definition.template-expression.begin.ts': { - target: 'idle', - actions: 'markGetTranslateAsDynamic', - }, - 'keyword.operator.arithmetic.ts': { - target: 'idle', - actions: 'markGetTranslateAsDynamic', - }, - }, - }, - }, - }, - component: { - initial: 'idle', - states: { - idle: { - on: { - 'punctuation.definition.tag.begin.svelte': { - target: 'tag', - actions: 'storeLine', - cond: (_ctx, evt) => evt.token === '<', - }, - }, - }, - tag: { - on: { - '*': 'idle', - newline: undefined, - 'meta.tag.start.svelte': undefined, - 'support.class.component.svelte': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && evt.token === 'T', - }, - { - target: 'props', - cond: (_ctx, evt) => evt.token === 'T', - }, - ], - }, - }, - props: { - invoke: { - id: 'propertiesMachine', - src: propertiesMachine, - onDone: [ - { - target: 'idle', - actions: 'emitWarningFromParameters', - cond: 'isPropertiesDataDynamic', - }, - { - target: 'idle', - actions: ['consumeParameters', 'pushKey'], - cond: (ctx, evt) => - evt.data.lastEvent.token !== '/>' && - ((!ctx.key.keyName && !evt.data.keyName) || - (!ctx.key.defaultValue && !evt.data.defaultValue)), - }, - { - target: 'idle', - actions: ['consumeParameters', 'pushKey'], - }, - ], - }, - on: { - '*': { - actions: forwardTo('propertiesMachine'), - }, - }, - }, - }, - }, - t: { - initial: 'idle', - states: { - idle: { - on: { - 'punctuation.definition.variable.svelte': { - target: 'dollar', - cond: (ctx, evt) => - ctx.getTranslate !== null && evt.token === '$', - }, - }, - }, - dollar: { - on: { - '*': 'idle', - 'entity.name.function.ts': { - target: 'func', - actions: 'storeLine', - cond: (_ctx, evt) => evt.token === 't', - }, - }, - }, - func: { - on: { - '*': 'idle', - newline: undefined, - 'source.ts': undefined, - 'meta.brace.round.ts': [ - { - target: 'idle', - actions: 'consumeIgnoredLine', - cond: (ctx, evt) => - ctx.ignore?.line === ctx.line && evt.token === '(', - }, - { - target: 'call', - cond: (_ctx, evt) => evt.token === '(', - }, - ], - }, - }, - call: { - invoke: { - id: 'tCall', - src: translateCallMachine, - onDone: [ - { - target: 'idle', - actions: 'dynamicKeyName', - cond: (_, evt) => evt.data.keyName === false, - }, - { - target: 'idle', - actions: 'dynamicNamespace', - cond: (_, evt) => evt.data.namespace === false, - }, - { - target: 'idle', - actions: 'dynamicOptions', - cond: (_, evt) => evt.data.dynamicOptions, - }, - { - target: 'idle', - cond: (_, evt) => !evt.data.keyName, - }, - { - target: 'idle', - actions: 'consumeTranslateCall', - }, - ], - }, - on: { - '*': { - actions: forwardTo('tCall'), - }, - }, - }, - }, - }, - }, - }, - { - guards: { - isPropertiesDataDynamic: (_ctx, evt) => - evt.data.keyName === false || evt.data.namespace === false, - }, - actions: { - storeLine: assign({ - line: (_ctx, evt) => evt.line, - }), - ignoreNextLine: assign({ - ignore: (_ctx, evt) => ({ type: 'ignore', line: evt.line + 1 }), - }), - consumeIgnoredLine: assign({ - ignore: (_ctx, _evt) => null, - }), - warnUnusedIgnore: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { warning: 'W_UNUSED_IGNORE', line: evt.line - 1 }, - ], - }), - - storeGetTranslate: assign({ - getTranslate: (_ctx, _evt) => '', - }), - storeNamespacedGetTranslate: assign({ - getTranslate: (_ctx, evt) => evt.token, - }), - markGetTranslateAsDynamic: assign({ - getTranslate: (_ctx, _evt) => false, - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_NAMESPACE', line: ctx.line }, - ], - }), - - consumeTranslateCall: assign({ - warnings: (ctx, evt) => { - if (!evt.data.namespace && ctx.getTranslate === false) { - return [ - ...ctx.warnings, - { warning: 'W_UNRESOLVABLE_NAMESPACE', line: ctx.line }, - ]; - } - if (evt.data.defaultValue === false) { - return [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: ctx.line }, - ]; - } - return ctx.warnings; - }, - keys: (ctx, evt) => { - const ns = - evt.data.namespace === null ? ctx.getTranslate : evt.data.namespace; - if (!evt.data.keyName || ns === false) return ctx.keys; - return [ - ...ctx.keys, - { - keyName: evt.data.keyName, - namespace: ns || undefined, - defaultValue: evt.data.defaultValue || undefined, - line: ctx.line, - }, - ]; - }, - }), - consumeParameters: assign({ - key: (ctx, evt) => ({ - keyName: ctx.key.keyName || evt.data.keyName, - defaultValue: - ctx.key.defaultValue || evt.data.defaultValue || undefined, - namespace: evt.data.namespace ?? ctx.key.namespace, - line: ctx.line, - }), - warnings: (ctx, evt) => { - if (evt.data.defaultValue !== false) return ctx.warnings; - return [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: ctx.line }, - ]; - }, - }), - emitWarningFromParameters: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { - warning: - evt.data.keyName === false - ? 'W_DYNAMIC_KEY' - : 'W_DYNAMIC_NAMESPACE', - line: ctx.line, - }, - ], - key: VOID_KEY, - }), - - dynamicKeyName: assign({ - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_KEY', line: ctx.line }, - ], - key: VOID_KEY, - }), - dynamicNamespace: assign({ - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_NAMESPACE', line: ctx.line }, - ], - key: VOID_KEY, - }), - dynamicOptions: assign({ - key: VOID_KEY, - warnings: (ctx, _evt) => [ - ...ctx.warnings, - { warning: 'W_DYNAMIC_OPTIONS', line: ctx.line }, - ], - }), - - pushKey: assign({ - keys: (ctx, _evt) => { - if (!ctx.key.keyName || ctx.key.namespace === false) return ctx.keys; - return [ - ...ctx.keys, - { - keyName: ctx.key.keyName.trim(), - namespace: - ctx.key.namespace === '' - ? undefined - : ctx.key.namespace?.trim(), - defaultValue: ctx.key.defaultValue?.trim().replace(/\s+/g, ' '), - line: ctx.line, - }, - ]; - }, - key: (_ctx, _evt) => ({ keyName: '', line: 0 }), - }), - pushImmediateKey: assign({ - ignore: (_ctx, evt) => ({ type: 'key', line: evt.line + 1 }), - keys: (ctx, evt) => [ - ...ctx.keys, - { - keyName: evt.keyName, - namespace: evt.namespace, - defaultValue: evt.defaultValue, - line: evt.line, - }, - ], - }), - pushWarning: assign({ - warnings: (ctx, evt) => [ - ...ctx.warnings, - { warning: evt.kind, line: evt.line }, - ], - }), - }, - } -); diff --git a/src/extractor/machines/vue/decoder.ts b/src/extractor/machines/vue/decoder.ts deleted file mode 100644 index 0e784219..00000000 --- a/src/extractor/machines/vue/decoder.ts +++ /dev/null @@ -1,217 +0,0 @@ -// This machine is responsible for decoding a Vue SFC file. -// It extracts the tokens in 3 different categories: -// - The setup function tokens -// - The scripts (excluding setup) -// - The template -// -// Property of the machine: will always include an -// extra token for all categories. Consumers must be -// aware of this! - -import type { Token } from '../../tokenizer.js'; -import { createMachine, assign } from 'xstate'; - -type VueDecoderContext = { - setup: Token[]; - script: Token[]; - template: Token[]; - - scriptSetupConsumed: boolean; - invalidSetup: number | null; - - depth: number; - memoizedDepth: number; -}; - -// This state machine is responsible for extracting translation key properties from an object/props -export default createMachine( - { - predictableActionArguments: true, - id: 'properties', - initial: 'idle', - context: { - setup: [], - script: [], - template: [], - scriptSetupConsumed: false, // +// ^^^^^^^-----------^^^^^^^^^ +export const scriptTag = { + trigger: 'trigger.script.tag', + call(context) { + const line = context.getCurrentLine(); + const { props, child } = parseTag(context); + const result: ExpressionNode = { + type: 'expr', + line, + values: [props], + }; + + if (child) { + if (child.type === 'expr') { + result.values.push(...child.values); + } else { + result.values.push(child); + } + } + + result.context = SVELTE_SCRIPT; + return result; + }, +} satisfies RuleType; diff --git a/src/extractor/parserSvelte/rules/tComponent.ts b/src/extractor/parserSvelte/rules/tComponent.ts new file mode 100644 index 00000000..d3bb3ff8 --- /dev/null +++ b/src/extractor/parserSvelte/rules/tComponent.ts @@ -0,0 +1,12 @@ +import { tComponentGeneral } from '../../parser/rules/tComponentGeneral.js'; +import { RuleType } from '../../parser/types.js'; +import type { SvelteTokenType } from '../ParserSvelte.js'; + +// Default +// ^^-----------------------^^^^ +export const tComponent = { + trigger: 'trigger.t.component', + call(context) { + return tComponentGeneral(context); + }, +} satisfies RuleType; diff --git a/src/extractor/parserSvelte/rules/tFunction.ts b/src/extractor/parserSvelte/rules/tFunction.ts new file mode 100644 index 00000000..5fbac448 --- /dev/null +++ b/src/extractor/parserSvelte/rules/tFunction.ts @@ -0,0 +1,10 @@ +import { tFunctionGeneral } from '../../parser/rules/tFunctionGeneral.js'; +import { RuleType } from '../../parser/types.js'; +import type { SvelteTokenType } from '../ParserSvelte.js'; + +export const tFunction = { + trigger: 'trigger.t.function', + call(context) { + return tFunctionGeneral(context, true); + }, +} satisfies RuleType; diff --git a/src/extractor/parserSvelte/rules/useTranslate.ts b/src/extractor/parserSvelte/rules/useTranslate.ts new file mode 100644 index 00000000..331fa8d4 --- /dev/null +++ b/src/extractor/parserSvelte/rules/useTranslate.ts @@ -0,0 +1,10 @@ +import { tNsSourceGeneral } from '../../parser/rules/tNsSourceGeneral.js'; +import { RuleType } from '../../parser/types.js'; +import type { SvelteTokenType } from '../ParserSvelte.js'; + +export const getTranslate = { + trigger: 'trigger.get.translate', + call(context) { + return tNsSourceGeneral(context); + }, +} satisfies RuleType; diff --git a/src/extractor/parserSvelte/svelteMapper.ts b/src/extractor/parserSvelte/svelteMapper.ts new file mode 100644 index 00000000..7ca0a593 --- /dev/null +++ b/src/extractor/parserSvelte/svelteMapper.ts @@ -0,0 +1,45 @@ +import { Token } from '../parser/types.js'; + +export const svelteMapper = (token: Token) => { + switch (token.type) { + // strings + case 'punctuation.definition.string.begin.svelte': + return 'string.begin'; + case 'punctuation.definition.string.end.svelte': + return 'string.end'; + case 'string.quoted.svelte': + return 'string.body'; + case 'string.unquoted.svelte': + return 'string'; + + // svelte template expression + case 'punctuation.section.embedded.begin.svelte': + return 'expression.template.begin'; + case 'punctuation.section.embedded.end.svelte': + return 'expression.template.end'; + + // + case 'punctuation.definition.variable.svelte': + return 'store.accessor.svelte'; + + // svelte template + // tags + case 'punctuation.definition.tag.begin.svelte': + return token.token === '' ? 'tag.self-closing.end' : 'tag.regular.end'; + case 'support.class.component.svelte': + case 'entity.name.tag.svelte': + return 'tag.name'; + case 'entity.other.attribute-name.svelte': + return 'tag.attribute.name'; + case 'punctuation.separator.key-value.svelte': + return 'operator.assignment'; + + // html comments + case 'punctuation.definition.comment.svelte': + return 'comment.definition'; + case 'comment.block.svelte': + return 'comment.block'; + } +}; diff --git a/src/extractor/parserSvelte/svelteTreeTransform.ts b/src/extractor/parserSvelte/svelteTreeTransform.ts new file mode 100644 index 00000000..45c4231e --- /dev/null +++ b/src/extractor/parserSvelte/svelteTreeTransform.ts @@ -0,0 +1,43 @@ +import { GeneralNode } from '../parser/types.js'; +import { SVELTE_SCRIPT } from './contextConstants.js'; + +/** + * Putting scripts to the top + * + * ``` + *
{$t('key1')}
+ * + * ``` + * + * transforming essentially to this: + * + * ``` + * const {t} = getTranslate('namespace') + * + *
{$t('key1')}
+ * ``` + */ +export const svelteTreeTransform = (root: GeneralNode) => { + if (root.type !== 'expr') { + return { tree: root }; + } + + const scripts: GeneralNode[] = []; + const other: GeneralNode[] = []; + + for (const node of root.values) { + if (node.type === 'expr' && node.context === SVELTE_SCRIPT) { + scripts.push(...node.values); + } else { + other.push(node); + } + } + + // put scripts to top + // and other to bottom + root.values = [...scripts, ...other]; + + return { tree: root }; +}; diff --git a/src/extractor/parserSvelte/tokenMergers/getTranslateMerger.ts b/src/extractor/parserSvelte/tokenMergers/getTranslateMerger.ts new file mode 100644 index 00000000..c1ef9db7 --- /dev/null +++ b/src/extractor/parserSvelte/tokenMergers/getTranslateMerger.ts @@ -0,0 +1,29 @@ +import { GeneralTokenType } from '../../parser/generalMapper.js'; +import { MachineType } from '../../parser/mergerMachine.js'; + +export const enum S { + Idle, + ExpectBracket, +} + +// getTranslate( +export const getTranslateMerger = { + initial: S.Idle, + step: (state, t, end) => { + const type = t.customType; + const token = t.token; + + switch (state) { + case S.Idle: + if (type === 'function.call' && token === 'getTranslate') { + return S.ExpectBracket; + } + break; + case S.ExpectBracket: + if (type === 'expression.begin') { + return end.MERGE_ALL; + } + } + }, + customType: 'trigger.get.translate', +} as const satisfies MachineType; diff --git a/src/extractor/parserSvelte/tokenMergers/scriptTagMerger.ts b/src/extractor/parserSvelte/tokenMergers/scriptTagMerger.ts new file mode 100644 index 00000000..d95275f3 --- /dev/null +++ b/src/extractor/parserSvelte/tokenMergers/scriptTagMerger.ts @@ -0,0 +1,29 @@ +import { MachineType } from '../../parser/mergerMachine.js'; +import { SvelteMappedTokenType } from '../ParserSvelte.js'; + +export const enum S { + Idle, + ExpectTemplate, +} + +// +// ^^^^^^^-----------^^^^^^^^^ +export const scriptTag = { + trigger: 'trigger.script.tag', + call(context) { + const line = context.getCurrentLine(); + const { props, child } = parseTag(context); + const result: ExpressionNode = { + type: 'expr', + line, + values: [props], + }; + + if (child) { + if (child.type === 'expr') { + result.values.push(...child.values); + } else { + result.values.push(child); + } + } + + if (props.type === 'dict' && Boolean(props.value['setup'])) { + result.context = VUE_SCRIPT_SETUP; + } else { + result.context = VUE_SCRIPT_REGULAR; + } + return result; + }, +} satisfies RuleType; diff --git a/src/extractor/parserVue/rules/tComponent.ts b/src/extractor/parserVue/rules/tComponent.ts new file mode 100644 index 00000000..aea81ad6 --- /dev/null +++ b/src/extractor/parserVue/rules/tComponent.ts @@ -0,0 +1,12 @@ +import { tComponentGeneral } from '../../parser/rules/tComponentGeneral.js'; +import { RuleType } from '../../parser/types.js'; +import type { VueTokenType } from '../ParserVue.js'; + +// Default +// ^^-----------------------^^^^ +export const tComponent = { + trigger: 'trigger.t.component', + call(context) { + return tComponentGeneral(context); + }, +} satisfies RuleType; diff --git a/src/extractor/parserVue/rules/tFunction.ts b/src/extractor/parserVue/rules/tFunction.ts new file mode 100644 index 00000000..cbdd02b3 --- /dev/null +++ b/src/extractor/parserVue/rules/tFunction.ts @@ -0,0 +1,10 @@ +import { tFunctionGeneral } from '../../parser/rules/tFunctionGeneral.js'; +import { RuleType } from '../../parser/types.js'; +import type { VueTokenType } from '../ParserVue.js'; + +export const tFunction = { + trigger: 'trigger.t.function', + call(context) { + return tFunctionGeneral(context, true); + }, +} satisfies RuleType; diff --git a/src/extractor/parserVue/rules/useTranslate.ts b/src/extractor/parserVue/rules/useTranslate.ts new file mode 100644 index 00000000..4f37bc34 --- /dev/null +++ b/src/extractor/parserVue/rules/useTranslate.ts @@ -0,0 +1,10 @@ +import { tNsSourceGeneral } from '../../parser/rules/tNsSourceGeneral.js'; +import { RuleType } from '../../parser/types.js'; +import type { VueTokenType } from '../ParserVue.js'; + +export const useTranslate = { + trigger: 'trigger.use.translate', + call(context) { + return tNsSourceGeneral(context); + }, +} satisfies RuleType; diff --git a/src/extractor/parserVue/tokenMergers/exportDefaultObjectMerger.ts b/src/extractor/parserVue/tokenMergers/exportDefaultObjectMerger.ts new file mode 100644 index 00000000..04db93e6 --- /dev/null +++ b/src/extractor/parserVue/tokenMergers/exportDefaultObjectMerger.ts @@ -0,0 +1,35 @@ +import { MachineType } from '../../parser/mergerMachine.js'; +import { VueMappedTokenType } from '../ParserVue.js'; + +export const enum S { + Idle, + ExpectDefault, + ExpectBlockStart, +} + +// export default { +export const exportDefaultObjectMerger = { + initial: S.Idle, + step: (state, t, end) => { + const type = t.customType; + + switch (state) { + case S.Idle: + if (type === 'keyword.export') { + return S.ExpectDefault; + } + break; + case S.ExpectDefault: + if (type === 'keyword.default') { + return S.ExpectBlockStart; + } + break; + case S.ExpectBlockStart: + if (type === 'block.begin') { + return end.MERGE_ALL; + } + break; + } + }, + customType: 'trigger.export.default.object', +} as const satisfies MachineType; diff --git a/src/extractor/parserVue/tokenMergers/globalTFunctionMerger.ts b/src/extractor/parserVue/tokenMergers/globalTFunctionMerger.ts new file mode 100644 index 00000000..d9f9321e --- /dev/null +++ b/src/extractor/parserVue/tokenMergers/globalTFunctionMerger.ts @@ -0,0 +1,46 @@ +import { GeneralTokenType } from '../../parser/generalMapper.js'; +import { MachineType } from '../../parser/mergerMachine.js'; + +export const enum S { + Idle, + ExpectBracket, + ExpectDot, + ExpectCall, + Ignore, +} + +// $t( +export const globalTFunctionMerger = { + initial: S.Idle, + step: (state, t, end) => { + const type = t.customType; + const token = t.token; + + switch (state) { + case S.Idle: + if (type === 'function.call' && token === '$t') { + return S.ExpectBracket; + } else if (type === 'variable' && token === 'this') { + return S.ExpectDot; + } else if (type === 'acessor.dot') { + return S.Ignore; + } + break; + case S.ExpectDot: + if (type === 'acessor.dot') { + return S.ExpectCall; + } + break; + case S.ExpectCall: + if (type === 'function.call' && token === '$t') { + return S.ExpectBracket; + } + break; + case S.ExpectBracket: + if (type === 'expression.begin') { + return end.MERGE_ALL; + } + } + }, + customType: 'trigger.global.t.function', +} as const satisfies MachineType; diff --git a/src/extractor/parserVue/tokenMergers/scriptTagMerger.ts b/src/extractor/parserVue/tokenMergers/scriptTagMerger.ts new file mode 100644 index 00000000..ff45f37c --- /dev/null +++ b/src/extractor/parserVue/tokenMergers/scriptTagMerger.ts @@ -0,0 +1,29 @@ +import { MachineType } from '../../parser/mergerMachine.js'; +import { VueMappedTokenType } from '../ParserVue.js'; + +export const enum S { + Idle, + ExpectTemplate, +} + +// + * ``` + * + * transforming essentially to this: + * + * ``` + * export default { + * ... + * } + * const {t} = useTranslate('namespace') + * `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', line: 3 }, @@ -36,7 +53,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([]); }); @@ -48,45 +65,31 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([{ keyName: 'key1', line: 3 }]); }); - it('extracts use of this.$t in scripts', async () => { + it('extracts both this.$t and $t', async () => { const code = ` `; - const extracted = await extractKeys(code, 'App.vue'); - expect(extracted.warnings).toEqual([]); - expect(extracted.keys).toEqual([{ keyName: 'key1', line: 6 }]); - }); - - it('does not extract use of $t without this in scripts', async () => { - const code = ` - - `; - - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); - expect(extracted.keys).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', line: 6 }, + { keyName: 'key2', line: 7 }, + ]); }); it('extracts calls to $t(string, string)', async () => { @@ -96,7 +99,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', defaultValue: 'default value', line: 3 }, @@ -110,7 +113,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { @@ -125,11 +128,11 @@ describe('$t', () => { it('extracts calls to $t(string, opts)', async () => { const code = ` `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { @@ -148,7 +151,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { @@ -171,7 +174,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', line: 3 }, @@ -187,7 +190,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', line: 3 }, @@ -205,7 +208,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_KEY', line: 3 }, { warning: 'W_DYNAMIC_KEY', line: 4 }, @@ -224,7 +227,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_NAMESPACE', line: 3 }, { warning: 'W_DYNAMIC_NAMESPACE', line: 4 }, @@ -237,12 +240,12 @@ describe('$t', () => { it('emits a warning on dynamic default value but keeps the key', async () => { const code = ` `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: 3 }, { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: 4 }, @@ -260,7 +263,7 @@ describe('$t', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_OPTIONS', @@ -283,11 +286,26 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([{ keyName: 'key1', line: 6 }]); }); + it('extracts calls to t in when script is under the template', async () => { + const code = ` + + + `; + + const extracted = await extractVueKeys(code, 'App.vue'); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([{ keyName: 'key1', line: 3 }]); + }); + it('extracts calls to t in v-bind attributes', async () => { const code = ` `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); - expect(extracted.keys).toEqual([{ keyName: 'key1', line: 4 }]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', line: 4 }, + { keyName: 'key2', line: 5 }, + ]); }); - it('extracts calls to t in the setup script after useTranslate was used (script)', async () => { + it('warning on t in script methods', async () => { const code = ` `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); + expect(extracted.warnings).toEqual([ + { line: 10, warning: 'W_MISSING_T_SOURCE' }, + ]); + expect(extracted.keys).toEqual([]); + }); + + it('warning on calls to t if there was no useTranslate', async () => { + const code = ` + + `; + + const extracted = await extractVueKeys(code, 'App.vue'); + expect(extracted.warnings).toEqual([ + { + line: 3, + warning: 'W_MISSING_T_SOURCE', + }, + ]); + expect(extracted.keys).toEqual([]); + }); + + it('keeps track of the namespace specified in useTranslate', async () => { + const code = ` + + + `; + + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); - expect(extracted.keys).toEqual([{ keyName: 'key1', line: 6 }]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'ns1', line: 6 }, + ]); }); - it('does not extract calls to this.t or t in script', async () => { + it('namespace specified in useTranslate in setup function', async () => { const code = ` + `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); - expect(extracted.keys).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'key1', namespace: 'ns1', line: 11 }, + ]); }); - it('does not extract calls to t if there was no useTranslate', async () => { + it('namespace in setup function, template above script', async () => { const code = ` - `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', namespace: 'ns1', line: 6 }, @@ -430,7 +526,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', namespace: 'ns2', line: 6 }, @@ -447,7 +543,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { keyName: 'key1', defaultValue: 'default value', line: 6 }, @@ -464,7 +560,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { @@ -482,11 +578,11 @@ describe('useTranslate', () => { const { t } = useTranslate() `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { @@ -504,11 +600,11 @@ describe('useTranslate', () => { const { t } = useTranslate() `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([ { @@ -533,7 +629,7 @@ describe('useTranslate', () => { } `; - const extracted = await extractKeys(code, 'useSomething.ts'); + const extracted = await extractVueKeys(code, 'useSomething.ts'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([{ keyName: 'key1', line: 8 }]); }); @@ -551,7 +647,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_KEY', line: 6 }, { warning: 'W_DYNAMIC_KEY', line: 7 }, @@ -573,7 +669,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_NAMESPACE', line: 6 }, { warning: 'W_DYNAMIC_NAMESPACE', line: 7 }, @@ -609,9 +705,9 @@ describe('useTranslate', () => { `; - const extracted1 = await extractKeys(code1, 'App.vue'); - const extracted2 = await extractKeys(code2, 'App.vue'); - const extracted3 = await extractKeys(code3, 'App.vue'); + const extracted1 = await extractVueKeys(code1, 'App.vue'); + const extracted2 = await extractVueKeys(code2, 'App.vue'); + const extracted3 = await extractVueKeys(code3, 'App.vue'); const expected = [ { warning: 'W_DYNAMIC_NAMESPACE', line: 3 }, @@ -631,12 +727,12 @@ describe('useTranslate', () => { const { t } = useTranslate() `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: 6 }, { warning: 'W_DYNAMIC_DEFAULT_VALUE', line: 7 }, @@ -657,7 +753,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_OPTIONS', @@ -678,7 +774,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_DYNAMIC_NAMESPACE', line: 3 }, { warning: 'W_UNRESOLVABLE_NAMESPACE', line: 6 }, @@ -697,7 +793,7 @@ describe('useTranslate', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([ { warning: 'W_VUE_SETUP_IS_A_REFERENCE', line: 4 }, ]); @@ -716,7 +812,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -730,7 +826,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -746,7 +842,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -760,7 +856,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -776,7 +872,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -790,7 +886,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -815,7 +911,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual(expected); }); @@ -840,7 +936,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expected); expect(extracted.keys).toEqual([]); }); @@ -864,7 +960,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expected); expect(extracted.keys).toEqual([]); }); @@ -893,7 +989,7 @@ describe('', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expectedWarnings); expect(extracted.keys).toEqual(expectedKeys); }); @@ -913,8 +1009,13 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); - expect(extracted.warnings).toEqual([]); + const extracted = await extractVueKeys(code, 'App.vue'); + expect(extracted.warnings).toEqual([ + { + line: 7, + warning: 'W_MISSING_T_SOURCE', + }, + ]); expect(extracted.keys).toEqual([]); }); @@ -929,7 +1030,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([]); }); @@ -942,7 +1043,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([]); }); @@ -964,7 +1065,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expected); expect(extracted.keys).toEqual([]); }); @@ -984,7 +1085,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([]); }); @@ -1000,23 +1101,7 @@ describe('magic comments', () => { {{ t('key2', \`dynamic-\${i}\`) }} `; - const extracted = await extractKeys(code, 'App.vue'); - expect(extracted.warnings).toEqual([]); - expect(extracted.keys).toEqual([]); - }); - - it("suppresses warnings related to useTranslate's subsequent resolve failures", async () => { - const code = ` - - - `; - - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([]); }); @@ -1034,7 +1119,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expected); expect(extracted.keys).toEqual([]); }); @@ -1051,7 +1136,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); expect(extracted.keys).toEqual([]); }); @@ -1080,7 +1165,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.keys).toEqual(expected); }); @@ -1102,7 +1187,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.keys).toEqual(expected); expect(extracted.warnings).toEqual([]); }); @@ -1117,7 +1202,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.keys).toEqual(expected); expect(extracted.warnings).toEqual([]); }); @@ -1134,7 +1219,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); }); @@ -1153,7 +1238,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); }); @@ -1170,7 +1255,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expected); }); @@ -1186,7 +1271,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual([]); }); @@ -1203,7 +1288,7 @@ describe('magic comments', () => { `; - const extracted = await extractKeys(code, 'App.vue'); + const extracted = await extractVueKeys(code, 'App.vue'); expect(extracted.warnings).toEqual(expected); expect(extracted.keys).toEqual([]); });