├── .gitattributes ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── jest.e2e.json ├── jest.json ├── package.json ├── src ├── builder.ts ├── extractor.ts ├── html │ ├── extractors │ │ ├── common.ts │ │ ├── factories │ │ │ ├── element.ts │ │ │ ├── elementAttribute.ts │ │ │ ├── elementContent.ts │ │ │ ├── embeddedAttributeJs.ts │ │ │ └── embeddedJs.ts │ │ └── index.ts │ ├── parser.ts │ ├── selector.ts │ └── utils.ts ├── index.ts ├── js │ ├── extractors │ │ ├── comments.ts │ │ ├── common.ts │ │ ├── factories │ │ │ ├── callExpression.ts │ │ │ └── htmlTemplate.ts │ │ └── index.ts │ ├── parser.ts │ └── utils.ts ├── parser.ts └── utils │ ├── content.ts │ ├── output.ts │ └── validate.ts ├── tests ├── builder.test.ts ├── e2e │ ├── builder.test.ts │ ├── extractor.test.ts │ ├── fixtures │ │ ├── html │ │ │ ├── embeddedJs.expected.pot │ │ │ ├── embeddedJs.html │ │ │ ├── example.expected.pot │ │ │ ├── header.html │ │ │ ├── linenumberStart.expected.pot │ │ │ ├── linenumberStart.html │ │ │ ├── template.expected.pot │ │ │ └── template.html │ │ └── js │ │ │ ├── example.expected.pot │ │ │ ├── hello.jsx │ │ │ ├── multiline.expected.pot │ │ │ ├── multiline.js │ │ │ └── view.jsx │ ├── html.test.ts │ ├── html │ │ ├── elementAttribute.test.ts │ │ ├── elementContent.test.ts │ │ ├── embeddedJs.test.ts │ │ └── parser.test.ts │ ├── js.test.ts │ └── js │ │ ├── callExpression.test.ts │ │ ├── comments.test.ts │ │ └── parser.test.ts ├── extractor.test.ts ├── fixtures │ ├── directory.ts │ │ └── .gitkeep │ ├── empty.ts │ └── unicode.ts ├── html │ ├── extractors │ │ └── factories │ │ │ ├── elementAttribute.test.ts │ │ │ ├── elementContent.test.ts │ │ │ ├── embeddedAttributeJs.test.ts │ │ │ └── embeddedJs.test.ts │ ├── parser.test.ts │ ├── selector.test.ts │ └── utils.test.ts ├── indent.ts ├── js │ ├── extractors │ │ ├── comments.test.ts │ │ └── factories │ │ │ ├── callExpression.test.ts │ │ │ └── htmlTemplate.test.ts │ ├── parser.test.ts │ └── utils.test.ts ├── parser.common.ts ├── parser.test.ts └── utils │ └── content.test.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: Version (format 1.2.3) 8 | required: true 9 | 10 | jobs: 11 | tag: 12 | name: Tag 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Create git tag 16 | uses: actions/github-script@v5 17 | with: 18 | script: | 19 | github.rest.git.createRef({ 20 | owner: context.repo.owner, 21 | repo: context.repo.repo, 22 | ref: "refs/tags/v${{ github.event.inputs.version }}", 23 | sha: context.sha 24 | }) 25 | publish: 26 | name: Publish 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v2 31 | - name: Set up node 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: lts/* 35 | cache: yarn 36 | registry-url: https://registry.npmjs.org 37 | - name: Install dependencies 38 | run: yarn install 39 | - name: Build 40 | run: yarn build 41 | - name: Publish 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | - name: Set up node 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: lts/* 22 | cache: yarn 23 | - name: Install dependencies 24 | run: yarn install 25 | - name: Lint 26 | run: yarn lint 27 | test: 28 | name: Test 29 | runs-on: ubuntu-latest 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | node: 34 | - '10' 35 | - '12' 36 | - '14' 37 | - '16' 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v2 41 | - name: Set up node 42 | uses: actions/setup-node@v2 43 | with: 44 | node-version: ${{ matrix.node }} 45 | cache: yarn 46 | - name: Install dependencies 47 | run: yarn install 48 | - name: Build 49 | run: yarn build 50 | - name: Test 51 | run: yarn test 52 | - name: Test E2E 53 | run: yarn test:e2e 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | dist/ 4 | yarn-error.log 5 | 6 | !.gitkeep 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thank you for considering to contribute to this project. These guidelines will help you get going with development and outline the most important rules to follow when submitting pull requests for this project. 4 | 5 |
6 | 7 | ## Development 8 | 9 | #### Setup 10 | 11 | ##### Prerequisites 12 | 13 | - [Yarn] 14 | - [NodeJS] 15 | 16 | ##### Steps 17 | 18 | 1. Clone the (forked) repository 19 | 1. Run `yarn install` in the project directory 20 | 21 | #### Building 22 | 23 | ```text 24 | yarn build 25 | ``` 26 | 27 | This will run the TypeScript compiler and write the JavaScript output to `dist`. 28 | 29 | #### Linting 30 | 31 | ```text 32 | yarn lint 33 | ``` 34 | 35 | This will run [tslint] to check for code style errors. 36 | 37 | #### Running Tests 38 | 39 | ```text 40 | yarn test 41 | ``` 42 | 43 | This will run automated tests with [jest]. There are also some end-to-end tests which can be run with `yarn test:e2e`. 44 | 45 | > **Note:** The E2E tests expect a built version of the package to be present in `dist/` so it's best to run `yarn build` first. 46 | 47 |
48 | 49 | 50 | ## Submitting Changes 51 | 52 | To get changes merged, create a pull request. Here are a few things to pay attention to when doing so: 53 | 54 | #### Commit Messages 55 | 56 | The summary of a commit should be concise and worded in an imperative mood. 57 | ...a *what* mood? This should clear things up: *[How to Write a Git Commit Message][git-commit-message]* 58 | 59 | #### Code Style 60 | 61 | It is required that you follow the existing code style. Use `yarn lint` to check. 62 | 63 | #### Tests 64 | 65 | If it makes sense, writing tests for your PRs is always appreciated and will help get them merged. 66 | 67 | [Yarn]: https://yarnpkg.com 68 | [NodeJS]: https://nodejs.org 69 | [tslint]: https://palantir.github.io/tslint/ 70 | [jest]: https://facebook.github.io/jest/ 71 | [git-commit-message]: https://chris.beams.io/posts/git-commit/ 72 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2017 Lukas Geiter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Not actively maintained:** I'm looking for someone to take over this project. Email me at info@lukasgeiter.com if you're interested. 2 | 3 | # Gettext Extractor [![Tests Status][status-tests-badge]][status-tests-link] 4 | 5 | *A flexible and powerful Gettext message extractor with support for JavaScript, TypeScript, JSX and HTML* 6 | 7 | It works by running your files through a parser and then uses the AST (Abstract Syntax Tree) to find and extract translatable strings from your source code. All extracted strings can then be saved as `.pot` file to act as template for translation files. 8 | 9 | Unlike many of the alternatives, this library is highly configurable and is designed to work with most existing setups. 10 | 11 | For the full documentation check out the [Github Wiki][wiki]. 12 | 13 |
14 | 15 | ## Installation 16 | 17 | > **Note:** This package requires Node.js version 6 or higher. 18 | 19 | #### Yarn 20 | 21 | ```text 22 | yarn add gettext-extractor 23 | ``` 24 | 25 | #### NPM 26 | 27 | ```text 28 | npm install gettext-extractor 29 | ``` 30 | 31 |
32 | 33 | ## Getting Started 34 | 35 | Let's start with a code example: 36 | 37 | ```javascript 38 | const { GettextExtractor, JsExtractors, HtmlExtractors } = require('gettext-extractor'); 39 | 40 | let extractor = new GettextExtractor(); 41 | 42 | extractor 43 | .createJsParser([ 44 | JsExtractors.callExpression('getText', { 45 | arguments: { 46 | text: 0, 47 | context: 1 48 | } 49 | }), 50 | JsExtractors.callExpression('getPlural', { 51 | arguments: { 52 | text: 1, 53 | textPlural: 2, 54 | context: 3 55 | } 56 | }) 57 | ]) 58 | .parseFilesGlob('./src/**/*.@(ts|js|tsx|jsx)'); 59 | 60 | extractor 61 | .createHtmlParser([ 62 | HtmlExtractors.elementContent('translate, [translate]') 63 | ]) 64 | .parseFilesGlob('./src/**/*.html'); 65 | 66 | extractor.savePotFile('./messages.pot'); 67 | 68 | extractor.printStats(); 69 | ``` 70 | 71 | A detailed explanation of this code example and much more can be found in the [Github Wiki][wiki-introduction]. 72 | 73 |
74 | 75 | ## Contributing 76 | 77 | From reporting a bug to submitting a pull request: every contribution is appreciated and welcome. 78 | Report bugs, ask questions and request features using [Github issues][github-issues]. 79 | If you want to contribute to the code of this project, please read the [Contribution Guidelines][contributing]. 80 | 81 | [status-tests-badge]: https://github.com/lukasgeiter/gettext-extractor/actions/workflows/tests.yml/badge.svg 82 | [status-tests-link]: https://github.com/lukasgeiter/gettext-extractor/actions/workflows/tests.yml 83 | [wiki]: https://github.com/lukasgeiter/gettext-extractor/wiki 84 | [wiki-introduction]: https://github.com/lukasgeiter/gettext-extractor/wiki/Introduction 85 | [github-issues]: https://github.com/lukasgeiter/gettext-extractor/issues 86 | [contributing]: CONTRIBUTING.md 87 | -------------------------------------------------------------------------------- /jest.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | ".ts": "ts-jest" 4 | }, 5 | "testRegex": ".*\\.test\\.ts$", 6 | "roots": [ 7 | "/tests/e2e" 8 | ], 9 | "moduleFileExtensions": [ 10 | "js", 11 | "ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | ".ts": "ts-jest" 4 | }, 5 | "testRegex": ".*\\.test\\.ts$", 6 | "roots": [ 7 | "/tests" 8 | ], 9 | "testPathIgnorePatterns": [ 10 | "/tests/e2e" 11 | ], 12 | "moduleFileExtensions": [ 13 | "js", 14 | "ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gettext-extractor", 3 | "version": "3.8.0", 4 | "description": "Gettext extractor for JavaScript, TypeScript, JSX and HTML", 5 | "author": "Lukas Geiter ", 6 | "repository": "lukasgeiter/gettext-extractor", 7 | "bugs": "https://github.com/lukasgeiter/gettext-extractor/issues", 8 | "license": "MIT", 9 | "keywords": [ 10 | "gettext", 11 | "extractor", 12 | "typescript", 13 | "po-files", 14 | "i18n", 15 | "l10n", 16 | "translation" 17 | ], 18 | "main": "dist/index.js", 19 | "types": "dist/index.d.ts", 20 | "files": [ 21 | "dist/", 22 | "LICENSE.md", 23 | "README.md" 24 | ], 25 | "scripts": { 26 | "test": "jest --config jest.json", 27 | "test:e2e": "jest --config jest.e2e.json", 28 | "lint": "tslint src/**/*.ts{,x}", 29 | "build": "tsc" 30 | }, 31 | "engines": { 32 | "node": ">=6" 33 | }, 34 | "dependencies": { 35 | "@types/glob": "5 - 7", 36 | "@types/parse5": "^5", 37 | "css-selector-parser": "^1.3", 38 | "glob": "5 - 7", 39 | "parse5": "5 - 6", 40 | "pofile": "1.0.x", 41 | "typescript": "4 - 5" 42 | }, 43 | "devDependencies": { 44 | "@types/jest": "^26.0.14", 45 | "@types/node": "^14.11.10", 46 | "jest": "^26.6.0", 47 | "ts-jest": "^26.4.1", 48 | "tslint": "^5.11.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/builder.ts: -------------------------------------------------------------------------------- 1 | import { IGettextExtractorStats } from './extractor'; 2 | 3 | export interface IMessage { 4 | text: string | null; 5 | textPlural?: string | null; 6 | context?: string | null; 7 | references: string[]; 8 | comments: string[]; 9 | } 10 | 11 | export interface IContext { 12 | name: string; 13 | messages: IMessage[]; 14 | } 15 | 16 | export type IMessageMap = {[text: string]: IMessage}; 17 | export type IContextMap = {[context: string]: IMessageMap}; 18 | 19 | export class CatalogBuilder { 20 | 21 | private contexts: IContextMap = {}; 22 | 23 | private static compareStrings(a: string, b: string): number { 24 | return a.localeCompare(b); 25 | } 26 | 27 | private static concatUnique(array?: any[], items?: any[]): any[] { 28 | array = array || []; 29 | for (let item of items || []) { 30 | if (array.indexOf(item) === -1) { 31 | array.push(item); 32 | } 33 | } 34 | return array; 35 | } 36 | 37 | private static extendMessage(message: IMessage, data: Partial): IMessage { 38 | 39 | message.text = typeof data.text === 'string' ? data.text : message.text; 40 | message.textPlural = typeof data.textPlural === 'string' ? data.textPlural : message.textPlural; 41 | message.context = typeof data.context === 'string' ? data.context : message.context; 42 | message.references = CatalogBuilder.concatUnique(message.references, data.references); 43 | message.comments = CatalogBuilder.concatUnique(message.comments, data.comments); 44 | 45 | return message; 46 | } 47 | 48 | private static normalizeMessage(message: Partial): IMessage { 49 | return CatalogBuilder.extendMessage({ 50 | text: null, 51 | textPlural: null, 52 | context: null, 53 | references: [], 54 | comments: [] 55 | }, message); 56 | } 57 | 58 | constructor( 59 | private stats?: IGettextExtractorStats 60 | ) {} 61 | 62 | public addMessage(message: Partial): void { 63 | message = CatalogBuilder.normalizeMessage(message); 64 | let context = this.getOrCreateContext(message.context || ''); 65 | if (context[message.text!]) { 66 | if (message.textPlural && context[message.text!].textPlural && context[message.text!].textPlural !== message.textPlural) { 67 | throw new Error(`Incompatible plurals found for '${message.text}' ('${context[message.text!].textPlural}' and '${message.textPlural}')`); 68 | } 69 | 70 | if (message.textPlural && !context[message.text!].textPlural) { 71 | this.stats && this.stats.numberOfPluralMessages++; 72 | } 73 | 74 | CatalogBuilder.extendMessage(context[message.text!], message); 75 | } else { 76 | context[message.text!] = message as IMessage; 77 | 78 | this.stats && this.stats.numberOfMessages++; 79 | if (message.textPlural) { 80 | this.stats && this.stats.numberOfPluralMessages++; 81 | } 82 | } 83 | 84 | this.stats && this.stats.numberOfMessageUsages++; 85 | } 86 | 87 | public getMessages(): IMessage[] { 88 | let messages: IMessage[] = []; 89 | for (let context of Object.keys(this.contexts).sort(CatalogBuilder.compareStrings)) { 90 | messages = messages.concat(this.getMessagesByContext(context)); 91 | } 92 | return messages; 93 | } 94 | 95 | public getContexts(): IContext[] { 96 | let contexts: IContext[] = []; 97 | for (let context of Object.keys(this.contexts).sort(CatalogBuilder.compareStrings)) { 98 | contexts.push({ 99 | name: context, 100 | messages: this.getMessagesByContext(context) 101 | }); 102 | } 103 | return contexts; 104 | } 105 | 106 | public getMessagesByContext(context: string): IMessage[] { 107 | let messages = this.contexts[context]; 108 | if (!messages) { 109 | return []; 110 | } 111 | return Object.keys(messages).sort(CatalogBuilder.compareStrings).map(text => messages[text]); 112 | } 113 | 114 | private getOrCreateContext(context: string): IMessageMap { 115 | if (!this.contexts[context]) { 116 | this.contexts[context] = {}; 117 | this.stats && this.stats.numberOfContexts++; 118 | } 119 | return this.contexts[context]; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/extractor.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as pofile from 'pofile'; 3 | 4 | import { CatalogBuilder, IContext, IMessage } from './builder'; 5 | import { JsParser, IJsExtractorFunction } from './js/parser'; 6 | import { HtmlParser, IHtmlExtractorFunction } from './html/parser'; 7 | import { StatsOutput } from './utils/output'; 8 | import { Validate } from './utils/validate'; 9 | 10 | export interface IGettextExtractorStats { 11 | numberOfMessages: number; 12 | numberOfPluralMessages: number; 13 | numberOfMessageUsages: number; 14 | numberOfContexts: number; 15 | numberOfParsedFiles: number; 16 | numberOfParsedFilesWithMessages: number; 17 | } 18 | 19 | export class GettextExtractor { 20 | 21 | private stats: IGettextExtractorStats = { 22 | numberOfMessages: 0, 23 | numberOfPluralMessages: 0, 24 | numberOfMessageUsages: 0, 25 | numberOfContexts: 0, 26 | numberOfParsedFiles: 0, 27 | numberOfParsedFilesWithMessages: 0 28 | }; 29 | 30 | private builder: CatalogBuilder; 31 | 32 | constructor() { 33 | this.builder = new CatalogBuilder(this.stats); 34 | } 35 | 36 | public createJsParser(extractors?: IJsExtractorFunction[]): JsParser { 37 | Validate.optional.nonEmptyArray({extractors}); 38 | 39 | return new JsParser(this.builder, extractors, this.stats); 40 | } 41 | 42 | public createHtmlParser(extractors?: IHtmlExtractorFunction[]): HtmlParser { 43 | Validate.optional.nonEmptyArray({extractors}); 44 | 45 | return new HtmlParser(this.builder, extractors, this.stats); 46 | } 47 | 48 | public addMessage(message: IMessage): void { 49 | Validate.required.stringProperty(message, 'message.text'); 50 | Validate.optional.stringProperty(message, 'message.textPlural'); 51 | Validate.optional.stringProperty(message, 'message.context'); 52 | Validate.optional.arrayProperty(message, 'message.references'); 53 | Validate.optional.arrayProperty(message, 'message.comments'); 54 | 55 | this.builder.addMessage(message); 56 | } 57 | 58 | public getMessages(): IMessage[] { 59 | return this.builder.getMessages(); 60 | } 61 | 62 | public getContexts(): IContext[] { 63 | return this.builder.getContexts(); 64 | } 65 | 66 | public getMessagesByContext(context: string): IMessage[] { 67 | return this.builder.getMessagesByContext(context); 68 | } 69 | 70 | public getPotString(headers: Partial = {}): string { 71 | Validate.optional.object({headers}); 72 | 73 | let po = new (pofile)(); 74 | po.items = this.getPofileItems(); 75 | po.headers = { 76 | 'Content-Type': 'text/plain; charset=UTF-8', 77 | ...headers 78 | }; 79 | return po.toString(); 80 | } 81 | 82 | public savePotFile(fileName: string, headers?: Partial): void { 83 | Validate.required.nonEmptyString({fileName}); 84 | Validate.optional.object({headers}); 85 | 86 | fs.writeFileSync(fileName, this.getPotString(headers)); 87 | } 88 | 89 | public savePotFileAsync(fileName: string, headers?: Partial): Promise { 90 | Validate.required.nonEmptyString({fileName}); 91 | Validate.optional.object({headers}); 92 | 93 | return new Promise((resolve, reject) => { 94 | fs.writeFile(fileName, this.getPotString(headers), (error) => { 95 | if (error) { 96 | return reject(error); 97 | } 98 | resolve(); 99 | }); 100 | }); 101 | } 102 | 103 | public getStats(): IGettextExtractorStats { 104 | return this.stats; 105 | } 106 | 107 | public printStats(): void { 108 | new StatsOutput(this.getStats()).print(); 109 | } 110 | 111 | private getPofileItems(): pofile.Item[] { 112 | return this.getMessages().map(message => { 113 | let item = new pofile.Item(); 114 | 115 | item.msgid = message.text as string; 116 | item.msgid_plural = message.textPlural as string; 117 | item.msgctxt = message.context as string; 118 | item.references = message.references.sort((a, b) => a.localeCompare(b)); 119 | item.extractedComments = message.comments; 120 | 121 | return item; 122 | }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/html/extractors/common.ts: -------------------------------------------------------------------------------- 1 | import { Validate } from '../../utils/validate'; 2 | import { IContentExtractorOptions } from '../../utils/content'; 3 | 4 | export interface IAttributeMapping { 5 | textPlural?: string; 6 | context?: string; 7 | comment?: string; 8 | } 9 | 10 | export interface IHtmlExtractorOptions extends IContentExtractorOptions { 11 | attributes?: IAttributeMapping; 12 | } 13 | 14 | export function validateOptions(options: IHtmlExtractorOptions): void { 15 | Validate.optional.stringProperty(options, 'options.attributes.textPlural'); 16 | Validate.optional.stringProperty(options, 'options.attributes.context'); 17 | Validate.optional.stringProperty(options, 'options.attributes.comment'); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/html/extractors/factories/element.ts: -------------------------------------------------------------------------------- 1 | import { IHtmlExtractorFunction } from '../../parser'; 2 | import { IAddMessageCallback } from '../../../parser'; 3 | import { IElementSelector, ElementSelectorSet } from '../../selector'; 4 | import { Element, Node } from '../../parser'; 5 | import { HtmlUtils } from '../../utils'; 6 | import { IHtmlExtractorOptions } from '../common'; 7 | import { getContentOptions, IContentOptions } from '../../../utils/content'; 8 | 9 | export type ITextExtractor = (element: Element) => string | null; 10 | 11 | export function elementExtractor(selector: string | IElementSelector[], textExtractor: ITextExtractor, options: IHtmlExtractorOptions = {}): IHtmlExtractorFunction { 12 | 13 | let selectors = new ElementSelectorSet(selector); 14 | 15 | return (node: Node, fileName: string, addMessage: IAddMessageCallback) => { 16 | if (typeof (node).tagName !== 'string') { 17 | return; 18 | } 19 | 20 | let element = node; 21 | 22 | if (selectors.anyMatch(element)) { 23 | let context: string | undefined, 24 | textPlural: string | undefined, 25 | comments: string[] = [], 26 | contentOptions: IContentOptions = getContentOptions(options, { 27 | trimWhiteSpace: true, 28 | preserveIndentation: false, 29 | replaceNewLines: false 30 | }); 31 | 32 | if (options.attributes && options.attributes.context) { 33 | context = HtmlUtils.getNormalizedAttributeValue(element, options.attributes.context, contentOptions) || undefined; 34 | } 35 | 36 | if (options.attributes && options.attributes.textPlural) { 37 | textPlural = HtmlUtils.getNormalizedAttributeValue(element, options.attributes.textPlural, contentOptions) || undefined; 38 | } 39 | 40 | if (options.attributes && options.attributes.comment) { 41 | let comment = HtmlUtils.getNormalizedAttributeValue(element, options.attributes.comment, contentOptions); 42 | if (comment) { 43 | comments.push(comment); 44 | } 45 | } 46 | 47 | let text = textExtractor(element); 48 | 49 | if (typeof text === 'string') { 50 | addMessage({text, context, textPlural, comments}); 51 | } 52 | } 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/html/extractors/factories/elementAttribute.ts: -------------------------------------------------------------------------------- 1 | import { IHtmlExtractorFunction } from '../../parser'; 2 | import { HtmlUtils } from '../../utils'; 3 | import { elementExtractor } from './element'; 4 | import { Validate } from '../../../utils/validate'; 5 | import { IHtmlExtractorOptions, validateOptions } from '../common'; 6 | import { IContentOptions, getContentOptions, validateContentOptions } from '../../../utils/content'; 7 | 8 | export function elementAttributeExtractor(selector: string, textAttribute: string, options: IHtmlExtractorOptions = {}): IHtmlExtractorFunction { 9 | Validate.required.nonEmptyString({selector, textAttribute}); 10 | validateOptions(options); 11 | validateContentOptions(options); 12 | 13 | let contentOptions: IContentOptions = getContentOptions(options, { 14 | trimWhiteSpace: false, 15 | preserveIndentation: true, 16 | replaceNewLines: false 17 | }); 18 | 19 | return elementExtractor(selector, element => { 20 | return HtmlUtils.getNormalizedAttributeValue(element, textAttribute, contentOptions); 21 | }, options); 22 | } 23 | -------------------------------------------------------------------------------- /src/html/extractors/factories/elementContent.ts: -------------------------------------------------------------------------------- 1 | import { IHtmlExtractorFunction } from '../../parser'; 2 | import { HtmlUtils } from '../../utils'; 3 | import { Validate } from '../../../utils/validate'; 4 | import { getContentOptions, IContentOptions, validateContentOptions } from '../../../utils/content'; 5 | import { IHtmlExtractorOptions, validateOptions } from '../common'; 6 | import { elementExtractor } from './element'; 7 | 8 | export function elementContentExtractor(selector: string, options: IHtmlExtractorOptions = {}): IHtmlExtractorFunction { 9 | Validate.required.nonEmptyString({selector}); 10 | validateOptions(options); 11 | validateContentOptions(options); 12 | 13 | let contentOptions: IContentOptions = getContentOptions(options, { 14 | trimWhiteSpace: true, 15 | preserveIndentation: false, 16 | replaceNewLines: false 17 | }); 18 | 19 | return elementExtractor(selector, element => { 20 | return HtmlUtils.getElementContent(element, contentOptions); 21 | }, options); 22 | } 23 | -------------------------------------------------------------------------------- /src/html/extractors/factories/embeddedAttributeJs.ts: -------------------------------------------------------------------------------- 1 | import { Attribute } from 'parse5'; 2 | import { JsParser } from '../../../js/parser'; 3 | import { Validate } from '../../../utils/validate'; 4 | import { Element, IHtmlExtractorFunction, Node } from '../../parser'; 5 | 6 | export type AttributePredicate = (attribute: Attribute) => boolean; 7 | 8 | export function embeddedAttributeJsExtractor(filter: RegExp | AttributePredicate, jsParser: JsParser): IHtmlExtractorFunction { 9 | Validate.required.argument({ filter }); 10 | Validate.required.argument({ jsParser }); 11 | let test: AttributePredicate; 12 | if (typeof filter === 'function') { 13 | test = filter; 14 | } else { 15 | test = attr => filter.test(attr.name); 16 | } 17 | 18 | return (node: Node, fileName: string, _, lineNumberStart) => { 19 | if (typeof (node as Element).tagName !== 'string') { 20 | return; 21 | } 22 | 23 | const element = node as Element; 24 | 25 | element.attrs.filter(test).forEach((attr) => { 26 | const startLine = element.sourceCodeLocation?.attrs[attr.name]?.startLine; 27 | if (startLine) { 28 | lineNumberStart = lineNumberStart + startLine - 1; 29 | } 30 | jsParser.parseString(attr.value, fileName, { 31 | lineNumberStart 32 | }); 33 | }); 34 | 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/html/extractors/factories/embeddedJs.ts: -------------------------------------------------------------------------------- 1 | import { JsParser } from '../../../js/parser'; 2 | import { IHtmlExtractorFunction, Element, Node } from '../../parser'; 3 | import { ElementSelectorSet } from '../../selector'; 4 | import { HtmlUtils } from '../../utils'; 5 | import { Validate } from '../../../utils/validate'; 6 | 7 | export function embeddedJsExtractor(selector: string, jsParser: JsParser): IHtmlExtractorFunction { 8 | Validate.required.nonEmptyString({ selector }); 9 | Validate.required.argument({ jsParser }); 10 | 11 | let selectors = new ElementSelectorSet(selector); 12 | 13 | return (node: Node, fileName: string, _, lineNumberStart) => { 14 | if (typeof (node).tagName !== 'string') { 15 | return; 16 | } 17 | 18 | let element = node; 19 | 20 | if (selectors.anyMatch(element)) { 21 | let source = HtmlUtils.getElementContent(element, { 22 | trimWhiteSpace: false, 23 | preserveIndentation: true, 24 | replaceNewLines: false 25 | }); 26 | if (element.sourceCodeLocation && element.sourceCodeLocation.startLine) { 27 | lineNumberStart = lineNumberStart + element.sourceCodeLocation.startLine - 1; 28 | } 29 | jsParser.parseString(source, fileName, { 30 | lineNumberStart 31 | }); 32 | } 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/html/extractors/index.ts: -------------------------------------------------------------------------------- 1 | import { elementAttributeExtractor } from './factories/elementAttribute'; 2 | import { elementContentExtractor } from './factories/elementContent'; 3 | import { embeddedAttributeJsExtractor } from './factories/embeddedAttributeJs'; 4 | import { embeddedJsExtractor } from './factories/embeddedJs'; 5 | 6 | export abstract class HtmlExtractors { 7 | public static elementContent: typeof elementContentExtractor = elementContentExtractor; 8 | public static elementAttribute: typeof elementAttributeExtractor = elementAttributeExtractor; 9 | public static embeddedJs: typeof embeddedJsExtractor = embeddedJsExtractor; 10 | public static embeddedAttributeJs: typeof embeddedAttributeJsExtractor = embeddedAttributeJsExtractor; 11 | } 12 | -------------------------------------------------------------------------------- /src/html/parser.ts: -------------------------------------------------------------------------------- 1 | import * as parse5 from 'parse5'; 2 | 3 | import { Parser, IAddMessageCallback, IParseOptions } from '../parser'; 4 | import { IMessage } from '../builder'; 5 | 6 | export type Node = parse5.DefaultTreeNode; 7 | export type TextNode = parse5.DefaultTreeTextNode; 8 | export type Element = parse5.DefaultTreeElement; 9 | 10 | export type IHtmlExtractorFunction = (node: Node, fileName: string, addMessage: IAddMessageCallback, lineNumberStart: number) => void; 11 | 12 | export class HtmlParser extends Parser { 13 | 14 | protected parse(source: string, fileName: string, options: IParseOptions = {}): IMessage[] { 15 | let document = parse5.parse(source, {sourceCodeLocationInfo: true}); 16 | return this.parseNode(document, fileName, options.lineNumberStart || 1); 17 | } 18 | 19 | protected parseNode(node: any, fileName: string, lineNumberStart: number): IMessage[] { 20 | let messages: IMessage[] = []; 21 | let addMessageCallback = Parser.createAddMessageCallback(messages, fileName, () => { 22 | if (node.sourceCodeLocation && node.sourceCodeLocation.startLine) { 23 | return lineNumberStart + node.sourceCodeLocation.startLine - 1; 24 | } 25 | }); 26 | 27 | for (let extractor of this.extractors) { 28 | extractor(node, fileName, addMessageCallback, lineNumberStart); 29 | } 30 | 31 | let childNodes = node.content ? node.content.childNodes : node.childNodes; 32 | if (childNodes) { 33 | for (let n of childNodes) { 34 | messages = messages.concat(this.parseNode(n, fileName, lineNumberStart)); 35 | } 36 | } 37 | 38 | return messages; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/html/selector.ts: -------------------------------------------------------------------------------- 1 | import { CssSelectorParser } from 'css-selector-parser'; 2 | import { HtmlUtils } from './utils'; 3 | import { Element } from './parser'; 4 | 5 | export interface IElementSelectorAttribute { 6 | name: string; 7 | operator?: '=' | '^=' | '$=' | '*='; 8 | value?: string; 9 | regex?: RegExp; 10 | } 11 | 12 | export interface IElementSelector { 13 | tagName?: string; 14 | id?: string; 15 | classNames?: string[]; 16 | attributes?: IElementSelectorAttribute[]; 17 | } 18 | 19 | export class ElementSelectorSet { 20 | 21 | private selectors: ElementSelector[] = []; 22 | 23 | constructor( 24 | selectors: string | IElementSelector[] = [] 25 | ) { 26 | if (typeof selectors === 'string') { 27 | this.addFromString(selectors); 28 | } else { 29 | for (let s of selectors) { 30 | this.add(s); 31 | } 32 | } 33 | } 34 | 35 | public add(selector: IElementSelector): void { 36 | if (selector instanceof ElementSelector) { 37 | this.selectors.push(selector); 38 | } else { 39 | this.selectors.push(new ElementSelector(selector)); 40 | } 41 | } 42 | 43 | public addFromString(selectorString: string): void { 44 | let parser = new CssSelectorParser(); 45 | let selectors: any[]; 46 | 47 | parser.registerAttrEqualityMods('^', '$', '*'); 48 | 49 | try { 50 | let result = parser.parse(selectorString); 51 | selectors = result.type === 'selectors' ? result.selectors : [result]; 52 | } catch (e) { 53 | throw new Error(`Error parsing selector string: ${e.message}`); 54 | } 55 | 56 | for (let s of selectors) { 57 | let selector: IElementSelector = {}; 58 | let rule = s.rule; 59 | 60 | if (rule.rule) { 61 | throw new Error(`Selector string '${selectorString}' is invalid. Multi-level rules are not supported.`); 62 | } 63 | 64 | if (rule.tagName && rule.tagName !== '*') { 65 | selector.tagName = rule.tagName; 66 | } 67 | 68 | if (rule.id) { 69 | selector.id = rule.id; 70 | } 71 | 72 | if (rule.classNames) { 73 | selector.classNames = rule.classNames; 74 | } 75 | 76 | if (rule.attrs) { 77 | selector.attributes = rule.attrs.map((a: any) => { 78 | return { 79 | name: a.name, 80 | operator: a.operator, 81 | value: a.value 82 | }; 83 | }); 84 | } 85 | 86 | this.add(selector); 87 | } 88 | } 89 | 90 | public anyMatch(element: Element): boolean { 91 | return this.selectors.reduce((matched, s) => matched || s.matches(element), false as boolean); 92 | } 93 | 94 | public allMatch(element: Element): boolean { 95 | return this.selectors.reduce((matched, s) => matched && s.matches(element), this.selectors.length > 0); 96 | } 97 | } 98 | 99 | export class ElementSelector implements IElementSelector { 100 | 101 | public tagName?: string; 102 | public id?: string; 103 | public classNames?: string[]; 104 | public attributes?: IElementSelectorAttribute[]; 105 | 106 | constructor( 107 | private selector: IElementSelector 108 | ) { 109 | this.tagName = selector.tagName; 110 | this.id = selector.id; 111 | this.classNames = selector.classNames; 112 | this.attributes = selector.attributes; 113 | } 114 | 115 | public matches(element: Element): boolean { 116 | return this.tagNameMatches(element) 117 | && this.idMatches(element) 118 | && this.classNamesMatch(element) 119 | && this.attributesMatch(element); 120 | } 121 | 122 | private tagNameMatches(element: Element): boolean { 123 | if (!this.tagName) { 124 | return true; 125 | } 126 | 127 | return element.tagName === this.tagName.toLowerCase(); 128 | } 129 | 130 | private idMatches(element: Element): boolean { 131 | if (!this.id) { 132 | return true; 133 | } 134 | 135 | return HtmlUtils.getAttributeValue(element, 'id') === this.id; 136 | } 137 | 138 | private classNamesMatch(element: Element): boolean { 139 | if (!this.classNames || !this.classNames.length) { 140 | return true; 141 | } 142 | 143 | let classAttributeValue = HtmlUtils.getAttributeValue(element, 'class'); 144 | if (classAttributeValue === null) { 145 | return false; 146 | } 147 | 148 | let elementClassNames = classAttributeValue.split(' '); 149 | for (let className of this.classNames) { 150 | if (elementClassNames.indexOf(className) === -1) { 151 | return false; 152 | } 153 | } 154 | return true; 155 | } 156 | 157 | private attributesMatch(element: Element): boolean { 158 | if (!this.attributes) { 159 | return true; 160 | } 161 | 162 | for (let attribute of this.attributes) { 163 | let elementAttributeValue = HtmlUtils.getAttributeValue(element, attribute.name); 164 | if (elementAttributeValue === null) { 165 | return false; 166 | } 167 | if (attribute.value !== undefined) { 168 | switch (attribute.operator) { 169 | case '^=': 170 | if (elementAttributeValue.slice(0, attribute.value.length) !== attribute.value) { 171 | return false; 172 | } 173 | break; 174 | case '$=': 175 | if (elementAttributeValue.slice(-attribute.value.length) !== attribute.value) { 176 | return false; 177 | } 178 | break; 179 | case '*=': 180 | if (elementAttributeValue.indexOf(attribute.value) === -1) { 181 | return false; 182 | } 183 | break; 184 | case '=': 185 | default: 186 | if (attribute.value !== elementAttributeValue) { 187 | return false; 188 | } 189 | } 190 | } else if (attribute.regex instanceof RegExp) { 191 | if (!attribute.regex.test(elementAttributeValue)) { 192 | return false; 193 | } 194 | } 195 | } 196 | 197 | return true; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/html/utils.ts: -------------------------------------------------------------------------------- 1 | import * as parse5 from 'parse5'; 2 | 3 | import { Element } from './parser'; 4 | import { IContentOptions, normalizeContent } from '../utils/content'; 5 | 6 | export abstract class HtmlUtils { 7 | 8 | public static getAttributeValue(element: Element, attributeName: string): string | null { 9 | for (let attribute of element.attrs) { 10 | if (attribute.name === attributeName) { 11 | return attribute.value; 12 | } 13 | } 14 | 15 | return null; 16 | } 17 | 18 | public static getNormalizedAttributeValue(element: Element, attributeName: string, options: IContentOptions): string | null { 19 | let value = HtmlUtils.getAttributeValue(element, attributeName); 20 | if (value === null) { 21 | return null; 22 | } 23 | 24 | return normalizeContent(value, options); 25 | } 26 | 27 | public static getElementContent(element: Element, options: IContentOptions): string { 28 | let content = parse5.serialize(element, {}); 29 | 30 | // Un-escape characters that get escaped by parse5 31 | content = content 32 | .replace(/&/g, '&') 33 | .replace(/</g, '<') 34 | .replace(/>/g, '>'); 35 | 36 | return normalizeContent(content, options); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { GettextExtractor } from './extractor'; 2 | export { JsExtractors } from './js/extractors'; 3 | export { HtmlExtractors } from './html/extractors'; 4 | -------------------------------------------------------------------------------- /src/js/extractors/comments.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | enum CommentEdge { 4 | Leading, 5 | Trailing 6 | } 7 | 8 | export interface ICommentOptions { 9 | regex?: RegExp; 10 | otherLineLeading?: boolean; 11 | sameLineLeading?: boolean; 12 | sameLineTrailing?: boolean; 13 | } 14 | 15 | export abstract class JsCommentUtils { 16 | 17 | public static extractComments(callExpression: ts.CallExpression, sourceFile: ts.SourceFile, commentOptions?: ICommentOptions): string[] { 18 | commentOptions = JsCommentUtils.defaultCommentOptions(commentOptions); 19 | let comments: string[] = [], 20 | { start, end } = this.getExtractionPositions(callExpression, sourceFile), 21 | source = sourceFile.text; 22 | 23 | if (callExpression.parent.kind === ts.SyntaxKind.JsxExpression) { 24 | source = source.slice(0, start) + '\n' + source.slice(start); 25 | end++; 26 | } 27 | 28 | if (commentOptions.otherLineLeading || commentOptions.sameLineLeading) { 29 | comments = comments.concat(JsCommentUtils.extractCommentsAtPosition(source, start, CommentEdge.Leading, commentOptions)); 30 | } 31 | 32 | if (commentOptions.sameLineTrailing) { 33 | comments = comments.concat(JsCommentUtils.extractCommentsAtPosition(source, end, CommentEdge.Trailing, commentOptions)); 34 | } 35 | 36 | return comments; 37 | } 38 | 39 | private static getExtractionPositions(node: ts.Node, sourceFile: ts.SourceFile): {start: number, end: number} { 40 | 41 | let start = node.getFullStart(), 42 | end = node.getEnd(); 43 | 44 | function skipToCharacter(character: number): void { 45 | scan: while (end < sourceFile.text.length) { 46 | switch (sourceFile.text.charCodeAt(end)) { 47 | case character: 48 | end++; 49 | break scan; 50 | case 9: // tab 51 | case 11: // verticalTab 52 | case 12: // formFeed 53 | case 32: // space 54 | end++; 55 | break; 56 | default: 57 | break scan; 58 | } 59 | } 60 | } 61 | 62 | function skipToSemicolon(): void { 63 | skipToCharacter(59); 64 | } 65 | 66 | function skipToComma(): void { 67 | skipToCharacter(44); 68 | } 69 | 70 | switch (node.parent.kind) { 71 | case ts.SyntaxKind.ReturnStatement: 72 | case ts.SyntaxKind.ThrowStatement: 73 | case ts.SyntaxKind.ExpressionStatement: 74 | case ts.SyntaxKind.ParenthesizedExpression: 75 | case ts.SyntaxKind.BinaryExpression: 76 | return this.getExtractionPositions(node.parent, sourceFile); 77 | 78 | case ts.SyntaxKind.VariableDeclaration: 79 | if (node.parent.parent.kind === ts.SyntaxKind.VariableDeclarationList) { 80 | let variableDeclarationList = (node.parent.parent); 81 | if (variableDeclarationList.declarations.length === 1) { 82 | return this.getExtractionPositions(variableDeclarationList.parent, sourceFile); 83 | } else { 84 | if (this.nodeIsOnSeparateLine(node, variableDeclarationList.declarations.map(d => d.initializer ?? d.name) as ReadonlyArray, sourceFile)) { 85 | if (variableDeclarationList.declarations[variableDeclarationList.declarations.length - 1].initializer === node) { 86 | skipToSemicolon(); 87 | } else { 88 | skipToComma(); 89 | } 90 | } 91 | } 92 | } 93 | break; 94 | 95 | case ts.SyntaxKind.CallExpression: 96 | case ts.SyntaxKind.NewExpression: 97 | if (this.nodeIsOnSeparateLine(node, (node.parent).arguments as ReadonlyArray, sourceFile)) { 98 | skipToComma(); 99 | } 100 | break; 101 | 102 | case ts.SyntaxKind.PropertyAssignment: 103 | if (node.parent.parent.kind === ts.SyntaxKind.ObjectLiteralExpression 104 | && this.nodeIsOnSeparateLine(node.parent, (node.parent.parent).properties, sourceFile)) { 105 | skipToComma(); 106 | } 107 | break; 108 | 109 | case ts.SyntaxKind.ArrayLiteralExpression: 110 | if (this.nodeIsOnSeparateLine(node, (node.parent).elements, sourceFile)) { 111 | skipToComma(); 112 | } 113 | break; 114 | 115 | case ts.SyntaxKind.ConditionalExpression: 116 | if ((node.parent).whenFalse === node) { 117 | skipToSemicolon(); 118 | } 119 | break; 120 | } 121 | 122 | return { start, end }; 123 | } 124 | 125 | private static nodeIsOnSeparateLine(node: ts.Node, nodes: ReadonlyArray, sourceFile: ts.SourceFile): boolean { 126 | let index = nodes.indexOf(node); 127 | if (index === -1) { 128 | return false; 129 | } 130 | 131 | let lineNumber = sourceFile.getLineAndCharacterOfPosition(nodes[index].getStart()).line; 132 | if (index > 0) { 133 | if (lineNumber === sourceFile.getLineAndCharacterOfPosition(nodes[index - 1].getEnd()).line) { 134 | return false; 135 | } 136 | } 137 | if (index + 1 < nodes.length) { 138 | if (lineNumber === sourceFile.getLineAndCharacterOfPosition(nodes[index + 1].getStart()).line) { 139 | return false; 140 | } 141 | } 142 | return true; 143 | } 144 | 145 | private static extractCommentsAtPosition(source: string, position: number, edge: CommentEdge, commentOptions: ICommentOptions): string[] { 146 | let ranges: ts.CommentRange[] | undefined, 147 | comments: string[] = []; 148 | 149 | if (edge === CommentEdge.Leading) { 150 | ranges = ts.getLeadingCommentRanges(source, position); 151 | } else { 152 | ranges = ts.getTrailingCommentRanges(source, position); 153 | } 154 | 155 | for (let range of ranges || []) { 156 | let commentSource = source.slice(range.pos, range.end), 157 | comment: string | null | undefined, 158 | isSameLine = !range.hasTrailingNewLine; 159 | 160 | if ( 161 | edge === CommentEdge.Trailing && commentOptions.sameLineTrailing || 162 | edge === CommentEdge.Leading && ( 163 | isSameLine && commentOptions.sameLineLeading || 164 | !isSameLine && commentOptions.otherLineLeading 165 | ) 166 | ) { 167 | if (range.kind === ts.SyntaxKind.SingleLineCommentTrivia) { 168 | comment = JsCommentUtils.extractLineComment(commentSource); 169 | } else { 170 | comment = JsCommentUtils.extractBlockComment(commentSource); 171 | } 172 | } 173 | 174 | if (comment) { 175 | if (commentOptions.regex) { 176 | let match = comment.match(commentOptions.regex); 177 | if (match) { 178 | comments.push(match[1] !== undefined ? match[1] : match[0]); 179 | } 180 | } else { 181 | comments.push(comment); 182 | } 183 | } 184 | } 185 | 186 | return comments; 187 | } 188 | 189 | private static defaultCommentOptions(options: ICommentOptions = {}): ICommentOptions { 190 | if (options.otherLineLeading === undefined && options.sameLineLeading === undefined && options.sameLineTrailing === undefined) { 191 | options.otherLineLeading = false; 192 | options.sameLineLeading = true; 193 | options.sameLineTrailing = true; 194 | } 195 | options.otherLineLeading = !!options.otherLineLeading; 196 | options.sameLineLeading = !!options.sameLineLeading; 197 | options.sameLineTrailing = !!options.sameLineTrailing; 198 | return options; 199 | } 200 | 201 | private static extractLineComment(source: string): string | null { 202 | let match = source.match(/^\/\/\s*(.*?)\s*$/); 203 | return match ? match[1] : null; 204 | } 205 | 206 | private static extractBlockComment(source: string): string | null { 207 | if (source.indexOf('\n') !== -1) { 208 | return null; 209 | } 210 | 211 | let match = source.match(/^\/\*\s*(.*?)\s*\*\/$/); 212 | return match ? match[1] : null; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/js/extractors/common.ts: -------------------------------------------------------------------------------- 1 | import { Validate } from '../../utils/validate'; 2 | import { ICommentOptions } from './comments'; 3 | import { IContentOptions, IContentExtractorOptions } from '../../utils/content'; 4 | 5 | export interface IArgumentIndexMapping { 6 | text: number; 7 | textPlural?: number; 8 | context?: number; 9 | } 10 | 11 | export interface IJsExtractorOptions extends IContentExtractorOptions { 12 | arguments: IArgumentIndexMapping; 13 | comments?: ICommentOptions; 14 | } 15 | 16 | export function validateOptions(options: IJsExtractorOptions): void { 17 | Validate.required.numberProperty(options, 'options.arguments.text'); 18 | Validate.optional.numberProperty(options, 'options.arguments.textPlural'); 19 | Validate.optional.numberProperty(options, 'options.arguments.context'); 20 | Validate.optional.regexProperty(options, 'options.comments.regex'); 21 | Validate.optional.booleanProperty(options, 'options.comments.otherLineLeading'); 22 | Validate.optional.booleanProperty(options, 'options.comments.sameLineLeading'); 23 | Validate.optional.booleanProperty(options, 'options.comments.sameLineTrailing'); 24 | } 25 | -------------------------------------------------------------------------------- /src/js/extractors/factories/callExpression.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | import { IJsExtractorFunction } from '../../parser'; 4 | import { Validate } from '../../../utils/validate'; 5 | import { IContentOptions, normalizeContent, getContentOptions, validateContentOptions } from '../../../utils/content'; 6 | import { IJsExtractorOptions, validateOptions, IArgumentIndexMapping } from '../common'; 7 | import { JsUtils } from '../../utils'; 8 | import { IAddMessageCallback, IMessageData } from '../../../parser'; 9 | import { JsCommentUtils } from '../comments'; 10 | 11 | export function callExpressionExtractor(calleeName: string | string[], options: IJsExtractorOptions): IJsExtractorFunction { 12 | Validate.required.argument({calleeName}); 13 | 14 | let calleeNames = ([] as string[]).concat(calleeName); 15 | 16 | for (let name of calleeNames) { 17 | if (typeof name !== 'string' || name.length === 0) { 18 | throw new TypeError(`Argument 'calleeName' must be a non-empty string or an array containing non-empty strings`); 19 | } 20 | } 21 | 22 | validateOptions(options); 23 | validateContentOptions(options); 24 | 25 | let contentOptions: IContentOptions = getContentOptions(options, { 26 | trimWhiteSpace: false, 27 | preserveIndentation: true, 28 | replaceNewLines: false 29 | }); 30 | 31 | return (node: ts.Node, sourceFile: ts.SourceFile, addMessage: IAddMessageCallback) => { 32 | if (node.kind === ts.SyntaxKind.CallExpression) { 33 | let callExpression = node; 34 | 35 | let matches = calleeNames.reduce((matchFound, name) => ( 36 | matchFound || JsUtils.calleeNameMatchesCallExpression(name, callExpression) 37 | ), false); 38 | 39 | if (matches) { 40 | let message = extractArguments(callExpression, options.arguments, contentOptions); 41 | if (message) { 42 | message.comments = JsCommentUtils.extractComments(callExpression, sourceFile, options.comments); 43 | addMessage(message); 44 | } 45 | } 46 | } 47 | }; 48 | } 49 | 50 | function extractArguments(callExpression: ts.CallExpression, argumentMapping: IArgumentIndexMapping, contentOptions: IContentOptions): IMessageData | null { 51 | let callArguments = callExpression.arguments; 52 | let textArgument: ts.Expression | undefined = callArguments[argumentMapping.text], 53 | textPluralArgument: ts.Expression | undefined = callArguments[argumentMapping.textPlural!], 54 | contextArgument: ts.Expression | undefined = callArguments[argumentMapping.context!]; 55 | 56 | textArgument = checkAndConcatenateStrings(textArgument); 57 | textPluralArgument = checkAndConcatenateStrings(textPluralArgument); 58 | 59 | let textPluralValid = typeof argumentMapping.textPlural !== 'number' || isTextLiteral(textPluralArgument); 60 | 61 | if (isTextLiteral(textArgument) && textPluralValid) { 62 | let message: IMessageData = { 63 | text: normalizeContent(textArgument.text, contentOptions) 64 | }; 65 | 66 | if (isTextLiteral(textPluralArgument)) { 67 | message.textPlural = normalizeContent(textPluralArgument.text, contentOptions); 68 | } 69 | if (isTextLiteral(contextArgument)) { 70 | message.context = normalizeContent(contextArgument.text, contentOptions); 71 | } 72 | 73 | return message; 74 | } 75 | 76 | return null; 77 | } 78 | 79 | function isTextLiteral(expression: ts.Expression): expression is ts.LiteralExpression { 80 | return expression && (expression.kind === ts.SyntaxKind.StringLiteral || expression.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral); 81 | } 82 | 83 | function isParenthesizedExpression(expression: ts.Expression): expression is ts.ParenthesizedExpression { 84 | return expression && expression.kind === ts.SyntaxKind.ParenthesizedExpression; 85 | } 86 | 87 | function isBinaryExpression(expression: ts.Expression): expression is ts.BinaryExpression { 88 | return expression && expression.kind === ts.SyntaxKind.BinaryExpression; 89 | } 90 | 91 | function getAdditionExpression(expression: ts.Expression): ts.BinaryExpression | null { 92 | while (isParenthesizedExpression(expression)) { 93 | expression = expression.expression; 94 | } 95 | 96 | if (isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) { 97 | return expression; 98 | } 99 | 100 | return null; 101 | } 102 | 103 | function checkAndConcatenateStrings(expression: ts.Expression): ts.Expression { 104 | let addition: ts.BinaryExpression | null; 105 | 106 | if (!expression || !(addition = getAdditionExpression(expression))) { 107 | return expression; 108 | } 109 | 110 | let concatenated = ts.factory.createStringLiteral(''); 111 | 112 | if (processStringAddition(addition, concatenated)) { 113 | return concatenated; 114 | } 115 | 116 | return expression; 117 | } 118 | 119 | function processStringAddition(expression: ts.BinaryExpression, concatenated: ts.StringLiteral): boolean { 120 | let addition: ts.BinaryExpression | null; 121 | 122 | if (isTextLiteral(expression.left)) { 123 | concatenated.text += expression.left.text; 124 | } else if (addition = getAdditionExpression(expression.left)) { 125 | if (!processStringAddition(addition, concatenated)) { 126 | return false; 127 | } 128 | } else { 129 | return false; 130 | } 131 | 132 | if (isTextLiteral(expression.right)) { 133 | concatenated.text += expression.right.text; 134 | return true; 135 | } else if (addition = getAdditionExpression(expression.right)) { 136 | return processStringAddition(addition, concatenated); 137 | } else { 138 | return false; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/js/extractors/factories/htmlTemplate.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | import { IJsExtractorFunction } from '../../../js/parser'; 4 | import { Validate } from '../../../utils/validate'; 5 | import { HtmlParser } from '../../../html/parser'; 6 | 7 | export function htmlTemplateExtractor(htmlParser: HtmlParser): IJsExtractorFunction { 8 | Validate.required.argument({ htmlParser }); 9 | 10 | return (node: ts.Node, sourceFile: ts.SourceFile, _, lineNumberStart = 1) => { 11 | if (ts.isStringLiteralLike(node)) { 12 | const source = node.getText(sourceFile); 13 | const location = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); 14 | 15 | htmlParser.parseString( 16 | source, 17 | sourceFile.fileName, 18 | { lineNumberStart: lineNumberStart + location.line } 19 | ); 20 | } 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/js/extractors/index.ts: -------------------------------------------------------------------------------- 1 | import { callExpressionExtractor } from './factories/callExpression'; 2 | import { htmlTemplateExtractor } from './factories/htmlTemplate'; 3 | 4 | export abstract class JsExtractors { 5 | public static callExpression: typeof callExpressionExtractor = callExpressionExtractor; 6 | public static htmlTemplate: typeof htmlTemplateExtractor = htmlTemplateExtractor; 7 | } 8 | -------------------------------------------------------------------------------- /src/js/parser.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | import { Parser, IAddMessageCallback, IParseOptions } from '../parser'; 4 | import { IMessage } from '../builder'; 5 | 6 | export type IJsExtractorFunction = (node: ts.Node, sourceFile: ts.SourceFile, addMessage: IAddMessageCallback, lineNumberStart: number) => void; 7 | 8 | export interface IJsParseOptions extends IParseOptions { 9 | scriptKind?: ts.ScriptKind; 10 | } 11 | 12 | export class JsParser extends Parser { 13 | 14 | protected parse(source: string, fileName: string, options: IJsParseOptions = {}): IMessage[] { 15 | let sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, options.scriptKind); 16 | return this.parseNode(sourceFile, sourceFile, options.lineNumberStart || 1); 17 | } 18 | 19 | protected parseNode(node: ts.Node, sourceFile: ts.SourceFile, lineNumberStart: number): IMessage[] { 20 | let messages: IMessage[] = []; 21 | let addMessageCallback = Parser.createAddMessageCallback(messages, sourceFile.fileName, () => { 22 | let location = sourceFile.getLineAndCharacterOfPosition(node.getStart()); 23 | return lineNumberStart + location.line; 24 | }); 25 | 26 | for (let extractor of this.extractors) { 27 | extractor(node, sourceFile, addMessageCallback, lineNumberStart); 28 | } 29 | 30 | ts.forEachChild(node, n => { 31 | messages = messages.concat(this.parseNode(n, sourceFile, lineNumberStart)); 32 | }); 33 | 34 | return messages; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/js/utils.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | export abstract class JsUtils { 4 | 5 | public static segmentsMatchPropertyExpression(segments: string[], propertyAccessExpression: ts.PropertyAccessExpression): boolean { 6 | segments = segments.slice(); 7 | 8 | if (!(segments.pop() === propertyAccessExpression.name.text)) { 9 | return false; 10 | } 11 | 12 | let segment: string | undefined; 13 | 14 | switch (propertyAccessExpression.expression.kind) { 15 | case ts.SyntaxKind.Identifier: 16 | segment = segments.pop(); 17 | return (segments.length === 0 || segments.length === 1 && segments[0] === '[this]') 18 | && segment === (propertyAccessExpression.expression).text; 19 | 20 | case ts.SyntaxKind.ThisKeyword: 21 | segment = segments.pop(); 22 | return segments.length === 0 && (segment === 'this' || segment === '[this]'); 23 | 24 | case ts.SyntaxKind.PropertyAccessExpression: 25 | return this.segmentsMatchPropertyExpression(segments, propertyAccessExpression.expression); 26 | } 27 | 28 | return false; 29 | } 30 | 31 | public static calleeNameMatchesCallExpression(calleeName: string, callExpression: ts.CallExpression): boolean { 32 | let segments = calleeName.split('.'); 33 | 34 | switch (segments.length) { 35 | case 0: 36 | return false; 37 | case 1: 38 | return callExpression.expression.kind === ts.SyntaxKind.Identifier 39 | && (callExpression.expression).text === segments[0]; 40 | default: 41 | return callExpression.expression.kind === ts.SyntaxKind.PropertyAccessExpression 42 | && this.segmentsMatchPropertyExpression(segments, callExpression.expression); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as glob from 'glob'; 3 | 4 | import { IGettextExtractorStats } from './extractor'; 5 | import { CatalogBuilder, IMessage } from './builder'; 6 | import { Validate } from './utils/validate'; 7 | 8 | export interface IMessageData { 9 | text: string; 10 | textPlural?: string; 11 | context?: string; 12 | lineNumber?: number; 13 | fileName?: string; 14 | comments?: string[]; 15 | } 16 | 17 | export type IAddMessageCallback = (data: IMessageData) => void; 18 | 19 | export interface IParseOptions { 20 | lineNumberStart?: number; 21 | transformSource?: (source: string) => string; 22 | } 23 | 24 | export abstract class Parser { 25 | 26 | public static STRING_LITERAL_FILENAME: string = 'gettext-extractor-string-literal'; 27 | 28 | public static createAddMessageCallback(messages: Partial[], fileName: string, getLineNumber: () => number | undefined): IAddMessageCallback { 29 | return (data: IMessageData) => { 30 | let references: string[] | undefined; 31 | 32 | if (typeof data.lineNumber !== 'number') { 33 | data.lineNumber = getLineNumber(); 34 | } 35 | 36 | data.fileName = data.fileName || fileName; 37 | 38 | if (data.fileName && data.lineNumber && data.fileName !== Parser.STRING_LITERAL_FILENAME) { 39 | references = [`${data.fileName}:${data.lineNumber}`]; 40 | } 41 | 42 | let message: Partial = { 43 | text: data.text, 44 | textPlural: data.textPlural || undefined, 45 | context: data.context || undefined, 46 | references: references, 47 | comments: data.comments && data.comments.length ? data.comments : undefined 48 | }; 49 | 50 | messages.push(message); 51 | }; 52 | } 53 | 54 | constructor( 55 | protected builder: CatalogBuilder, 56 | protected extractors: TExtractorFunction[] = [], 57 | protected stats?: IGettextExtractorStats 58 | ) { 59 | this.validateExtractors(...extractors); 60 | } 61 | 62 | public parseString(source: string, fileName?: string, options?: TParseOptions): this { 63 | Validate.required.string({source}); 64 | Validate.optional.nonEmptyString({fileName}); 65 | this.validateParseOptions(options); 66 | 67 | if (!this.extractors.length) { 68 | throw new Error(`Missing extractor functions. Provide them when creating the parser or dynamically add extractors using 'addExtractor()'`); 69 | } 70 | 71 | if (options && options.transformSource) { 72 | source = options.transformSource(source); 73 | } 74 | 75 | let messages = this.parse(source, fileName || Parser.STRING_LITERAL_FILENAME, options); 76 | 77 | for (let message of messages) { 78 | this.builder.addMessage(message); 79 | } 80 | 81 | this.stats && this.stats.numberOfParsedFiles++; 82 | if (messages.length) { 83 | this.stats && this.stats.numberOfParsedFilesWithMessages++; 84 | } 85 | 86 | return this; 87 | } 88 | 89 | public parseFile(fileName: string, options?: TParseOptions): this { 90 | Validate.required.nonEmptyString({fileName}); 91 | this.validateParseOptions(options); 92 | 93 | this.parseString(fs.readFileSync(fileName).toString(), fileName, options); 94 | 95 | return this; 96 | } 97 | 98 | public parseFilesGlob(pattern: string, globOptions?: glob.IOptions, options?: TParseOptions): this { 99 | Validate.required.nonEmptyString({pattern}); 100 | Validate.optional.object({globOptions}); 101 | this.validateParseOptions(options); 102 | 103 | for (let fileName of glob.sync(pattern, { ...globOptions, nodir: true })) { 104 | this.parseFile(fileName, options); 105 | } 106 | 107 | return this; 108 | } 109 | 110 | public addExtractor(extractor: TExtractorFunction): this { 111 | Validate.required.argument({extractor}); 112 | this.validateExtractors(extractor); 113 | 114 | this.extractors.push(extractor); 115 | 116 | return this; 117 | } 118 | 119 | protected validateParseOptions(options?: TParseOptions): void { 120 | Validate.optional.numberProperty(options, 'options.lineNumberStart'); 121 | Validate.optional.functionProperty(options, 'options.transformSource'); 122 | } 123 | 124 | protected validateExtractors(...extractors: TExtractorFunction[]): void { 125 | for (let extractor of extractors) { 126 | if (typeof extractor !== 'function') { 127 | throw new TypeError(`Invalid extractor function provided. '${extractor}' is not a function`); 128 | } 129 | } 130 | } 131 | 132 | protected abstract parse(source: string, fileName: string, options?: TParseOptions): IMessage[]; 133 | } 134 | -------------------------------------------------------------------------------- /src/utils/content.ts: -------------------------------------------------------------------------------- 1 | import { Validate } from './validate'; 2 | 3 | export interface IContentOptions { 4 | trimWhiteSpace: boolean; 5 | preserveIndentation: boolean; 6 | replaceNewLines: false | string; 7 | } 8 | 9 | export interface IContentExtractorOptions { 10 | content?: Partial; 11 | } 12 | 13 | export function getContentOptions(extractorOptions: IContentExtractorOptions, defaultOptions: IContentOptions): IContentOptions { 14 | let contentOptions = defaultOptions; 15 | 16 | if (extractorOptions.content) { 17 | if (extractorOptions.content.trimWhiteSpace !== undefined) { 18 | contentOptions.trimWhiteSpace = extractorOptions.content.trimWhiteSpace; 19 | } 20 | if (extractorOptions.content.preserveIndentation !== undefined) { 21 | contentOptions.preserveIndentation = extractorOptions.content.preserveIndentation; 22 | } 23 | if (extractorOptions.content.replaceNewLines !== undefined) { 24 | contentOptions.replaceNewLines = extractorOptions.content.replaceNewLines; 25 | } 26 | } 27 | 28 | return contentOptions; 29 | } 30 | 31 | export function validateContentOptions(options: IContentExtractorOptions): void { 32 | Validate.optional.booleanProperty(options, 'options.content.trimWhiteSpace'); 33 | Validate.optional.booleanProperty(options, 'options.content.preserveIndentation'); 34 | if (options.content && options.content.replaceNewLines !== undefined 35 | && options.content.replaceNewLines !== false && typeof options.content.replaceNewLines !== 'string') { 36 | throw new TypeError(`Property 'options.content.replaceNewLines' must be false or a string`); 37 | } 38 | } 39 | 40 | export function normalizeContent(content: string, options: IContentOptions): string { 41 | content = content.replace(/\r\n/g, '\n'); 42 | if (options.trimWhiteSpace) { 43 | if (options.preserveIndentation) { 44 | // trim whitespace while preserving indentation 45 | // uses regex constructor instead of literal to simplify documentation 46 | let contentWithIndentationRegex = new RegExp( 47 | '^\\s*?' + // non-greedily matches whitespace in the beginning 48 | '(' + 49 | '[ \\t]*\\S' + // matches tabs or spaces in front of a non-whitespace character 50 | '[^]*?' + // non-greedily matches everything (including newlines) until the end (minus trailing whitespace) 51 | ')' + 52 | '\\s*$', // matches trailing whitespace 53 | 'g' 54 | ); 55 | content = content.replace(contentWithIndentationRegex, '$1'); 56 | } else { 57 | content = content.trim(); 58 | } 59 | } 60 | if (!options.preserveIndentation) { 61 | content = content.replace(/^[ \t]+/mg, ''); 62 | } 63 | if (typeof options.replaceNewLines === 'string') { 64 | content = content.replace(/\n/g, options.replaceNewLines); 65 | } 66 | 67 | return content; 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/output.ts: -------------------------------------------------------------------------------- 1 | import { IGettextExtractorStats } from '../extractor'; 2 | import { Chalk } from 'chalk'; 3 | 4 | let chalk: Chalk | undefined; 5 | try { 6 | chalk = require('chalk'); 7 | } catch (e) { /* falls back to default colored output */ } 8 | 9 | export abstract class OutputUtils { 10 | public static green(value: string): string { 11 | return chalk ? chalk.green(value) : value; 12 | } 13 | 14 | public static grey(value: string): string { 15 | return chalk ? chalk.grey(value) : value; 16 | } 17 | } 18 | 19 | interface IStatsDetail { 20 | primaryNumber: number; 21 | primaryText: string; 22 | secondaryText?: string | false; 23 | } 24 | 25 | export class StatsOutput { 26 | 27 | private static readonly INDENTATION: number = 2; 28 | 29 | private maxNumberLength: number; 30 | private maxTextLength: number; 31 | 32 | private get details(): IStatsDetail[] { 33 | return [ 34 | { 35 | primaryNumber: this.stats.numberOfMessageUsages, 36 | primaryText: this.stats.numberOfMessageUsages === 1 ? 'total usage' : 'total usages' 37 | }, 38 | { 39 | primaryNumber: this.stats.numberOfParsedFiles, 40 | primaryText: this.stats.numberOfParsedFiles === 1 ? 'file' : 'files', 41 | secondaryText: `(${this.stats.numberOfParsedFilesWithMessages} with messages)` 42 | }, 43 | { 44 | primaryNumber: this.stats.numberOfContexts, 45 | primaryText: this.stats.numberOfContexts === 1 ? 'message context' : 'message contexts', 46 | secondaryText: this.stats.numberOfContexts === 1 && '(default)' 47 | } 48 | ]; 49 | } 50 | 51 | private get title(): string { 52 | return this.stats.numberOfMessages === 1 53 | ? `${this.padNumber(1)} message extracted` 54 | : `${this.padNumber(this.stats.numberOfMessages)} messages extracted`; 55 | } 56 | 57 | constructor( 58 | private stats: IGettextExtractorStats 59 | ) { 60 | let numbers = this.details 61 | .map(d => d.primaryNumber) 62 | .concat(this.stats.numberOfMessages); 63 | 64 | this.maxNumberLength = Math.max.apply(undefined, numbers.map(n => n.toString().length)); 65 | 66 | this.maxTextLength = Math.max.apply(undefined, this.details.map(l => { 67 | return this.padNumber(1).length + 1 + l.primaryText.length + (l.secondaryText ? l.secondaryText.length + 1 : 0); 68 | }).concat(this.title.length)); 69 | } 70 | 71 | public print(): void { 72 | console.log(); 73 | console.log(this.indent(OutputUtils.green(this.title))); 74 | console.log(this.indent(OutputUtils.grey(new Array(this.maxTextLength + 2).join('-')))); 75 | 76 | for (let line of this.details) { 77 | let text = this.padNumber(line.primaryNumber) + ' ' + line.primaryText; 78 | if (line.secondaryText) { 79 | text += ' ' + OutputUtils.grey(line.secondaryText); 80 | } 81 | console.log(this.indent(text)); 82 | } 83 | 84 | console.log(); 85 | } 86 | 87 | private padNumber(value: number): string { 88 | return (new Array(this.maxNumberLength).join(' ') + value).slice(-this.maxNumberLength); 89 | } 90 | 91 | private indent(value: string): string { 92 | return new Array(StatsOutput.INDENTATION + 1).join(' ') + value; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | export type Arguments = {[name: string]: any}; 2 | 3 | export class Validate { 4 | 5 | private entry?: Function; 6 | 7 | public static get required(): Validate { 8 | return new Validate(true); 9 | } 10 | 11 | public static get optional(): Validate { 12 | return new Validate(false); 13 | } 14 | 15 | constructor( 16 | private required: boolean 17 | ) {} 18 | 19 | public argument(args: Arguments): void { 20 | this.entry = this.argument; 21 | this.each(args, () => {}); 22 | } 23 | 24 | public booleanProperty(object: any, path: string): void { 25 | this.entry = this.booleanProperty; 26 | this.property(object, path, (name, value) => { 27 | if (typeof value !== 'boolean') { 28 | throw this.typeError(`Property '${name}' must be a boolean`); 29 | } 30 | }); 31 | } 32 | 33 | public string(args: Arguments): void { 34 | this.entry = this.string; 35 | this.each(args, (name, value) => { 36 | if (typeof value !== 'string') { 37 | throw this.typeError(`'Argument '${name}' must be a string`); 38 | } 39 | }); 40 | } 41 | 42 | public nonEmptyString(args: Arguments): void { 43 | this.entry = this.nonEmptyString; 44 | this.each(args, (name, value) => { 45 | if (typeof value !== 'string' || value.length === 0) { 46 | throw this.typeError(`'Argument '${name}' must be a non-empty string`); 47 | } 48 | }); 49 | } 50 | 51 | public stringProperty(object: any, path: string): void { 52 | this.entry = this.stringProperty; 53 | this.property(object, path, (name, value) => { 54 | if (typeof value !== 'string') { 55 | throw this.typeError(`Property '${name}' must be a string`); 56 | } 57 | }); 58 | } 59 | 60 | public numberProperty(object: any, path: string): void { 61 | this.entry = this.numberProperty; 62 | this.property(object, path, (name, value) => { 63 | if (typeof value !== 'number' || isNaN(value)) { 64 | throw this.typeError(`Property '${name}' must be a number`); 65 | } 66 | }); 67 | } 68 | 69 | public functionProperty(object: any, path: string): void { 70 | this.entry = this.functionProperty; 71 | this.property(object, path, (name, value) => { 72 | if (typeof value !== 'function') { 73 | throw this.typeError(`Property '${name}' must be a function`); 74 | } 75 | }); 76 | } 77 | 78 | public object(args: Arguments): void { 79 | this.entry = this.object; 80 | this.each(args, (name, value) => { 81 | if (typeof value !== 'object' || value === null) { 82 | throw this.typeError(`Argument '${name}' must be an object`); 83 | } 84 | }); 85 | } 86 | 87 | public nonEmptyArray(args: Arguments): void { 88 | this.entry = this.nonEmptyArray; 89 | this.each(args, (name, value) => { 90 | if (!(value instanceof Array) || value.length === 0) { 91 | throw this.typeError(`Argument '${name}' must be a non-empty array`); 92 | } 93 | }); 94 | } 95 | 96 | public arrayProperty(object: any, path: string): void { 97 | this.entry = this.arrayProperty; 98 | this.property(object, path, (name, value) => { 99 | if (!(value instanceof Array)) { 100 | throw this.typeError(`Property '${name}' must be an array`); 101 | } 102 | }); 103 | } 104 | 105 | public regexProperty(object: any, path: string): void { 106 | this.entry = this.regexProperty; 107 | this.property(object, path, (name, value) => { 108 | if (!(value instanceof RegExp)) { 109 | throw this.typeError(`Property '${name}' must be a regular expression`); 110 | } 111 | }); 112 | } 113 | 114 | private each(args: Arguments, callback: (name: string, value: any) => void): void { 115 | for (let name of Object.keys(args)) { 116 | let value = args[name]; 117 | if (value !== undefined) { 118 | callback(name, value); 119 | } else if (this.required) { 120 | throw this.error(`Missing argument '${name}'`); 121 | } 122 | } 123 | } 124 | 125 | private property(object: any, path: string, callback: (name: string, value: any) => void): void { 126 | let properties = path.split('.'); 127 | 128 | let value = object; 129 | let name = properties.shift()!; 130 | 131 | if (!value && !this.required) { 132 | return; 133 | } 134 | 135 | this.object({[name]: value}); 136 | 137 | let property: string | undefined; 138 | while (property = properties.shift()) { 139 | name += `.${property}`; 140 | value = value[property]; 141 | 142 | if (properties.length) { 143 | if (typeof value !== 'object' || value === null) { 144 | if (value !== undefined || this.required) { 145 | throw this.typeError(`Property '${name}' must be an object`); 146 | } else { 147 | break; 148 | } 149 | } 150 | } else { 151 | if (value !== undefined) { 152 | callback(name, value); 153 | } else if (this.required) { 154 | throw this.error(`Property '${name}' is missing`); 155 | } 156 | } 157 | } 158 | } 159 | 160 | private typeError(message: string): TypeError { 161 | let error = new TypeError(message); 162 | Error.captureStackTrace(error, this.entry); 163 | return error; 164 | } 165 | 166 | private error(message: string): Error { 167 | let error = new Error(message); 168 | Error.captureStackTrace(error, this.entry); 169 | return error; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/e2e/extractor.test.ts: -------------------------------------------------------------------------------- 1 | import { GettextExtractor } from '../../dist'; 2 | 3 | let extractor: GettextExtractor; 4 | 5 | beforeEach(() => { 6 | extractor = new GettextExtractor(); 7 | }); 8 | 9 | describe('POT string', () => { 10 | 11 | test('default headers', () => { 12 | let pot = extractor.getPotString(); 13 | expect(pot).toBe( 14 | `msgid ""\n` + 15 | `msgstr ""\n` + 16 | `"Content-Type: text/plain; charset=UTF-8\\n"\n` 17 | ); 18 | }); 19 | 20 | test('additional headers', () => { 21 | let pot = extractor.getPotString({ 22 | 'Project-Id-Version': 'Foo' 23 | }); 24 | expect(pot).toBe( 25 | `msgid ""\n` + 26 | `msgstr ""\n` + 27 | `"Content-Type: text/plain; charset=UTF-8\\n"\n` + 28 | `"Project-Id-Version: Foo\\n"\n` 29 | ); 30 | }); 31 | 32 | test('overridden content type header', () => { 33 | let pot = extractor.getPotString({ 34 | 'Content-Type': 'text/plain; charset=ISO-8859-1' 35 | }); 36 | expect(pot).toBe( 37 | `msgid ""\n` + 38 | `msgstr ""\n` + 39 | `"Content-Type: text/plain; charset=ISO-8859-1\\n"\n` 40 | ); 41 | }); 42 | }); 43 | 44 | describe('argument validation', () => { 45 | 46 | describe('addMessage', () => { 47 | 48 | test('message: (none)', () => { 49 | expect(() => { 50 | (extractor.addMessage)(); 51 | }).toThrowError(`Missing argument 'message'`); 52 | }); 53 | 54 | test('message: null', () => { 55 | expect(() => { 56 | (extractor.addMessage)(null); 57 | }).toThrowError(`Argument 'message' must be an object`); 58 | }); 59 | 60 | test('message: wrong type', () => { 61 | expect(() => { 62 | (extractor.addMessage)(42); 63 | }).toThrowError(`Argument 'message' must be an object`); 64 | }); 65 | 66 | test('message.text: (none)', () => { 67 | expect(() => { 68 | (extractor.addMessage)({}); 69 | }).toThrowError(`Property 'message.text' is missing`); 70 | }); 71 | 72 | test('message.text: null', () => { 73 | expect(() => { 74 | (extractor.addMessage)({ 75 | text: null 76 | }); 77 | }).toThrowError(`Property 'message.text' must be a string`); 78 | }); 79 | 80 | test('message.text: wrong type', () => { 81 | expect(() => { 82 | (extractor.addMessage)({ 83 | text: 42 84 | }); 85 | }).toThrowError(`Property 'message.text' must be a string`); 86 | }); 87 | 88 | test('message: only required', () => { 89 | expect(() => { 90 | (extractor.addMessage)({ 91 | text: 'Foo' 92 | }); 93 | }).not.toThrow(); 94 | }); 95 | 96 | test('message.textPlural: null', () => { 97 | expect(() => { 98 | (extractor.addMessage)({ 99 | text: 'Foo', 100 | textPlural: null 101 | }); 102 | }).toThrowError(`Property 'message.textPlural' must be a string`); 103 | }); 104 | 105 | test('message.textPlural: wrong type', () => { 106 | expect(() => { 107 | (extractor.addMessage)({ 108 | text: 'Foo', 109 | textPlural: 42 110 | }); 111 | }).toThrowError(`Property 'message.textPlural' must be a string`); 112 | }); 113 | 114 | test('message.context: null', () => { 115 | expect(() => { 116 | (extractor.addMessage)({ 117 | text: 'Foo', 118 | context: null 119 | }); 120 | }).toThrowError(`Property 'message.context' must be a string`); 121 | }); 122 | 123 | test('message.context: wrong type', () => { 124 | expect(() => { 125 | (extractor.addMessage)({ 126 | text: 'Foo', 127 | context: 42 128 | }); 129 | }).toThrowError(`Property 'message.context' must be a string`); 130 | }); 131 | 132 | test('message.references: null', () => { 133 | expect(() => { 134 | (extractor.addMessage)({ 135 | text: 'Foo', 136 | references: null 137 | }); 138 | }).toThrowError(`Property 'message.references' must be an array`); 139 | }); 140 | 141 | test('message.references: wrong type', () => { 142 | expect(() => { 143 | (extractor.addMessage)({ 144 | text: 'Foo', 145 | references: 42 146 | }); 147 | }).toThrowError(`Property 'message.references' must be an array`); 148 | }); 149 | 150 | test('message.comments: null', () => { 151 | expect(() => { 152 | (extractor.addMessage)({ 153 | text: 'Foo', 154 | comments: null 155 | }); 156 | }).toThrowError(`Property 'message.comments' must be an array`); 157 | }); 158 | 159 | test('message.comments: wrong type', () => { 160 | expect(() => { 161 | (extractor.addMessage)({ 162 | text: 'Foo', 163 | comments: 42 164 | }); 165 | }).toThrowError(`Property 'message.comments' must be an array`); 166 | }); 167 | }); 168 | 169 | describe('getPotString', () => { 170 | 171 | test('headers: wrong type', () => { 172 | expect(() => { 173 | (extractor.getPotString)('foo'); 174 | }).toThrowError(`Argument 'headers' must be an object`); 175 | }); 176 | }); 177 | 178 | describe('savePotFile', () => { 179 | 180 | test('fileName: (none)', () => { 181 | expect(() => { 182 | (extractor.savePotFile)(); 183 | }).toThrowError(`Missing argument 'fileName'`); 184 | }); 185 | 186 | test('fileName: null', () => { 187 | expect(() => { 188 | (extractor.savePotFile)(null); 189 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 190 | }); 191 | 192 | test('fileName: wrong type', () => { 193 | expect(() => { 194 | (extractor.savePotFile)(42); 195 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 196 | }); 197 | 198 | test('headers: wrong type', () => { 199 | expect(() => { 200 | (extractor.savePotFile)('foo.ts', 'foo'); 201 | }).toThrowError(`Argument 'headers' must be an object`); 202 | }); 203 | }); 204 | 205 | describe('savePotFileAsync', () => { 206 | 207 | test('fileName: (none)', () => { 208 | expect(() => { 209 | (extractor.savePotFileAsync)(); 210 | }).toThrowError(`Missing argument 'fileName'`); 211 | }); 212 | 213 | test('fileName: null', () => { 214 | expect(() => { 215 | (extractor.savePotFileAsync)(null); 216 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 217 | }); 218 | 219 | test('fileName: wrong type', () => { 220 | expect(() => { 221 | (extractor.savePotFileAsync)(42); 222 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 223 | }); 224 | 225 | test('headers: wrong type', () => { 226 | expect(() => { 227 | (extractor.savePotFileAsync)('foo.ts', 'foo'); 228 | }).toThrowError(`Argument 'headers' must be an object`); 229 | }); 230 | }); 231 | 232 | describe('createJsParser', () => { 233 | 234 | test('extractors: (none)', () => { 235 | expect(() => { 236 | (extractor.createJsParser)(); 237 | }).not.toThrow(); 238 | }); 239 | 240 | test('extractors: null', () => { 241 | expect(() => { 242 | (extractor.createJsParser)(null); 243 | }).toThrowError(`Argument 'extractors' must be a non-empty array`); 244 | }); 245 | 246 | test('extractors: wrong type', () => { 247 | expect(() => { 248 | (extractor.createJsParser)(42); 249 | }).toThrowError(`Argument 'extractors' must be a non-empty array`); 250 | }); 251 | 252 | test('extractors: []', () => { 253 | expect(() => { 254 | (extractor.createJsParser)([]); 255 | }).toThrowError(`Argument 'extractors' must be a non-empty array`); 256 | }); 257 | 258 | test('extractors: [(none)]', () => { 259 | expect(() => { 260 | (extractor.createJsParser)([ 261 | undefined 262 | ]); 263 | }).toThrowError(`Invalid extractor function provided. 'undefined' is not a function`); 264 | }); 265 | 266 | test('extractors: [null]', () => { 267 | expect(() => { 268 | (extractor.createJsParser)([ 269 | null 270 | ]); 271 | }).toThrowError(`Invalid extractor function provided. 'null' is not a function`); 272 | }); 273 | 274 | test('extractors: [wrong type]', () => { 275 | expect(() => { 276 | (extractor.createJsParser)([ 277 | 42 278 | ]); 279 | }).toThrowError(`Invalid extractor function provided. '42' is not a function`); 280 | }); 281 | 282 | test('extractors: [function, wrong type]', () => { 283 | expect(() => { 284 | (extractor.createJsParser)([ 285 | () => {}, 286 | 42 287 | ]); 288 | }).toThrowError(`Invalid extractor function provided. '42' is not a function`); 289 | }); 290 | }); 291 | }); 292 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/embeddedJs.expected.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: tests/e2e/fixtures/html/embeddedJs.html:10 6 | msgid "Hello World!" 7 | msgstr "" 8 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/embeddedJs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/example.expected.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: tests/e2e/fixtures/html/header.html:2 6 | msgid "Logo" 7 | msgstr "" 8 | 9 | #: tests/e2e/fixtures/html/header.html:4 10 | msgctxt "Menu" 11 | msgid "{{n}} notification" 12 | msgid_plural "{{n}} notifications" 13 | msgstr[0] "" 14 | msgstr[1] "" 15 | 16 | #. Comment 17 | #: tests/e2e/fixtures/html/header.html:6 18 | msgctxt "Menu" 19 | msgid "Logout" 20 | msgstr "" 21 | 22 | #: tests/e2e/fixtures/html/header.html:5 23 | msgctxt "Menu" 24 | msgid "Settings" 25 | msgstr "" 26 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/header.html: -------------------------------------------------------------------------------- 1 |
2 | Logo 3 |
    4 |
  • {{n}} notification
  • 5 |
  • Settings
  • 6 |
  • Logout
  • 7 |
8 |
9 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/linenumberStart.expected.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: tests/e2e/fixtures/html/linenumberStart.html:23 6 | msgid "%{n} apple" 7 | msgid_plural "%{n} apples" 8 | msgstr[0] "" 9 | msgstr[1] "" 10 | 11 | #: tests/e2e/fixtures/html/linenumberStart.html:22 12 | msgid "msg 1" 13 | msgstr "" 14 | 15 | #: tests/e2e/fixtures/html/linenumberStart.html:24 16 | msgid "msg 2" 17 | msgstr "" 18 | 19 | #: tests/e2e/fixtures/html/linenumberStart.html:19 20 | msgctxt "title" 21 | msgid "%{n} apple" 22 | msgid_plural "%{n} apples" 23 | msgstr[0] "" 24 | msgstr[1] "" 25 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/linenumberStart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
11 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/template.expected.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: tests/e2e/fixtures/html/template.html:2 6 | msgid "Logo" 7 | msgstr "" 8 | 9 | #: tests/e2e/fixtures/html/template.html:4 10 | msgctxt "Menu" 11 | msgid "{{n}} notification" 12 | msgid_plural "{{n}} notifications" 13 | msgstr[0] "" 14 | msgstr[1] "" 15 | 16 | #: tests/e2e/fixtures/html/template.html:6 17 | msgctxt "Menu" 18 | msgid "Logout" 19 | msgstr "" 20 | 21 | #: tests/e2e/fixtures/html/template.html:5 22 | msgctxt "Menu" 23 | msgid "Settings" 24 | msgstr "" 25 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/html/template.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/js/example.expected.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: tests/e2e/fixtures/js/view.jsx:22 6 | msgid "One new message" 7 | msgid_plural "{{n}} new messages" 8 | msgstr[0] "" 9 | msgstr[1] "" 10 | 11 | #. Comment 12 | #: tests/e2e/fixtures/js/view.jsx:14 13 | msgid "Refresh" 14 | msgstr "" 15 | 16 | #: tests/e2e/fixtures/js/hello.jsx:5 17 | msgctxt "title" 18 | msgid "Hello World!" 19 | msgstr "" 20 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/js/hello.jsx: -------------------------------------------------------------------------------- 1 | export class HelloWorld extends React.Component { 2 | render() { 3 | return ( 4 |
5 |

{ t('Hello World!', 'title') }

6 |
7 | ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/js/multiline.expected.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: tests/e2e/fixtures/js/multiline.js:1 6 | msgid "" 7 | "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam\n" 8 | " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam\n" 9 | " erat, sed diam voluptua." 10 | msgstr "" 11 | 12 | #: tests/e2e/fixtures/js/multiline.js:7 13 | msgid "" 14 | "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam\n" 15 | "nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam\n" 16 | "erat, sed diam voluptua." 17 | msgstr "" 18 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/js/multiline.js: -------------------------------------------------------------------------------- 1 | translate( 2 | `Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam 3 | nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam 4 | erat, sed diam voluptua.` 5 | ); 6 | 7 | translate_trim( 8 | `Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam 9 | nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam 10 | erat, sed diam voluptua.` 11 | ); 12 | -------------------------------------------------------------------------------- /tests/e2e/fixtures/js/view.jsx: -------------------------------------------------------------------------------- 1 | import { HelloWorld } from 'tests/e2e/fixtures/js/hello.jsx'; 2 | 3 | export class View extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | this.translations = props.translationService; 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 | 13 | 16 |
17 | ); 18 | } 19 | 20 | refresh() { 21 | let count = Math.round(Math.random() * 100); 22 | alert(this.translations.plural(count, 'One new message', '{{n}} new messages')); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/e2e/html.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { GettextExtractor, HtmlExtractors, JsExtractors } from '../../dist'; 3 | 4 | describe('HTML E2E', () => { 5 | 6 | test('example', () => { 7 | let extractor = new GettextExtractor(); 8 | 9 | extractor 10 | .createHtmlParser([ 11 | HtmlExtractors.elementContent('[translate]', { 12 | attributes: { 13 | textPlural: 'translate-plural', 14 | context: 'translation-context', 15 | comment: 'translation-comment' 16 | } 17 | }), 18 | HtmlExtractors.elementAttribute('[translate-alt]', 'alt') 19 | ]) 20 | .parseFile('tests/e2e/fixtures/html/header.html'); 21 | 22 | expect(extractor.getPotString()).toBe(fs.readFileSync(__dirname + '/fixtures/html/example.expected.pot').toString()); 23 | }); 24 | 25 | test('template element', () => { 26 | let extractor = new GettextExtractor(); 27 | 28 | extractor 29 | .createHtmlParser([ 30 | HtmlExtractors.elementContent('[translate]', { 31 | attributes: { 32 | textPlural: 'translate-plural', 33 | context: 'translation-context' 34 | } 35 | }), 36 | HtmlExtractors.elementAttribute('[translate-alt]', 'alt') 37 | ]) 38 | .parseFile('tests/e2e/fixtures/html/template.html'); 39 | 40 | expect(extractor.getPotString()).toBe(fs.readFileSync(__dirname + '/fixtures/html/template.expected.pot').toString()); 41 | }); 42 | 43 | test('embedded js', () => { 44 | let extractor = new GettextExtractor(); 45 | 46 | let jsParser = extractor.createJsParser([ 47 | JsExtractors.callExpression('translations.getText', { 48 | arguments: { 49 | text: 0 50 | } 51 | }) 52 | ]); 53 | 54 | extractor 55 | .createHtmlParser([ 56 | HtmlExtractors.embeddedJs('script[type=text/javascript]', jsParser) 57 | ]) 58 | .parseFile('tests/e2e/fixtures/html/embeddedJs.html'); 59 | 60 | expect(extractor.getPotString()).toBe(fs.readFileSync(__dirname + '/fixtures/html/embeddedJs.expected.pot').toString()); 61 | }); 62 | 63 | test('line number start 11', () => { 64 | const extractor = new GettextExtractor(); 65 | const jsParser = extractor.createJsParser([ 66 | JsExtractors.callExpression('__', { arguments: { text: 0, } }), 67 | JsExtractors.callExpression('_n', { arguments: { text: 0, textPlural: 1 } }), 68 | JsExtractors.callExpression('_xn', { arguments: { context: 0, text: 1, textPlural: 2 } }), 69 | ]); 70 | const htmlParser = extractor.createHtmlParser([ 71 | HtmlExtractors.embeddedAttributeJs(/:title/, jsParser), 72 | HtmlExtractors.embeddedJs('script', jsParser), 73 | ]); 74 | htmlParser.parseFile('tests/e2e/fixtures/html/linenumberStart.html', { lineNumberStart: 11 }); 75 | expect(extractor.getPotString()) 76 | .toBe(fs.readFileSync(__dirname + '/fixtures/html/linenumberStart.expected.pot').toString()) 77 | }); 78 | 79 | }); 80 | -------------------------------------------------------------------------------- /tests/e2e/html/elementAttribute.test.ts: -------------------------------------------------------------------------------- 1 | import { IHtmlExtractorFunction } from '../../../dist/html/parser'; 2 | import { IMessage } from '../../../dist/builder'; 3 | import { GettextExtractor, HtmlExtractors } from '../../../dist'; 4 | import { trimIndent } from '../../indent'; 5 | 6 | describe('standard', () => { 7 | 8 | test('just text', assertMessages( 9 | HtmlExtractors.elementAttribute('translate', 'text', { 10 | attributes: { 11 | context: 'context', 12 | textPlural: 'plural' 13 | } 14 | }), 15 | ``, 16 | { 17 | text: 'Foo' 18 | } 19 | )); 20 | 21 | test('with context', assertMessages( 22 | HtmlExtractors.elementAttribute('translate', 'text', { 23 | attributes: { 24 | context: 'context', 25 | textPlural: 'plural' 26 | } 27 | }), 28 | ``, 29 | { 30 | text: 'Foo', 31 | context: 'Context' 32 | } 33 | )); 34 | 35 | test('plural', assertMessages( 36 | HtmlExtractors.elementAttribute('translate', 'text', { 37 | attributes: { 38 | context: 'context', 39 | textPlural: 'plural' 40 | } 41 | }), 42 | ``, 43 | { 44 | text: 'Foo', 45 | textPlural: 'Foos' 46 | } 47 | )); 48 | 49 | test('plural with context', assertMessages( 50 | HtmlExtractors.elementAttribute('translate', 'text', { 51 | attributes: { 52 | context: 'context', 53 | textPlural: 'plural' 54 | } 55 | }), 56 | ``, 57 | { 58 | text: 'Foo', 59 | textPlural: 'Foos', 60 | context: 'Context' 61 | } 62 | )); 63 | 64 | test('missing text', assertMessages( 65 | HtmlExtractors.elementAttribute('translate', 'text', { 66 | attributes: { 67 | context: 'context', 68 | textPlural: 'plural' 69 | } 70 | }), 71 | `` 72 | )); 73 | }); 74 | 75 | describe('just text', () => { 76 | 77 | test('with context', assertMessages( 78 | HtmlExtractors.elementAttribute('translate', 'text'), 79 | ``, 80 | { 81 | text: 'Foo' 82 | } 83 | )); 84 | 85 | test('plural', assertMessages( 86 | HtmlExtractors.elementAttribute('translate', 'text'), 87 | ``, 88 | { 89 | text: 'Foo' 90 | } 91 | )); 92 | 93 | test('plural with context', assertMessages( 94 | HtmlExtractors.elementAttribute('translate', 'text'), 95 | ``, 96 | { 97 | text: 'Foo' 98 | } 99 | )); 100 | }); 101 | 102 | describe('comment', () => { 103 | 104 | test('just text', assertMessages( 105 | HtmlExtractors.elementAttribute('translate', 'text', { 106 | attributes: { 107 | comment: 'comment' 108 | } 109 | }), 110 | ``, 111 | { 112 | text: 'Foo' 113 | } 114 | )); 115 | 116 | test('with comment', assertMessages( 117 | HtmlExtractors.elementAttribute('translate', 'text', { 118 | attributes: { 119 | comment: 'comment' 120 | } 121 | }), 122 | ``, 123 | { 124 | text: 'Foo', 125 | comments: [ 126 | 'Foo Bar' 127 | ] 128 | } 129 | )); 130 | 131 | test('empty comment', assertMessages( 132 | HtmlExtractors.elementAttribute('translate', 'text', { 133 | attributes: { 134 | comment: 'comment' 135 | } 136 | }), 137 | ``, 138 | { 139 | text: 'Foo' 140 | } 141 | )); 142 | }); 143 | 144 | describe('argument validation', () => { 145 | 146 | test('selector: (none)', () => { 147 | expect(() => { 148 | (HtmlExtractors.elementAttribute)(); 149 | }).toThrowError(`Missing argument 'selector'`); 150 | }); 151 | 152 | test('selector: null', () => { 153 | expect(() => { 154 | (HtmlExtractors.elementAttribute)(null); 155 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 156 | }); 157 | 158 | test('selector: wrong type', () => { 159 | expect(() => { 160 | (HtmlExtractors.elementAttribute)(42); 161 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 162 | }); 163 | 164 | test('textAttribute: (none)', () => { 165 | expect(() => { 166 | (HtmlExtractors.elementAttribute)('[translate]'); 167 | }).toThrowError(`Missing argument 'textAttribute'`); 168 | }); 169 | 170 | test('textAttribute: null', () => { 171 | expect(() => { 172 | (HtmlExtractors.elementAttribute)('[translate]', null); 173 | }).toThrowError(`Argument 'textAttribute' must be a non-empty string`); 174 | }); 175 | 176 | test('textAttribute: wrong type', () => { 177 | expect(() => { 178 | (HtmlExtractors.elementAttribute)('[translate]', 42); 179 | }).toThrowError(`Argument 'textAttribute' must be a non-empty string`); 180 | }); 181 | 182 | test('options: wrong type', () => { 183 | expect(() => { 184 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', 'foo'); 185 | }).toThrowError(`Argument 'options' must be an object`); 186 | }); 187 | 188 | test('options.attributes: wrong type', () => { 189 | expect(() => { 190 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 191 | attributes: 'foo' 192 | }); 193 | }).toThrowError(`Property 'options.attributes' must be an object`); 194 | }); 195 | 196 | test('options.attributes.textPlural: wrong type', () => { 197 | expect(() => { 198 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 199 | attributes: { 200 | textPlural: 42 201 | } 202 | }); 203 | }).toThrowError(`Property 'options.attributes.textPlural' must be a string`); 204 | }); 205 | 206 | test('options.attributes.context: wrong type', () => { 207 | expect(() => { 208 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 209 | attributes: { 210 | context: 42 211 | } 212 | }); 213 | }).toThrowError(`Property 'options.attributes.context' must be a string`); 214 | }); 215 | 216 | test('options.attributes.comment: wrong type', () => { 217 | expect(() => { 218 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 219 | attributes: { 220 | comment: 42 221 | } 222 | }); 223 | }).toThrowError(`Property 'options.attributes.comment' must be a string`); 224 | }); 225 | 226 | test('options.content: wrong type', () => { 227 | expect(() => { 228 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 229 | content: 'foo' 230 | }); 231 | }).toThrowError(`Property 'options.content' must be an object`); 232 | }); 233 | 234 | test('options.content.trimWhiteSpace: wrong type', () => { 235 | expect(() => { 236 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 237 | content: { 238 | trimWhiteSpace: 'foo' 239 | } 240 | }); 241 | }).toThrowError(`Property 'options.content.trimWhiteSpace' must be a boolean`); 242 | }); 243 | 244 | test('options.content.preserveIndentation: wrong type', () => { 245 | expect(() => { 246 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 247 | content: { 248 | preserveIndentation: 'foo' 249 | } 250 | }); 251 | }).toThrowError(`Property 'options.content.preserveIndentation' must be a boolean`); 252 | }); 253 | 254 | test('options.content.replaceNewLines: wrong type', () => { 255 | expect(() => { 256 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 257 | content: { 258 | replaceNewLines: 42 259 | } 260 | }); 261 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 262 | }); 263 | 264 | test('options.content.replaceNewLines: true', () => { 265 | expect(() => { 266 | (HtmlExtractors.elementAttribute)('[translate]', 'translate', { 267 | content: { 268 | replaceNewLines: true 269 | } 270 | }); 271 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 272 | }); 273 | }); 274 | 275 | function assertMessages(extractorFunction: IHtmlExtractorFunction, source: string, ...expected: Partial[]): () => void { 276 | return () => { 277 | let extractor = new GettextExtractor(); 278 | 279 | extractor.createHtmlParser([extractorFunction]).parseString(source); 280 | 281 | expect(extractor.getMessages()).toStrictEqual( 282 | expected.map(message => ({ 283 | textPlural: null, 284 | context: null, 285 | comments: [], 286 | references: [], 287 | ...message 288 | })) 289 | ); 290 | }; 291 | } 292 | -------------------------------------------------------------------------------- /tests/e2e/html/elementContent.test.ts: -------------------------------------------------------------------------------- 1 | import { GettextExtractor, HtmlExtractors } from '../../../dist'; 2 | import { IHtmlExtractorFunction } from '../../../dist/html/parser'; 3 | import { IMessage } from '../../../dist/builder'; 4 | import { trimIndent } from '../../indent'; 5 | 6 | describe('standard', () => { 7 | 8 | test('just text', assertMessages( 9 | HtmlExtractors.elementContent('translate', { 10 | attributes: { 11 | context: 'context', 12 | textPlural: 'plural' 13 | } 14 | }), 15 | `Foo`, 16 | { 17 | text: 'Foo' 18 | } 19 | )); 20 | 21 | test('with context', assertMessages( 22 | HtmlExtractors.elementContent('translate', { 23 | attributes: { 24 | context: 'context', 25 | textPlural: 'plural' 26 | } 27 | }), 28 | `Foo`, 29 | { 30 | text: 'Foo', 31 | context: 'Context' 32 | } 33 | )); 34 | 35 | test('plural', assertMessages( 36 | HtmlExtractors.elementContent('translate', { 37 | attributes: { 38 | context: 'context', 39 | textPlural: 'plural' 40 | } 41 | }), 42 | `Foo`, 43 | { 44 | text: 'Foo', 45 | textPlural: 'Foos' 46 | } 47 | )); 48 | 49 | test('plural with context', assertMessages( 50 | HtmlExtractors.elementContent('translate', { 51 | attributes: { 52 | context: 'context', 53 | textPlural: 'plural' 54 | } 55 | }), 56 | `Foo`, 57 | { 58 | text: 'Foo', 59 | textPlural: 'Foos', 60 | context: 'Context' 61 | } 62 | )); 63 | }); 64 | 65 | describe('just text', () => { 66 | 67 | test('with context', assertMessages( 68 | HtmlExtractors.elementContent('translate'), 69 | `Foo`, 70 | { 71 | text: 'Foo' 72 | } 73 | )); 74 | 75 | test('plural', assertMessages( 76 | HtmlExtractors.elementContent('translate'), 77 | `Foo`, 78 | { 79 | text: 'Foo' 80 | } 81 | )); 82 | 83 | test('plural with context', assertMessages( 84 | HtmlExtractors.elementContent('translate'), 85 | `Foo`, 86 | { 87 | text: 'Foo' 88 | } 89 | )); 90 | }); 91 | 92 | describe('comment', () => { 93 | 94 | test('just text', assertMessages( 95 | HtmlExtractors.elementContent('translate', { 96 | attributes: { 97 | comment: 'comment' 98 | } 99 | }), 100 | `Foo`, 101 | { 102 | text: 'Foo' 103 | } 104 | )); 105 | 106 | test('with comment', assertMessages( 107 | HtmlExtractors.elementContent('translate', { 108 | attributes: { 109 | comment: 'comment' 110 | } 111 | }), 112 | `Foo`, 113 | { 114 | text: 'Foo', 115 | comments: [ 116 | 'Foo Bar' 117 | ] 118 | } 119 | )); 120 | 121 | test('empty comment', assertMessages( 122 | HtmlExtractors.elementContent('translate', { 123 | attributes: { 124 | comment: 'comment' 125 | } 126 | }), 127 | `Foo`, 128 | { 129 | text: 'Foo' 130 | } 131 | )); 132 | }); 133 | 134 | describe('argument validation', () => { 135 | 136 | test('selector: (none)', () => { 137 | expect(() => { 138 | (HtmlExtractors.elementContent)(); 139 | }).toThrowError(`Missing argument 'selector'`); 140 | }); 141 | 142 | test('selector: null', () => { 143 | expect(() => { 144 | (HtmlExtractors.elementContent)(null); 145 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 146 | }); 147 | 148 | test('selector: wrong type', () => { 149 | expect(() => { 150 | (HtmlExtractors.elementContent)(42); 151 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 152 | }); 153 | 154 | test('options: wrong type', () => { 155 | expect(() => { 156 | (HtmlExtractors.elementContent)('[translate]', 'foo'); 157 | }).toThrowError(`Argument 'options' must be an object`); 158 | }); 159 | 160 | test('options.attributes: wrong type', () => { 161 | expect(() => { 162 | (HtmlExtractors.elementContent)('[translate]', { 163 | attributes: 'foo' 164 | }); 165 | }).toThrowError(`Property 'options.attributes' must be an object`); 166 | }); 167 | 168 | test('options.attributes.textPlural: wrong type', () => { 169 | expect(() => { 170 | (HtmlExtractors.elementContent)('[translate]', { 171 | attributes: { 172 | textPlural: 42 173 | } 174 | }); 175 | }).toThrowError(`Property 'options.attributes.textPlural' must be a string`); 176 | }); 177 | 178 | test('options.attributes.context: wrong type', () => { 179 | expect(() => { 180 | (HtmlExtractors.elementContent)('[translate]', { 181 | attributes: { 182 | context: 42 183 | } 184 | }); 185 | }).toThrowError(`Property 'options.attributes.context' must be a string`); 186 | }); 187 | 188 | test('options.attributes.comment: wrong type', () => { 189 | expect(() => { 190 | (HtmlExtractors.elementContent)('[translate]', { 191 | attributes: { 192 | comment: 42 193 | } 194 | }); 195 | }).toThrowError(`Property 'options.attributes.comment' must be a string`); 196 | }); 197 | 198 | test('options.content: wrong type', () => { 199 | expect(() => { 200 | (HtmlExtractors.elementContent)('[translate]', { 201 | content: 'foo' 202 | }); 203 | }).toThrowError(`Property 'options.content' must be an object`); 204 | }); 205 | 206 | test('options.content.trimWhiteSpace: wrong type', () => { 207 | expect(() => { 208 | (HtmlExtractors.elementContent)('[translate]', { 209 | content: { 210 | trimWhiteSpace: 'foo' 211 | } 212 | }); 213 | }).toThrowError(`Property 'options.content.trimWhiteSpace' must be a boolean`); 214 | }); 215 | 216 | test('options.content.preserveIndentation: wrong type', () => { 217 | expect(() => { 218 | (HtmlExtractors.elementContent)('[translate]', { 219 | content: { 220 | preserveIndentation: 'foo' 221 | } 222 | }); 223 | }).toThrowError(`Property 'options.content.preserveIndentation' must be a boolean`); 224 | }); 225 | 226 | test('options.content.replaceNewLines: wrong type', () => { 227 | expect(() => { 228 | (HtmlExtractors.elementContent)('[translate]', { 229 | content: { 230 | replaceNewLines: 42 231 | } 232 | }); 233 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 234 | }); 235 | 236 | test('options.content.replaceNewLines: true', () => { 237 | expect(() => { 238 | (HtmlExtractors.elementContent)('[translate]', { 239 | content: { 240 | replaceNewLines: true 241 | } 242 | }); 243 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 244 | }); 245 | }); 246 | 247 | function assertMessages(extractorFunction: IHtmlExtractorFunction, source: string, ...expected: Partial[]): () => void { 248 | return () => { 249 | let extractor = new GettextExtractor(); 250 | 251 | extractor.createHtmlParser([extractorFunction]).parseString(source); 252 | 253 | expect(extractor.getMessages()).toStrictEqual( 254 | expected.map(message => ({ 255 | textPlural: null, 256 | context: null, 257 | comments: [], 258 | references: [], 259 | ...message 260 | })) 261 | ); 262 | }; 263 | } 264 | -------------------------------------------------------------------------------- /tests/e2e/html/embeddedJs.test.ts: -------------------------------------------------------------------------------- 1 | import { HtmlExtractors } from '../../../dist'; 2 | 3 | describe('argument validation', () => { 4 | 5 | test('selector: (none)', () => { 6 | expect(() => { 7 | (HtmlExtractors.embeddedJs)(); 8 | }).toThrowError(`Missing argument 'selector'`); 9 | }); 10 | 11 | test('selector: null', () => { 12 | expect(() => { 13 | (HtmlExtractors.embeddedJs)(null); 14 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 15 | }); 16 | 17 | test('selector: wrong type', () => { 18 | expect(() => { 19 | (HtmlExtractors.embeddedJs)(42); 20 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 21 | }); 22 | 23 | test('jsParser: (none)', () => { 24 | expect(() => { 25 | (HtmlExtractors.embeddedJs)('script'); 26 | }).toThrowError(`Missing argument 'jsParser'`); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/e2e/js.test.ts: -------------------------------------------------------------------------------- 1 | import { GettextExtractor, JsExtractors } from '../../dist'; 2 | import * as fs from 'fs'; 3 | 4 | describe('JavaScript E2E', () => { 5 | 6 | test('example', () => { 7 | let extractor = new GettextExtractor(); 8 | 9 | extractor 10 | .createJsParser([ 11 | JsExtractors.callExpression(['t', '[this].translations.get'], { 12 | arguments: { 13 | text: 0, 14 | context: 1 15 | } 16 | }), 17 | JsExtractors.callExpression('[this].translations.plural', { 18 | arguments: { 19 | text: 1, 20 | textPlural: 2, 21 | context: 3 22 | } 23 | }) 24 | ]) 25 | .parseFilesGlob('tests/e2e/fixtures/js/**/*.@(js|jsx)'); 26 | 27 | expect(extractor.getPotString()).toBe(fs.readFileSync(__dirname + '/fixtures/js/example.expected.pot').toString()); 28 | }); 29 | 30 | test('multi-line', () => { 31 | let extractor = new GettextExtractor(); 32 | 33 | extractor 34 | .createJsParser([ 35 | JsExtractors.callExpression(['translate'], { 36 | arguments: { 37 | text: 0 38 | } 39 | }), 40 | JsExtractors.callExpression(['translate_trim'], { 41 | arguments: { 42 | text: 0 43 | }, 44 | content: { 45 | trimWhiteSpace: true, 46 | preserveIndentation: false 47 | } 48 | }) 49 | ]) 50 | .parseFilesGlob('tests/e2e/fixtures/js/**/*.js'); 51 | 52 | expect(extractor.getPotString()).toBe(fs.readFileSync(__dirname + '/fixtures/js/multiline.expected.pot').toString()); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/extractor.test.ts: -------------------------------------------------------------------------------- 1 | import { GettextExtractor } from '../src/extractor'; 2 | 3 | describe('GettextExtractor', () => { 4 | 5 | let extractor: GettextExtractor; 6 | 7 | beforeEach(() => { 8 | extractor = new GettextExtractor(); 9 | }); 10 | 11 | describe('POT string', () => { 12 | 13 | test('default headers', () => { 14 | let pot = extractor.getPotString(); 15 | expect(pot).toBe( 16 | `msgid ""\n` + 17 | `msgstr ""\n` + 18 | `"Content-Type: text/plain; charset=UTF-8\\n"\n` 19 | ); 20 | }); 21 | 22 | test('additional headers', () => { 23 | let pot = extractor.getPotString({ 24 | 'Project-Id-Version': 'Foo' 25 | }); 26 | expect(pot).toBe( 27 | `msgid ""\n` + 28 | `msgstr ""\n` + 29 | `"Content-Type: text/plain; charset=UTF-8\\n"\n` + 30 | `"Project-Id-Version: Foo\\n"\n` 31 | ); 32 | }); 33 | 34 | test('overridden content type header', () => { 35 | let pot = extractor.getPotString({ 36 | 'Content-Type': 'text/plain; charset=ISO-8859-1' 37 | }); 38 | expect(pot).toBe( 39 | `msgid ""\n` + 40 | `msgstr ""\n` + 41 | `"Content-Type: text/plain; charset=ISO-8859-1\\n"\n` 42 | ); 43 | }); 44 | }); 45 | 46 | describe('argument validation', () => { 47 | 48 | describe('addMessage', () => { 49 | 50 | test('message: (none)', () => { 51 | expect(() => { 52 | (extractor.addMessage)(); 53 | }).toThrowError(`Missing argument 'message'`); 54 | }); 55 | 56 | test('message: null', () => { 57 | expect(() => { 58 | (extractor.addMessage)(null); 59 | }).toThrowError(`Argument 'message' must be an object`); 60 | }); 61 | 62 | test('message: wrong type', () => { 63 | expect(() => { 64 | (extractor.addMessage)(42); 65 | }).toThrowError(`Argument 'message' must be an object`); 66 | }); 67 | 68 | test('message.text: (none)', () => { 69 | expect(() => { 70 | (extractor.addMessage)({}); 71 | }).toThrowError(`Property 'message.text' is missing`); 72 | }); 73 | 74 | test('message.text: null', () => { 75 | expect(() => { 76 | (extractor.addMessage)({ 77 | text: null 78 | }); 79 | }).toThrowError(`Property 'message.text' must be a string`); 80 | }); 81 | 82 | test('message.text: wrong type', () => { 83 | expect(() => { 84 | (extractor.addMessage)({ 85 | text: 42 86 | }); 87 | }).toThrowError(`Property 'message.text' must be a string`); 88 | }); 89 | 90 | test('message: only required', () => { 91 | expect(() => { 92 | (extractor.addMessage)({ 93 | text: 'Foo' 94 | }); 95 | }).not.toThrow(); 96 | }); 97 | 98 | test('message.textPlural: null', () => { 99 | expect(() => { 100 | (extractor.addMessage)({ 101 | text: 'Foo', 102 | textPlural: null 103 | }); 104 | }).toThrowError(`Property 'message.textPlural' must be a string`); 105 | }); 106 | 107 | test('message.textPlural: wrong type', () => { 108 | expect(() => { 109 | (extractor.addMessage)({ 110 | text: 'Foo', 111 | textPlural: 42 112 | }); 113 | }).toThrowError(`Property 'message.textPlural' must be a string`); 114 | }); 115 | 116 | test('message.context: null', () => { 117 | expect(() => { 118 | (extractor.addMessage)({ 119 | text: 'Foo', 120 | context: null 121 | }); 122 | }).toThrowError(`Property 'message.context' must be a string`); 123 | }); 124 | 125 | test('message.context: wrong type', () => { 126 | expect(() => { 127 | (extractor.addMessage)({ 128 | text: 'Foo', 129 | context: 42 130 | }); 131 | }).toThrowError(`Property 'message.context' must be a string`); 132 | }); 133 | 134 | test('message.references: null', () => { 135 | expect(() => { 136 | (extractor.addMessage)({ 137 | text: 'Foo', 138 | references: null 139 | }); 140 | }).toThrowError(`Property 'message.references' must be an array`); 141 | }); 142 | 143 | test('message.references: wrong type', () => { 144 | expect(() => { 145 | (extractor.addMessage)({ 146 | text: 'Foo', 147 | references: 42 148 | }); 149 | }).toThrowError(`Property 'message.references' must be an array`); 150 | }); 151 | 152 | test('message.comments: null', () => { 153 | expect(() => { 154 | (extractor.addMessage)({ 155 | text: 'Foo', 156 | comments: null 157 | }); 158 | }).toThrowError(`Property 'message.comments' must be an array`); 159 | }); 160 | 161 | test('message.comments: wrong type', () => { 162 | expect(() => { 163 | (extractor.addMessage)({ 164 | text: 'Foo', 165 | comments: 42 166 | }); 167 | }).toThrowError(`Property 'message.comments' must be an array`); 168 | }); 169 | }); 170 | 171 | describe('getPotString', () => { 172 | 173 | test('headers: wrong type', () => { 174 | expect(() => { 175 | (extractor.getPotString)('foo'); 176 | }).toThrowError(`Argument 'headers' must be an object`); 177 | }); 178 | }); 179 | 180 | describe('savePotFile', () => { 181 | 182 | test('fileName: (none)', () => { 183 | expect(() => { 184 | (extractor.savePotFile)(); 185 | }).toThrowError(`Missing argument 'fileName'`); 186 | }); 187 | 188 | test('fileName: null', () => { 189 | expect(() => { 190 | (extractor.savePotFile)(null); 191 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 192 | }); 193 | 194 | test('fileName: wrong type', () => { 195 | expect(() => { 196 | (extractor.savePotFile)(42); 197 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 198 | }); 199 | 200 | test('headers: wrong type', () => { 201 | expect(() => { 202 | (extractor.savePotFile)('foo.ts', 'foo'); 203 | }).toThrowError(`Argument 'headers' must be an object`); 204 | }); 205 | }); 206 | 207 | describe('savePotFileAsync', () => { 208 | 209 | test('fileName: (none)', () => { 210 | expect(() => { 211 | (extractor.savePotFileAsync)(); 212 | }).toThrowError(`Missing argument 'fileName'`); 213 | }); 214 | 215 | test('fileName: null', () => { 216 | expect(() => { 217 | (extractor.savePotFileAsync)(null); 218 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 219 | }); 220 | 221 | test('fileName: wrong type', () => { 222 | expect(() => { 223 | (extractor.savePotFileAsync)(42); 224 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 225 | }); 226 | 227 | test('headers: wrong type', () => { 228 | expect(() => { 229 | (extractor.savePotFileAsync)('foo.ts', 'foo'); 230 | }).toThrowError(`Argument 'headers' must be an object`); 231 | }); 232 | }); 233 | 234 | describe('createJsParser', () => { 235 | 236 | test('extractors: (none)', () => { 237 | expect(() => { 238 | (extractor.createJsParser)(); 239 | }).not.toThrow(); 240 | }); 241 | 242 | test('extractors: null', () => { 243 | expect(() => { 244 | (extractor.createJsParser)(null); 245 | }).toThrowError(`Argument 'extractors' must be a non-empty array`); 246 | }); 247 | 248 | test('extractors: wrong type', () => { 249 | expect(() => { 250 | (extractor.createJsParser)(42); 251 | }).toThrowError(`Argument 'extractors' must be a non-empty array`); 252 | }); 253 | 254 | test('extractors: []', () => { 255 | expect(() => { 256 | (extractor.createJsParser)([]); 257 | }).toThrowError(`Argument 'extractors' must be a non-empty array`); 258 | }); 259 | 260 | test('extractors: [(none)]', () => { 261 | expect(() => { 262 | (extractor.createJsParser)([ 263 | undefined 264 | ]); 265 | }).toThrowError(`Invalid extractor function provided. 'undefined' is not a function`); 266 | }); 267 | 268 | test('extractors: [null]', () => { 269 | expect(() => { 270 | (extractor.createJsParser)([ 271 | null 272 | ]); 273 | }).toThrowError(`Invalid extractor function provided. 'null' is not a function`); 274 | }); 275 | 276 | test('extractors: [wrong type]', () => { 277 | expect(() => { 278 | (extractor.createJsParser)([ 279 | 42 280 | ]); 281 | }).toThrowError(`Invalid extractor function provided. '42' is not a function`); 282 | }); 283 | 284 | test('extractors: [function, wrong type]', () => { 285 | expect(() => { 286 | (extractor.createJsParser)([ 287 | () => {}, 288 | 42 289 | ]); 290 | }).toThrowError(`Invalid extractor function provided. '42' is not a function`); 291 | }); 292 | }); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /tests/fixtures/directory.ts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukasgeiter/gettext-extractor/1688fa8b6880cb82501879cf6e95cd52db47aaa4/tests/fixtures/directory.ts/.gitkeep -------------------------------------------------------------------------------- /tests/fixtures/empty.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukasgeiter/gettext-extractor/1688fa8b6880cb82501879cf6e95cd52db47aaa4/tests/fixtures/empty.ts -------------------------------------------------------------------------------- /tests/fixtures/unicode.ts: -------------------------------------------------------------------------------- 1 | // Source: http://www.cl.cam.ac.uk/~mgk25/ucs/examples/quickbrown.txt 2 | 3 | export const UnicodeSamples = { 4 | danish: `Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen Wolther spillede på xylofon`, 5 | german: `Falsches Üben von Xylophonmusik quält jeden größeren Zwerg - Zwölf Boxkämpfer jagten Eva quer über den Sylter Deich - Heizölrückstoßabdämpfung`, 6 | greek: `Γαζέες καὶ μυρτιὲς δὲν θὰ βρῶ πιὰ στὸ χρυσαφὶ ξέφωτο - Ξεσκεπάζω τὴν ψυχοφθόρα βδελυγμία`, 7 | english: `The quick brown fox jumps over the lazy dog`, 8 | spanish: `El pingüino Wenceslao hizo kilómetros bajo exhaustiva lluvia y frío, añoraba a su querido cachorro`, 9 | french: `Portez ce vieux whisky au juge blond qui fume sur son île intérieure, à côté de l'alcôve ovoïde, où les bûches se consument dans l'âtre, ce qui lui permet de penser à la cænogenèse de l'être dont il est question dans la cause ambiguë entendue à Moÿ, dans un capharnaüm qui, pense-t-il, diminue çà et là la qualité de son œuvre - Le cœur déçu mais l'âme plutôt naïve, Louÿs rêva de crapaüter en canoë au delà des îles, près du mälström où brûlent les novæ`, 10 | irishGaelic: `D'fhuascail Íosa, Úrmhac na hÓighe Beannaithe, pór Éava agus Ádhaimh`, 11 | hungarian: `Árvíztűrő tükörfúrógép`, 12 | icelandic: `Kæmi ný öxi hér ykist þjófum nú bæði víl og ádrepa - Sævör grét áðan því úlpan var ónýt`, 13 | japanese: `いろはにほへとちりぬるを わかよたれそつねならむ うゐのおくやまけふこえて あさきゆめみしゑひもせす - イロハニホヘト チリヌルヲ ワカヨタレソ ツネナラム ウヰノオクヤマ ケフコエテ アサキユメミシ ヱヒモセスン`, 14 | hebrew: `דג סקרן שט בים מאוכזב ולפתע מצא לו חברה איך הקליטה`, 15 | polish: `Pchnąć w tę łódź jeża lub ośm skrzyń fig`, 16 | russian: `В чащах юга жил бы цитрус? Да, но фальшивый экземпляр! - Съешь же ещё этих мягких французских булок да выпей чаю`, 17 | thai: `๏ เป็นมนุษย์สุดประเสริฐเลิศคุณค่า กว่าบรรดาฝูงสัตว์เดรัจฉาน จงฝ่าฟันพัฒนาวิชาการ อย่าล้างผลาญฤๅเข่นฆ่าบีฑาใคร ไม่ถือโทษโกรธแช่งซัดฮึดฮัดด่า หัดอภัยเหมือนกีฬาอัชฌาสัย ปฏิบัติประพฤติกฎกำหนดใจ พูดจาให้จ๊ะๆ จ๋าๆ น่าฟังเอย ฯ`, 18 | turkish: `Pijamalı hasta, yağız şoföre çabucak güvendi` 19 | }; 20 | 21 | export function createUnicodeTests(callback: (text: string) => void): void { 22 | for (let [language, text] of Object.entries(UnicodeSamples)) { 23 | test(language, () => { 24 | callback(text); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/html/extractors/factories/elementAttribute.test.ts: -------------------------------------------------------------------------------- 1 | import { HtmlParser } from '../../../../src/html/parser'; 2 | import { CatalogBuilder, IMessage } from '../../../../src/builder'; 3 | import { elementAttributeExtractor } from '../../../../src/html/extractors/factories/elementAttribute'; 4 | import { elementContentExtractor } from '../../../../src/html/extractors/factories/elementContent'; 5 | 6 | describe('HTML: Element Attribute Extractor', () => { 7 | 8 | let builder: CatalogBuilder, 9 | messages: IMessage[], 10 | parser: HtmlParser; 11 | 12 | beforeEach(() => { 13 | messages = []; 14 | 15 | builder = { 16 | addMessage: jest.fn((message: IMessage) => { 17 | messages.push(message); 18 | }) 19 | }; 20 | }); 21 | 22 | describe('standard', () => { 23 | 24 | beforeEach(() => { 25 | parser = new HtmlParser(builder, [ 26 | elementAttributeExtractor('translate', 'text', { 27 | attributes: { 28 | context: 'context', 29 | textPlural: 'plural' 30 | } 31 | }) 32 | ]); 33 | }); 34 | 35 | test('just text', () => { 36 | parser.parseString(``); 37 | 38 | expect(messages).toEqual([ 39 | { 40 | text: 'Foo' 41 | } 42 | ]); 43 | }); 44 | 45 | test('with context', () => { 46 | parser.parseString(``); 47 | 48 | expect(messages).toEqual([ 49 | { 50 | text: 'Foo', 51 | context: 'Context' 52 | } 53 | ]); 54 | }); 55 | 56 | test('plural', () => { 57 | parser.parseString(``); 58 | 59 | expect(messages).toEqual([ 60 | { 61 | text: 'Foo', 62 | textPlural: 'Foos' 63 | } 64 | ]); 65 | }); 66 | 67 | test('plural with context', () => { 68 | parser.parseString(``); 69 | 70 | expect(messages).toEqual([ 71 | { 72 | text: 'Foo', 73 | textPlural: 'Foos', 74 | context: 'Context' 75 | } 76 | ]); 77 | }); 78 | 79 | test('missing text', () => { 80 | parser.parseString(``); 81 | 82 | expect(messages).toEqual([]); 83 | }); 84 | }); 85 | 86 | describe('just text', () => { 87 | 88 | beforeEach(() => { 89 | parser = new HtmlParser(builder, [ 90 | elementAttributeExtractor('translate', 'text') 91 | ]); 92 | }); 93 | 94 | test('with context', () => { 95 | parser.parseString(``); 96 | 97 | expect(messages).toEqual([ 98 | { 99 | text: 'Foo' 100 | } 101 | ]); 102 | }); 103 | 104 | test('plural', () => { 105 | parser.parseString(``); 106 | 107 | expect(messages).toEqual([ 108 | { 109 | text: 'Foo' 110 | } 111 | ]); 112 | }); 113 | 114 | test('plural with context', () => { 115 | parser.parseString(``); 116 | 117 | expect(messages).toEqual([ 118 | { 119 | text: 'Foo' 120 | } 121 | ]); 122 | }); 123 | }); 124 | 125 | describe('comment', () => { 126 | 127 | beforeEach(() => { 128 | parser = new HtmlParser(builder, [ 129 | elementAttributeExtractor('translate', 'text', { 130 | attributes: { 131 | comment: 'comment' 132 | } 133 | }) 134 | ]); 135 | }); 136 | 137 | test('just text', () => { 138 | parser.parseString(``); 139 | 140 | expect(messages).toEqual([ 141 | { 142 | text: 'Foo' 143 | } 144 | ]); 145 | }); 146 | 147 | test('with comment', () => { 148 | parser.parseString(``); 149 | 150 | expect(messages).toEqual([ 151 | { 152 | text: 'Foo', 153 | comments: [ 154 | 'Foo Bar' 155 | ] 156 | } 157 | ]); 158 | }); 159 | 160 | test('empty comment', () => { 161 | parser.parseString(``); 162 | 163 | expect(messages).toEqual([ 164 | { 165 | text: 'Foo' 166 | } 167 | ]); 168 | }); 169 | }); 170 | 171 | describe('argument validation', () => { 172 | 173 | test('selector: (none)', () => { 174 | expect(() => { 175 | (elementAttributeExtractor)(); 176 | }).toThrowError(`Missing argument 'selector'`); 177 | }); 178 | 179 | test('selector: null', () => { 180 | expect(() => { 181 | (elementAttributeExtractor)(null); 182 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 183 | }); 184 | 185 | test('selector: wrong type', () => { 186 | expect(() => { 187 | (elementAttributeExtractor)(42); 188 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 189 | }); 190 | 191 | test('textAttribute: (none)', () => { 192 | expect(() => { 193 | (elementAttributeExtractor)('[translate]'); 194 | }).toThrowError(`Missing argument 'textAttribute'`); 195 | }); 196 | 197 | test('textAttribute: null', () => { 198 | expect(() => { 199 | (elementAttributeExtractor)('[translate]', null); 200 | }).toThrowError(`Argument 'textAttribute' must be a non-empty string`); 201 | }); 202 | 203 | test('textAttribute: wrong type', () => { 204 | expect(() => { 205 | (elementAttributeExtractor)('[translate]', 42); 206 | }).toThrowError(`Argument 'textAttribute' must be a non-empty string`); 207 | }); 208 | 209 | test('options: wrong type', () => { 210 | expect(() => { 211 | (elementAttributeExtractor)('[translate]', 'translate', 'foo'); 212 | }).toThrowError(`Argument 'options' must be an object`); 213 | }); 214 | 215 | test('options.attributes: wrong type', () => { 216 | expect(() => { 217 | (elementAttributeExtractor)('[translate]', 'translate', { 218 | attributes: 'foo' 219 | }); 220 | }).toThrowError(`Property 'options.attributes' must be an object`); 221 | }); 222 | 223 | test('options.attributes.textPlural: wrong type', () => { 224 | expect(() => { 225 | (elementAttributeExtractor)('[translate]', 'translate', { 226 | attributes: { 227 | textPlural: 42 228 | } 229 | }); 230 | }).toThrowError(`Property 'options.attributes.textPlural' must be a string`); 231 | }); 232 | 233 | test('options.attributes.context: wrong type', () => { 234 | expect(() => { 235 | (elementAttributeExtractor)('[translate]', 'translate', { 236 | attributes: { 237 | context: 42 238 | } 239 | }); 240 | }).toThrowError(`Property 'options.attributes.context' must be a string`); 241 | }); 242 | 243 | test('options.attributes.comment: wrong type', () => { 244 | expect(() => { 245 | (elementAttributeExtractor)('[translate]', 'translate', { 246 | attributes: { 247 | comment: 42 248 | } 249 | }); 250 | }).toThrowError(`Property 'options.attributes.comment' must be a string`); 251 | }); 252 | 253 | test('options.content: wrong type', () => { 254 | expect(() => { 255 | (elementContentExtractor)('[translate]', { 256 | content: 'foo' 257 | }); 258 | }).toThrowError(`Property 'options.content' must be an object`); 259 | }); 260 | 261 | test('options.content.trimWhiteSpace: wrong type', () => { 262 | expect(() => { 263 | (elementContentExtractor)('[translate]', { 264 | content: { 265 | trimWhiteSpace: 'foo' 266 | } 267 | }); 268 | }).toThrowError(`Property 'options.content.trimWhiteSpace' must be a boolean`); 269 | }); 270 | 271 | test('options.content.preserveIndentation: wrong type', () => { 272 | expect(() => { 273 | (elementContentExtractor)('[translate]', { 274 | content: { 275 | preserveIndentation: 'foo' 276 | } 277 | }); 278 | }).toThrowError(`Property 'options.content.preserveIndentation' must be a boolean`); 279 | }); 280 | 281 | test('options.content.replaceNewLines: wrong type', () => { 282 | expect(() => { 283 | (elementContentExtractor)('[translate]', { 284 | content: { 285 | replaceNewLines: 42 286 | } 287 | }); 288 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 289 | }); 290 | 291 | test('options.content.replaceNewLines: true', () => { 292 | expect(() => { 293 | (elementContentExtractor)('[translate]', { 294 | content: { 295 | replaceNewLines: true 296 | } 297 | }); 298 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 299 | }); 300 | }); 301 | 302 | describe('argument proxying', () => { 303 | test('options.content.options: applies for all attributes', () => { 304 | parser = new HtmlParser(builder, [ 305 | elementAttributeExtractor('translate', 'text', { 306 | attributes: { 307 | textPlural: 'plural', 308 | context: 'context', 309 | comment: 'comment' 310 | }, 311 | content: { 312 | preserveIndentation: false, 313 | replaceNewLines: '', 314 | trimWhiteSpace: true 315 | } 316 | }) 317 | ]); 318 | 319 | parser.parseString(` 320 | 333 | `); 334 | 335 | expect(messages).toEqual([ 336 | { 337 | text: 'Foo', 338 | textPlural: 'Foos', 339 | context: 'Context', 340 | comments: ['Comment'] 341 | } 342 | ]); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /tests/html/extractors/factories/elementContent.test.ts: -------------------------------------------------------------------------------- 1 | import { HtmlParser } from '../../../../src/html/parser'; 2 | import { CatalogBuilder, IMessage } from '../../../../src/builder'; 3 | import { elementContentExtractor } from '../../../../src/html/extractors/factories/elementContent'; 4 | 5 | describe('HTML: Element Content Extractor', () => { 6 | 7 | let builder: CatalogBuilder, 8 | messages: IMessage[], 9 | parser: HtmlParser; 10 | 11 | beforeEach(() => { 12 | messages = []; 13 | 14 | builder = { 15 | addMessage: jest.fn((message: IMessage) => { 16 | messages.push(message); 17 | }) 18 | }; 19 | }); 20 | 21 | describe('standard', () => { 22 | 23 | beforeEach(() => { 24 | parser = new HtmlParser(builder, [ 25 | elementContentExtractor('translate', { 26 | attributes: { 27 | context: 'context', 28 | textPlural: 'plural' 29 | } 30 | }) 31 | ]); 32 | }); 33 | 34 | test('just text', () => { 35 | parser.parseString(`Foo`); 36 | 37 | expect(messages).toEqual([ 38 | { 39 | text: 'Foo' 40 | } 41 | ]); 42 | }); 43 | 44 | test('with context', () => { 45 | parser.parseString(`Foo`); 46 | 47 | expect(messages).toEqual([ 48 | { 49 | text: 'Foo', 50 | context: 'Context' 51 | } 52 | ]); 53 | }); 54 | 55 | test('plural', () => { 56 | parser.parseString(`Foo`); 57 | 58 | expect(messages).toEqual([ 59 | { 60 | text: 'Foo', 61 | textPlural: 'Foos' 62 | } 63 | ]); 64 | }); 65 | 66 | test('plural with context', () => { 67 | parser.parseString(`Foo`); 68 | 69 | expect(messages).toEqual([ 70 | { 71 | text: 'Foo', 72 | textPlural: 'Foos', 73 | context: 'Context' 74 | } 75 | ]); 76 | }); 77 | }); 78 | 79 | describe('just text', () => { 80 | 81 | beforeEach(() => { 82 | parser = new HtmlParser(builder, [ 83 | elementContentExtractor('translate') 84 | ]); 85 | }); 86 | 87 | test('with context', () => { 88 | parser.parseString(`Foo`); 89 | 90 | expect(messages).toEqual([ 91 | { 92 | text: 'Foo' 93 | } 94 | ]); 95 | }); 96 | 97 | test('plural', () => { 98 | parser.parseString(`Foo`); 99 | 100 | expect(messages).toEqual([ 101 | { 102 | text: 'Foo' 103 | } 104 | ]); 105 | }); 106 | 107 | test('plural with context', () => { 108 | parser.parseString(`Foo`); 109 | 110 | expect(messages).toEqual([ 111 | { 112 | text: 'Foo' 113 | } 114 | ]); 115 | }); 116 | }); 117 | 118 | describe('comment', () => { 119 | 120 | beforeEach(() => { 121 | parser = new HtmlParser(builder, [ 122 | elementContentExtractor('translate', { 123 | attributes: { 124 | comment: 'comment' 125 | } 126 | }) 127 | ]); 128 | }); 129 | 130 | test('just text', () => { 131 | parser.parseString(`Foo`); 132 | 133 | expect(messages).toEqual([ 134 | { 135 | text: 'Foo' 136 | } 137 | ]); 138 | }); 139 | 140 | test('with comment', () => { 141 | parser.parseString(`Foo`); 142 | 143 | expect(messages).toEqual([ 144 | { 145 | text: 'Foo', 146 | comments: [ 147 | 'Foo Bar' 148 | ] 149 | } 150 | ]); 151 | }); 152 | 153 | test('empty comment', () => { 154 | parser.parseString(`Foo`); 155 | 156 | expect(messages).toEqual([ 157 | { 158 | text: 'Foo' 159 | } 160 | ]); 161 | }); 162 | }); 163 | 164 | describe('argument validation', () => { 165 | 166 | test('selector: (none)', () => { 167 | expect(() => { 168 | (elementContentExtractor)(); 169 | }).toThrowError(`Missing argument 'selector'`); 170 | }); 171 | 172 | test('selector: null', () => { 173 | expect(() => { 174 | (elementContentExtractor)(null); 175 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 176 | }); 177 | 178 | test('selector: wrong type', () => { 179 | expect(() => { 180 | (elementContentExtractor)(42); 181 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 182 | }); 183 | 184 | test('options: wrong type', () => { 185 | expect(() => { 186 | (elementContentExtractor)('[translate]', 'foo'); 187 | }).toThrowError(`Argument 'options' must be an object`); 188 | }); 189 | 190 | test('options.attributes: wrong type', () => { 191 | expect(() => { 192 | (elementContentExtractor)('[translate]', { 193 | attributes: 'foo' 194 | }); 195 | }).toThrowError(`Property 'options.attributes' must be an object`); 196 | }); 197 | 198 | test('options.attributes.textPlural: wrong type', () => { 199 | expect(() => { 200 | (elementContentExtractor)('[translate]', { 201 | attributes: { 202 | textPlural: 42 203 | } 204 | }); 205 | }).toThrowError(`Property 'options.attributes.textPlural' must be a string`); 206 | }); 207 | 208 | test('options.attributes.context: wrong type', () => { 209 | expect(() => { 210 | (elementContentExtractor)('[translate]', { 211 | attributes: { 212 | context: 42 213 | } 214 | }); 215 | }).toThrowError(`Property 'options.attributes.context' must be a string`); 216 | }); 217 | 218 | test('options.attributes.comment: wrong type', () => { 219 | expect(() => { 220 | (elementContentExtractor)('[translate]', { 221 | attributes: { 222 | comment: 42 223 | } 224 | }); 225 | }).toThrowError(`Property 'options.attributes.comment' must be a string`); 226 | }); 227 | 228 | test('options.content: wrong type', () => { 229 | expect(() => { 230 | (elementContentExtractor)('[translate]', { 231 | content: 'foo' 232 | }); 233 | }).toThrowError(`Property 'options.content' must be an object`); 234 | }); 235 | 236 | test('options.content.trimWhiteSpace: wrong type', () => { 237 | expect(() => { 238 | (elementContentExtractor)('[translate]', { 239 | content: { 240 | trimWhiteSpace: 'foo' 241 | } 242 | }); 243 | }).toThrowError(`Property 'options.content.trimWhiteSpace' must be a boolean`); 244 | }); 245 | 246 | test('options.content.preserveIndentation: wrong type', () => { 247 | expect(() => { 248 | (elementContentExtractor)('[translate]', { 249 | content: { 250 | preserveIndentation: 'foo' 251 | } 252 | }); 253 | }).toThrowError(`Property 'options.content.preserveIndentation' must be a boolean`); 254 | }); 255 | 256 | test('options.content.replaceNewLines: wrong type', () => { 257 | expect(() => { 258 | (elementContentExtractor)('[translate]', { 259 | content: { 260 | replaceNewLines: 42 261 | } 262 | }); 263 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 264 | }); 265 | 266 | test('options.content.replaceNewLines: true', () => { 267 | expect(() => { 268 | (elementContentExtractor)('[translate]', { 269 | content: { 270 | replaceNewLines: true 271 | } 272 | }); 273 | }).toThrowError(`Property 'options.content.replaceNewLines' must be false or a string`); 274 | }); 275 | }); 276 | 277 | describe('argument proxying', () => { 278 | test('options.content.options: applies for all attributes', () => { 279 | parser = new HtmlParser(builder, [ 280 | elementContentExtractor('translate', { 281 | attributes: { 282 | textPlural: 'plural', 283 | context: 'context', 284 | comment: 'comment' 285 | }, 286 | content: { 287 | preserveIndentation: false, 288 | replaceNewLines: '', 289 | trimWhiteSpace: true 290 | } 291 | }) 292 | ]); 293 | 294 | parser.parseString(` 295 | 305 | Foo 306 | 307 | `); 308 | 309 | expect(messages).toEqual([ 310 | { 311 | text: 'Foo', 312 | textPlural: 'Foos', 313 | context: 'Context', 314 | comments: ['Comment'] 315 | } 316 | ]); 317 | }); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /tests/html/extractors/factories/embeddedAttributeJs.test.ts: -------------------------------------------------------------------------------- 1 | import { embeddedAttributeJsExtractor } from '../../../../src/html/extractors/factories/embeddedAttributeJs'; 2 | import { HtmlParser } from '../../../../src/html/parser'; 3 | import { JsParser } from '../../../../src/js/parser'; 4 | 5 | describe('HTML: Attribute Value as Embedded JS Extractor', () => { 6 | describe('calling js parser', () => { 7 | let jsParserMock: JsParser; 8 | 9 | beforeEach(() => { 10 | jsParserMock = { 11 | parseString: jest.fn(), 12 | }; 13 | }); 14 | 15 | test('use regex filter / with line number start', () => { 16 | const htmlParser = new HtmlParser(undefined!, [ 17 | embeddedAttributeJsExtractor(/:title/, jsParserMock), 18 | ]); 19 | htmlParser.parseString( 20 | `content`, 21 | 'foo.html', 22 | { lineNumberStart: 10 } 23 | ); 24 | expect(jsParserMock.parseString).toHaveBeenCalledWith( 25 | `__('msg id')`, 26 | 'foo.html', 27 | { 28 | lineNumberStart: 10, 29 | } 30 | ); 31 | }); 32 | 33 | test('use filter function', () => { 34 | const htmlParser = new HtmlParser(undefined!, [ 35 | embeddedAttributeJsExtractor((e) => { 36 | return e.name.startsWith(':'); 37 | }, jsParserMock), 38 | ]); 39 | htmlParser.parseString( 40 | `Hello`, 41 | 'foo.html' 42 | ); 43 | expect(jsParserMock.parseString).toHaveBeenCalledWith( 44 | `__('title')`, 45 | 'foo.html', 46 | { lineNumberStart: 1 } 47 | ); 48 | }); 49 | }); 50 | 51 | describe('argument validation', () => { 52 | test('filter: (none)', () => { 53 | expect(() => { 54 | (embeddedAttributeJsExtractor)(); 55 | }).toThrowError(`Missing argument 'filter'`); 56 | }); 57 | test('jsParser: (none)', () => { 58 | expect(() => { 59 | (embeddedAttributeJsExtractor)(/:title/); 60 | }).toThrowError(`Missing argument 'jsParser'`); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/html/extractors/factories/embeddedJs.test.ts: -------------------------------------------------------------------------------- 1 | import { HtmlParser } from '../../../../src/html/parser'; 2 | import { embeddedJsExtractor } from '../../../../src/html/extractors/factories/embeddedJs'; 3 | import { JsParser } from '../../../../src/js/parser'; 4 | 5 | describe('HTML: Embedded JS Extractor', () => { 6 | 7 | describe('calling js parser', () => { 8 | 9 | let htmlParser: HtmlParser, 10 | jsParserMock: JsParser; 11 | 12 | beforeEach(() => { 13 | jsParserMock = { 14 | parseString: jest.fn() 15 | }; 16 | 17 | htmlParser = new HtmlParser(undefined!, [ 18 | embeddedJsExtractor('script', jsParserMock) 19 | ]); 20 | }); 21 | 22 | test('single line', () => { 23 | htmlParser.parseString(``, 'foo.html'); 24 | 25 | expect(jsParserMock.parseString).toHaveBeenCalledWith('Foo', 'foo.html', { 26 | lineNumberStart: 1 27 | }); 28 | }); 29 | 30 | test('with lineNumberStart option', () => { 31 | htmlParser.parseString(``, 'foo.html', { lineNumberStart: 10 }); 32 | 33 | expect(jsParserMock.parseString).toHaveBeenCalledWith('Foo', 'foo.html', { 34 | lineNumberStart: 10 35 | }); 36 | }); 37 | 38 | test('separate line', () => { 39 | htmlParser.parseString(``, 'foo.html'); 40 | 41 | expect(jsParserMock.parseString).toHaveBeenCalledWith('\nFoo\n', 'foo.html', { 42 | lineNumberStart: 1 43 | }); 44 | }); 45 | 46 | test('offset', () => { 47 | htmlParser.parseString(`
\n

Hello World

\n
\n\n`, 'foo.html'); 48 | 49 | expect(jsParserMock.parseString).toHaveBeenCalledWith('Foo', 'foo.html', { 50 | lineNumberStart: 5 51 | }); 52 | }); 53 | }); 54 | 55 | describe('argument validation', () => { 56 | 57 | test('selector: (none)', () => { 58 | expect(() => { 59 | (embeddedJsExtractor)(); 60 | }).toThrowError(`Missing argument 'selector'`); 61 | }); 62 | 63 | test('selector: null', () => { 64 | expect(() => { 65 | (embeddedJsExtractor)(null); 66 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 67 | }); 68 | 69 | test('selector: wrong type', () => { 70 | expect(() => { 71 | (embeddedJsExtractor)(42); 72 | }).toThrowError(`Argument 'selector' must be a non-empty string`); 73 | }); 74 | 75 | test('jsParser: (none)', () => { 76 | expect(() => { 77 | (embeddedJsExtractor)('script'); 78 | }).toThrowError(`Missing argument 'jsParser'`); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/html/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { HtmlParser, TextNode, Node } from '../../src/html/parser'; 2 | import { registerCommonParserTests } from '../parser.common'; 3 | import { UnicodeSamples } from '../fixtures/unicode'; 4 | import { CatalogBuilder } from '../../src/builder'; 5 | import { IParseOptions } from '../../src/parser'; 6 | 7 | describe('HtmlParser', () => { 8 | 9 | registerCommonParserTests(HtmlParser); 10 | 11 | describe('line number', () => { 12 | 13 | let parser: HtmlParser; 14 | let builderMock: CatalogBuilder; 15 | 16 | beforeEach(() => { 17 | builderMock = { 18 | addMessage: jest.fn() 19 | }; 20 | parser = new HtmlParser(builderMock, [(node: Node, fileName: string, addMessage) => { 21 | if (node.nodeName === '#text') { 22 | addMessage({ 23 | text: (node as TextNode).value 24 | }); 25 | } 26 | }]); 27 | }); 28 | 29 | test('first line', () => { 30 | parser.parseString(`Foo`, 'foo.html'); 31 | 32 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 33 | text: 'Foo', 34 | references: ['foo.html:1'] 35 | }); 36 | }); 37 | 38 | test('third line', () => { 39 | parser.parseString(`\n\nFoo`, 'foo.html'); 40 | 41 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 42 | text: 'Foo', 43 | references: ['foo.html:3'] 44 | }); 45 | }); 46 | 47 | test('with offset', () => { 48 | parser.parseString(`Foo`, 'foo.html', { 49 | lineNumberStart: 10 50 | }); 51 | 52 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 53 | text: 'Foo', 54 | references: ['foo.html:10'] 55 | }); 56 | }); 57 | }); 58 | 59 | test('transform source function', () => { 60 | let parser = new HtmlParser({}, [jest.fn()]); 61 | let parseFunctionMock = (parser).parse = jest.fn(() => []); 62 | 63 | const fileName = 'foo.html'; 64 | const parseOptions: IParseOptions = { 65 | transformSource: source => source.toUpperCase() 66 | }; 67 | 68 | parser.parseString('foo', fileName, parseOptions); 69 | expect(parseFunctionMock).toHaveBeenCalledWith('FOO', fileName, parseOptions); 70 | }); 71 | 72 | describe('unicode', () => { 73 | 74 | function check(text: string): void { 75 | let parser = new HtmlParser(new CatalogBuilder(), [(node: Node) => { 76 | if (node.nodeName === '#text') { 77 | expect((node as TextNode).value).toEqual(text); 78 | } 79 | }]); 80 | 81 | parser.parseString(`${text}`); 82 | } 83 | 84 | test('danish', () => { 85 | check(UnicodeSamples.danish); 86 | }); 87 | 88 | test('german', () => { 89 | check(UnicodeSamples.german); 90 | }); 91 | 92 | test('greek', () => { 93 | check(UnicodeSamples.greek); 94 | }); 95 | 96 | test('english', () => { 97 | check(UnicodeSamples.english); 98 | }); 99 | 100 | test('spanish', () => { 101 | check(UnicodeSamples.spanish); 102 | }); 103 | 104 | test('french', () => { 105 | check(UnicodeSamples.french); 106 | }); 107 | 108 | test('irish gaelic', () => { 109 | check(UnicodeSamples.irishGaelic); 110 | }); 111 | 112 | test('hungarian', () => { 113 | check(UnicodeSamples.hungarian); 114 | }); 115 | 116 | test('icelandic', () => { 117 | check(UnicodeSamples.icelandic); 118 | }); 119 | 120 | test('japanese', () => { 121 | check(UnicodeSamples.japanese); 122 | }); 123 | 124 | test('hebrew', () => { 125 | check(UnicodeSamples.hebrew); 126 | }); 127 | 128 | test('polish', () => { 129 | check(UnicodeSamples.polish); 130 | }); 131 | 132 | test('russian', () => { 133 | check(UnicodeSamples.russian); 134 | }); 135 | 136 | test('thai', () => { 137 | check(UnicodeSamples.thai); 138 | }); 139 | 140 | test('turkish', () => { 141 | check(UnicodeSamples.turkish); 142 | }); 143 | }); 144 | 145 | test('template element', () => { 146 | let parser: HtmlParser; 147 | let builderMock: CatalogBuilder; 148 | 149 | builderMock = { 150 | addMessage: jest.fn() 151 | }; 152 | parser = new HtmlParser(builderMock, [(node: Node, fileName: string, addMessage) => { 153 | if (node.nodeName === '#text') { 154 | addMessage({ 155 | text: (node as TextNode).value 156 | }); 157 | } 158 | }]); 159 | 160 | parser.parseString(``, 'foo.html'); 161 | 162 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 163 | text: 'Foo', 164 | references: ['foo.html:1'] 165 | }); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /tests/html/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as parse5 from 'parse5'; 2 | import { HtmlUtils } from '../../src/html/utils'; 3 | import { Element } from '../../src/html/parser'; 4 | 5 | describe('HTML: Utils', () => { 6 | 7 | function createElement(source: string): Element { 8 | return (parse5.parse(source)).childNodes[0].childNodes[1].childNodes[0]; 9 | } 10 | 11 | describe('getAttributeValue', () => { 12 | 13 | test('normal attribute value', () => { 14 | expect(HtmlUtils.getAttributeValue(createElement('

'), 'foo')).toBe('bar'); 15 | }); 16 | 17 | test('attribute missing', () => { 18 | expect(HtmlUtils.getAttributeValue(createElement('

'), 'foo')).toBe(null); 19 | }); 20 | 21 | test('empty string', () => { 22 | expect(HtmlUtils.getAttributeValue(createElement('

'), 'foo')).toBe(''); 23 | }); 24 | 25 | test('no value', () => { 26 | expect(HtmlUtils.getAttributeValue(createElement('

'), 'foo')).toBe(''); 27 | }); 28 | 29 | test('"null"', () => { 30 | expect(HtmlUtils.getAttributeValue(createElement('

'), 'foo')).toBe('null'); 31 | }); 32 | 33 | test('numeric', () => { 34 | expect(HtmlUtils.getAttributeValue(createElement('

'), 'foo')).toBe('42'); 35 | }); 36 | }); 37 | 38 | describe('getNormalizedAttributeValue', () => { 39 | 40 | test('indendation', () => { 41 | expect(HtmlUtils.getNormalizedAttributeValue(createElement('

'), 'foo', { 45 | preserveIndentation: true, 46 | trimWhiteSpace: true, 47 | replaceNewLines: false 48 | })).toBe( 49 | ' Foo\n' + 50 | ' Bar' 51 | ); 52 | }); 53 | 54 | test('new lines', () => { 55 | expect(HtmlUtils.getNormalizedAttributeValue(createElement('
'), 'foo', { 59 | preserveIndentation: false, 60 | trimWhiteSpace: true, 61 | replaceNewLines: ' ' 62 | })).toBe('Foo Bar'); 63 | }); 64 | }); 65 | 66 | describe('getElementContent', () => { 67 | 68 | function getContent(source: string): string { 69 | return HtmlUtils.getElementContent(createElement(source), { 70 | preserveIndentation: true, 71 | trimWhiteSpace: true, 72 | replaceNewLines: false 73 | }); 74 | } 75 | 76 | test('single line', () => { 77 | expect(getContent('
Foo Bar
')).toBe('Foo Bar'); 78 | }); 79 | 80 | test('nested element', () => { 81 | expect(getContent('
Foo Bar
')).toBe('Foo Bar'); 82 | }); 83 | 84 | test('indentation', () => { 85 | expect(getContent( 86 | '
\n' + 87 | ' Foo\n' + 88 | ' Bar\n' + 89 | '
' 90 | )).toBe( 91 | ' Foo\n' + 92 | ' Bar' 93 | ); 94 | }); 95 | 96 | describe('un-escaping', () => { 97 | 98 | test('&', () => { 99 | expect(getContent('
Foo & Bar
')).toBe('Foo & Bar'); 100 | }); 101 | 102 | test('<', () => { 103 | expect(getContent('
Foo < Bar
')).toBe('Foo < Bar'); 104 | }); 105 | 106 | test('>', () => { 107 | expect(getContent('
Foo > Bar
')).toBe('Foo > Bar'); 108 | }); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /tests/indent.ts: -------------------------------------------------------------------------------- 1 | export function trimIndent(literals: string): string { 2 | const lines = literals.split('\n'); 3 | const commonIndent = lines.reduce((minIndent: number | undefined, line: string) => { 4 | const match = line.match(/^(\s*)\S+/); 5 | if (match !== null) { 6 | if (minIndent === undefined) { 7 | return match[1].length; 8 | } else { 9 | return Math.min(match[1].length, minIndent); 10 | } 11 | } 12 | return minIndent; 13 | }, undefined); 14 | return lines.map(line => line.slice(commonIndent)).join('\n'); 15 | } 16 | -------------------------------------------------------------------------------- /tests/js/extractors/factories/htmlTemplate.test.ts: -------------------------------------------------------------------------------- 1 | import { htmlTemplateExtractor } from '../../../../src/js/extractors/factories/htmlTemplate' 2 | import { HtmlExtractors } from '../../../../src/html/extractors'; 3 | import { JsParser } from '../../../../src/js/parser'; 4 | import { HtmlParser } from '../../../../src/html/parser'; 5 | import { CatalogBuilder, IMessage } from '../../../../src/builder'; 6 | 7 | describe('JS: HTML template extractor', () => { 8 | describe('calling html parser ', () => { 9 | let builder: CatalogBuilder; 10 | let messages: IMessage[] 11 | let jsParser: JsParser; 12 | let htmlParser: HtmlParser; 13 | 14 | beforeEach(() => { 15 | messages = []; 16 | 17 | builder = { 18 | addMessage: jest.fn((message: IMessage) => { 19 | messages.push(message); 20 | }) 21 | }; 22 | 23 | htmlParser = new HtmlParser(builder, [ 24 | HtmlExtractors.elementContent('translate') 25 | ]); 26 | 27 | jsParser = new JsParser(builder, [ 28 | htmlTemplateExtractor(htmlParser) 29 | ]); 30 | }); 31 | 32 | test('single line (regular string)', () => { 33 | jsParser.parseString('let itBe = "
test
"'); 34 | expect(messages).toEqual([ 35 | { 36 | text: 'test' 37 | } 38 | ]) 39 | }); 40 | 41 | test('single line (template string)', () => { 42 | jsParser.parseString('let itBe = "
test
"'); 43 | expect(messages).toEqual([ 44 | { 45 | text: 'test' 46 | } 47 | ]) 48 | }); 49 | 50 | test('with lineNumberStart option (regular string)', () => { 51 | jsParser.parseString( 52 | 'let itBe = "
test
"', 53 | 'test', 54 | { lineNumberStart: 10 } 55 | ); 56 | 57 | expect(messages).toEqual([ 58 | { 59 | text: 'test', 60 | references: ['test:10'], 61 | }, 62 | ]) 63 | }) 64 | 65 | test('with lineNumberStart option (template string)', () => { 66 | jsParser.parseString( 67 | 'let itBe = "
test
"', 68 | 'test', 69 | { lineNumberStart: 10 } 70 | ); 71 | 72 | expect(messages).toEqual([ 73 | { 74 | text: 'test', 75 | references: ['test:10'], 76 | }, 77 | ]) 78 | }) 79 | 80 | test('HTML inside a template literal with the correct line numbers', () => { 81 | jsParser.parseString(` 82 | 83 | 84 | 85 | 86 | 87 | 88 | let tuce = \` 89 |
90 | First level 91 |
92 | Second level 93 |
Third level
94 |
95 |
\` 96 | `, 'test') 97 | 98 | expect(messages).toEqual([ 99 | { 100 | text: 'First level', 101 | references: ['test:10'], 102 | }, 103 | { 104 | text: 'Second level', 105 | references: ['test:12'], 106 | }, 107 | { 108 | text: 'Third level', 109 | references: ['test:13'], 110 | }, 111 | ]) 112 | }) 113 | 114 | test('HTML inside a template literal with correct line numbers and with lineNumberStart', () => { 115 | jsParser.parseString(` 116 | 117 | 118 | 119 | 120 | 121 | 122 | let tuce = \` 123 |
124 | First level 125 |
126 | Second level 127 |
Third level
128 |
129 |
\` 130 | `, 'test', { lineNumberStart: 10 }) 131 | 132 | expect(messages).toEqual([ 133 | { 134 | text: 'First level', 135 | references: ['test:19'], 136 | }, 137 | { 138 | text: 'Second level', 139 | references: ['test:21'], 140 | }, 141 | { 142 | text: 'Third level', 143 | references: ['test:22'], 144 | }, 145 | ]) 146 | }) 147 | }) 148 | }) -------------------------------------------------------------------------------- /tests/js/parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | import { JsParser, IJsParseOptions } from '../../src/js/parser'; 4 | import { registerCommonParserTests } from '../parser.common'; 5 | import { UnicodeSamples } from '../fixtures/unicode'; 6 | import { CatalogBuilder } from '../../src/builder'; 7 | 8 | describe('JsParser', () => { 9 | 10 | registerCommonParserTests(JsParser); 11 | 12 | describe('line number', () => { 13 | 14 | let parser: JsParser; 15 | let builderMock: CatalogBuilder; 16 | 17 | beforeEach(() => { 18 | builderMock = { 19 | addMessage: jest.fn() 20 | }; 21 | parser = new JsParser(builderMock, [(node: ts.Node, sourceFile: ts.SourceFile, addMessage) => { 22 | if (node.kind === ts.SyntaxKind.StringLiteral) { 23 | addMessage({ 24 | text: (node).text 25 | }); 26 | } 27 | }]); 28 | }); 29 | 30 | test('first line', () => { 31 | parser.parseString(`'Foo'`, 'foo.html'); 32 | 33 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 34 | text: 'Foo', 35 | references: ['foo.html:1'] 36 | }); 37 | }); 38 | 39 | test('third line', () => { 40 | parser.parseString(`\n\n'Foo'`, 'foo.html'); 41 | 42 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 43 | text: 'Foo', 44 | references: ['foo.html:3'] 45 | }); 46 | }); 47 | 48 | test('with offset', () => { 49 | parser.parseString(`'Foo'`, 'foo.html', { 50 | lineNumberStart: 10 51 | }); 52 | 53 | expect(builderMock.addMessage).toHaveBeenCalledWith({ 54 | text: 'Foo', 55 | references: ['foo.html:10'] 56 | }); 57 | }); 58 | }); 59 | 60 | test('transform source function', () => { 61 | let parser = new JsParser({}, [jest.fn()]); 62 | let parseFunctionMock = (parser).parse = jest.fn(() => []); 63 | 64 | const fileName = 'foo.ts'; 65 | const parseOptions: IJsParseOptions = { 66 | transformSource: source => source.toUpperCase() 67 | }; 68 | 69 | parser.parseString('foo', fileName, parseOptions); 70 | expect(parseFunctionMock).toHaveBeenCalledWith('FOO', fileName, parseOptions); 71 | }); 72 | 73 | describe('unicode', () => { 74 | 75 | function check(text: string): void { 76 | let parser = new JsParser(new CatalogBuilder(), [(node: ts.Node) => { 77 | if (node.kind === ts.SyntaxKind.StringLiteral) { 78 | expect((node as ts.StringLiteral).text).toEqual(text); 79 | } 80 | }]); 81 | 82 | parser.parseString(`"${text}"`); 83 | } 84 | 85 | test('danish', () => { 86 | check(UnicodeSamples.danish); 87 | }); 88 | 89 | test('german', () => { 90 | check(UnicodeSamples.german); 91 | }); 92 | 93 | test('greek', () => { 94 | check(UnicodeSamples.greek); 95 | }); 96 | 97 | test('english', () => { 98 | check(UnicodeSamples.english); 99 | }); 100 | 101 | test('spanish', () => { 102 | check(UnicodeSamples.spanish); 103 | }); 104 | 105 | test('french', () => { 106 | check(UnicodeSamples.french); 107 | }); 108 | 109 | test('irish gaelic', () => { 110 | check(UnicodeSamples.irishGaelic); 111 | }); 112 | 113 | test('hungarian', () => { 114 | check(UnicodeSamples.hungarian); 115 | }); 116 | 117 | test('icelandic', () => { 118 | check(UnicodeSamples.icelandic); 119 | }); 120 | 121 | test('japanese', () => { 122 | check(UnicodeSamples.japanese); 123 | }); 124 | 125 | test('hebrew', () => { 126 | check(UnicodeSamples.hebrew); 127 | }); 128 | 129 | test('polish', () => { 130 | check(UnicodeSamples.polish); 131 | }); 132 | 133 | test('russian', () => { 134 | check(UnicodeSamples.russian); 135 | }); 136 | 137 | test('thai', () => { 138 | check(UnicodeSamples.thai); 139 | }); 140 | 141 | test('turkish', () => { 142 | check(UnicodeSamples.turkish); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /tests/js/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { JsUtils } from '../../src/js/utils'; 3 | 4 | describe('JS: Utils', () => { 5 | 6 | describe('segmentsMatchPropertyExpression', () => { 7 | 8 | function getExpression(source: string): ts.PropertyAccessExpression { 9 | let sourceFile = ts.createSourceFile('foo.ts', source, ts.ScriptTarget.Latest, true); 10 | 11 | return sourceFile.getChildAt(0).getChildAt(0).getChildAt(0); 12 | } 13 | 14 | test('standard case', () => { 15 | let segments = ['foo', 'bar']; 16 | 17 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('foo.bar'))).toBe(true); 18 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('bar.foo'))).toBe(false); 19 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('this.foo.bar'))).toBe(false); 20 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('baz.foo.bar'))).toBe(false); 21 | }); 22 | 23 | test('long path', () => { 24 | let segments = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']; 25 | 26 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('one.two.three.four.five.six.seven.eight.nine.ten'))).toBe(true); 27 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('two.three.four.five.six.seven.eight.nine.ten'))).toBe(false); 28 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('one.two.three.four.five.six.seven.eight.nine'))).toBe(false); 29 | }); 30 | 31 | test('this keyword', () => { 32 | let segments = ['this', 'foo', 'bar']; 33 | 34 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('this.foo.bar'))).toBe(true); 35 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('foo.bar'))).toBe(false); 36 | }); 37 | 38 | test('optional this keyword', () => { 39 | let segments = ['[this]', 'foo', 'bar']; 40 | 41 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('this.foo.bar'))).toBe(true); 42 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('foo.bar'))).toBe(true); 43 | }); 44 | 45 | test('this keyword not at first position', () => { 46 | let segments = ['foo', 'this', 'bar']; 47 | 48 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('foo.this.bar'))).toBe(true); 49 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('this.foo.this.bar'))).toBe(false); 50 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('this.foo.bar'))).toBe(false); 51 | expect(JsUtils.segmentsMatchPropertyExpression(segments, getExpression('this.bar'))).toBe(false); 52 | }); 53 | 54 | test('case sensitivity', () => { 55 | expect(JsUtils.segmentsMatchPropertyExpression(['this', 'foo'], getExpression('this.FOO'))).toBe(false); 56 | expect(JsUtils.segmentsMatchPropertyExpression(['this', 'foo'], getExpression('THIS.foo'))).toBe(false); 57 | expect(JsUtils.segmentsMatchPropertyExpression(['THIS', 'foo'], getExpression('this.foo'))).toBe(false); 58 | expect(JsUtils.segmentsMatchPropertyExpression(['THIS', 'foo'], getExpression('THIS.foo'))).toBe(true); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/parser.common.ts: -------------------------------------------------------------------------------- 1 | import { Parser, IAddMessageCallback } from '../src/parser'; 2 | import { CatalogBuilder, IMessage } from '../src/builder'; 3 | import { IGettextExtractorStats } from '../src/extractor'; 4 | 5 | export function registerCommonParserTests(parserClass: any): void { 6 | let parser: Parser, 7 | builder: CatalogBuilder, 8 | messages: IMessage[]; 9 | 10 | beforeEach(() => { 11 | messages = []; 12 | 13 | builder = { 14 | stats: {}, 15 | addMessage: jest.fn((message: IMessage) => { 16 | messages.push(message); 17 | }) 18 | }; 19 | }); 20 | 21 | test('one extractor', () => { 22 | let extractor = jest.fn(); 23 | parser = new parserClass(builder, [extractor]); 24 | parser.parseString(''); 25 | expect(extractor).toHaveBeenCalled(); 26 | }); 27 | 28 | test('multiple extractors', () => { 29 | let extractor1 = jest.fn(), 30 | extractor2 = jest.fn(); 31 | parser = new parserClass(builder, [extractor1, extractor2]); 32 | parser.parseString(''); 33 | expect(extractor1).toHaveBeenCalled(); 34 | expect(extractor2).toHaveBeenCalled(); 35 | }); 36 | 37 | test('extractor added later', () => { 38 | let extractor = jest.fn(); 39 | parser = new parserClass(builder); 40 | parser.addExtractor(extractor); 41 | parser.parseString(''); 42 | expect(extractor).toHaveBeenCalled(); 43 | }); 44 | 45 | test('second extractor added later', () => { 46 | let extractor1 = jest.fn(), 47 | extractor2 = jest.fn(); 48 | parser = new parserClass(builder, [extractor1]); 49 | parser.parseString(''); 50 | expect(extractor1).toHaveBeenCalled(); 51 | expect(extractor2).not.toHaveBeenCalled(); 52 | 53 | extractor1.mockClear(); 54 | extractor2.mockClear(); 55 | 56 | parser.addExtractor(extractor2); 57 | parser.parseString(''); 58 | expect(extractor1).toHaveBeenCalled(); 59 | expect(extractor2).toHaveBeenCalled(); 60 | }); 61 | 62 | test('addMessage call', () => { 63 | let extractor = jest.fn().mockImplementationOnce((node: any, file: any, addMessage: IAddMessageCallback) => { 64 | addMessage({ 65 | text: 'Foo' 66 | }); 67 | }); 68 | 69 | parser = new parserClass(builder, [extractor]); 70 | parser.parseString(''); 71 | 72 | expect(messages).toEqual([ 73 | { 74 | text: 'Foo' 75 | } 76 | ]); 77 | }); 78 | 79 | test('fluid api', () => { 80 | let extractor1 = jest.fn(), 81 | extractor2 = jest.fn(); 82 | 83 | parser = new parserClass(builder, [extractor1]); 84 | 85 | expect(parser.parseString('')).toBe(parser); 86 | expect(parser.parseFilesGlob('tests/fixtures/*.ts')).toBe(parser); 87 | expect(parser.parseFile('tests/fixtures/empty.ts')).toBe(parser); 88 | expect(parser.addExtractor(extractor2)).toBe(parser); 89 | }); 90 | 91 | describe('stats', () => { 92 | 93 | let stats: IGettextExtractorStats; 94 | 95 | beforeEach(() => { 96 | stats = { 97 | numberOfMessages: 0, 98 | numberOfPluralMessages: 0, 99 | numberOfMessageUsages: 0, 100 | numberOfContexts: 0, 101 | numberOfParsedFiles: 0, 102 | numberOfParsedFilesWithMessages: 0 103 | }; 104 | }); 105 | 106 | test('no files with messages', () => { 107 | let extractor = jest.fn(); 108 | 109 | parser = new parserClass(builder, [extractor], stats); 110 | 111 | parser.parseString(''); 112 | parser.parseString(''); 113 | parser.parseString(''); 114 | 115 | expect(stats.numberOfParsedFiles).toBe(3); 116 | expect(stats.numberOfParsedFilesWithMessages).toBe(0); 117 | }); 118 | 119 | test('some files with messages', () => { 120 | let extractor = jest.fn().mockImplementationOnce((node: any, file: any, addMessage: IAddMessageCallback) => { 121 | addMessage({ 122 | text: 'Foo' 123 | }); 124 | }); 125 | 126 | parser = new parserClass(builder, [extractor], stats); 127 | 128 | parser.parseString(''); 129 | parser.parseString(''); 130 | parser.parseString(''); 131 | 132 | expect(stats.numberOfParsedFiles).toBe(3); 133 | expect(stats.numberOfParsedFilesWithMessages).toBe(1); 134 | }); 135 | 136 | test('all files with messages', () => { 137 | let extractor = jest.fn().mockImplementation((node: any, file: any, addMessage: IAddMessageCallback) => { 138 | addMessage({ 139 | text: 'Foo' 140 | }); 141 | }); 142 | 143 | parser = new parserClass(builder, [extractor], stats); 144 | 145 | parser.parseString(''); 146 | parser.parseString(''); 147 | parser.parseString(''); 148 | 149 | expect(stats.numberOfParsedFiles).toBe(3); 150 | expect(stats.numberOfParsedFilesWithMessages).toBe(3); 151 | }); 152 | }); 153 | 154 | test('parsing without extractors', () => { 155 | const ERROR_MESSAGE = `Missing extractor functions. Provide them when creating the parser or dynamically add extractors using 'addExtractor()'`; 156 | 157 | parser = new parserClass(builder); 158 | 159 | expect(() => { 160 | parser.parseString(''); 161 | }).toThrowError(ERROR_MESSAGE); 162 | 163 | expect(() => { 164 | parser.parseFile('tests/fixtures/empty.ts'); 165 | }).toThrowError(ERROR_MESSAGE); 166 | 167 | expect(() => { 168 | parser.parseFilesGlob('tests/fixtures/*.ts'); 169 | }).toThrowError(ERROR_MESSAGE); 170 | }); 171 | 172 | describe('argument validation', () => { 173 | 174 | beforeEach(() => { 175 | parser = new parserClass(builder, [jest.fn()]); 176 | }); 177 | 178 | describe('parseFile', () => { 179 | 180 | test('fileName: (none)', () => { 181 | expect(() => { 182 | (parser.parseFile)(); 183 | }).toThrowError(`Missing argument 'fileName'`); 184 | }); 185 | 186 | test('fileName: null', () => { 187 | expect(() => { 188 | (parser.parseFile)(null); 189 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 190 | }); 191 | 192 | test('fileName: wrong type', () => { 193 | expect(() => { 194 | (parser.parseFile)(42); 195 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 196 | }); 197 | 198 | test('options: wrong type', () => { 199 | expect(() => { 200 | (parser.parseFile)('foo.ts', 'bar'); 201 | }).toThrowError(`Argument 'options' must be an object`); 202 | }); 203 | 204 | test('options.lineNumberStart: wrong type', () => { 205 | expect(() => { 206 | (parser.parseFile)('foo.ts', { 207 | lineNumberStart: 'bar' 208 | }); 209 | }).toThrowError(`Property 'options.lineNumberStart' must be a number`); 210 | }); 211 | 212 | test('options.transformSource: wrong type', () => { 213 | expect(() => { 214 | (parser.parseFile)('foo.ts', { 215 | transformSource: 42 216 | }); 217 | }).toThrowError(`Property 'options.transformSource' must be a function`); 218 | }); 219 | }); 220 | 221 | describe('parseFilesGlob', () => { 222 | 223 | test('pattern: (none)', () => { 224 | expect(() => { 225 | (parser.parseFilesGlob)(); 226 | }).toThrowError(`Missing argument 'pattern'`); 227 | }); 228 | 229 | test('pattern: null', () => { 230 | expect(() => { 231 | (parser.parseFilesGlob)(null); 232 | }).toThrowError(`Argument 'pattern' must be a non-empty string`); 233 | }); 234 | 235 | test('pattern: wrong type', () => { 236 | expect(() => { 237 | (parser.parseFilesGlob)(42); 238 | }).toThrowError(`Argument 'pattern' must be a non-empty string`); 239 | }); 240 | 241 | test('globOptions: wrong type', () => { 242 | expect(() => { 243 | (parser.parseFilesGlob)('*.ts;', 'foo'); 244 | }).toThrowError(`Argument 'globOptions' must be an object`); 245 | }); 246 | 247 | test('options: wrong type', () => { 248 | expect(() => { 249 | (parser.parseFilesGlob)('*.ts;', {}, 'foo'); 250 | }).toThrowError(`Argument 'options' must be an object`); 251 | }); 252 | 253 | test('options.lineNumberStart: wrong type', () => { 254 | expect(() => { 255 | (parser.parseFilesGlob)('*.ts;', {}, { 256 | lineNumberStart: 'foo' 257 | }); 258 | }).toThrowError(`Property 'options.lineNumberStart' must be a number`); 259 | }); 260 | 261 | test('options.transformSource: wrong type', () => { 262 | expect(() => { 263 | (parser.parseFile)('foo.ts', { 264 | transformSource: 42 265 | }); 266 | }).toThrowError(`Property 'options.transformSource' must be a function`); 267 | }); 268 | }); 269 | 270 | describe('parseString', () => { 271 | 272 | test('source: (none)', () => { 273 | expect(() => { 274 | (parser.parseString)(); 275 | }).toThrowError(`Missing argument 'source'`); 276 | }); 277 | 278 | test('source: null', () => { 279 | expect(() => { 280 | (parser.parseString)(null); 281 | }).toThrowError(`Argument 'source' must be a string`); 282 | }); 283 | 284 | test('source: wrong type', () => { 285 | expect(() => { 286 | (parser.parseString)(42); 287 | }).toThrowError(`Argument 'source' must be a string`); 288 | }); 289 | 290 | test('fileName: (none)', () => { 291 | expect(() => { 292 | (parser.parseString)('let foo = 42;'); 293 | }).not.toThrow(); 294 | }); 295 | 296 | test('fileName: wrong type', () => { 297 | expect(() => { 298 | (parser.parseString)('let foo = 42;', 42); 299 | }).toThrowError(`Argument 'fileName' must be a non-empty string`); 300 | }); 301 | 302 | test('options: wrong type', () => { 303 | expect(() => { 304 | (parser.parseString)('let foo = 42;', 'foo.ts', 'bar'); 305 | }).toThrowError(`Argument 'options' must be an object`); 306 | }); 307 | 308 | test('options.lineNumberStart: wrong type', () => { 309 | expect(() => { 310 | (parser.parseString)('let foo = 42;', 'foo.ts', { 311 | lineNumberStart: 'bar' 312 | }); 313 | }).toThrowError(`Property 'options.lineNumberStart' must be a number`); 314 | }); 315 | 316 | test('options.transformSource: wrong type', () => { 317 | expect(() => { 318 | (parser.parseFile)('foo.ts', { 319 | transformSource: 42 320 | }); 321 | }).toThrowError(`Property 'options.transformSource' must be a function`); 322 | }); 323 | }); 324 | 325 | describe('addExtractor', () => { 326 | 327 | test('extractor: (none)', () => { 328 | expect(() => { 329 | (parser.addExtractor)(); 330 | }).toThrowError(`Missing argument 'extractor'`); 331 | }); 332 | 333 | test('extractor: null', () => { 334 | expect(() => { 335 | (parser.addExtractor)(null); 336 | }).toThrowError(`Invalid extractor function provided. 'null' is not a function`); 337 | }); 338 | 339 | test('extractor: wrong type', () => { 340 | expect(() => { 341 | (parser.addExtractor)(42); 342 | }).toThrowError(`Invalid extractor function provided. '42' is not a function`); 343 | }); 344 | }); 345 | }); 346 | } 347 | -------------------------------------------------------------------------------- /tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { IAddMessageCallback, Parser } from '../src/parser'; 2 | import { IMessage } from '../src/builder'; 3 | 4 | export const foo = 'bar'; 5 | 6 | describe('Abstract Parser', () => { 7 | 8 | describe('createAddMessageCallback', () => { 9 | 10 | let lineNumber: number, 11 | messages: IMessage[], 12 | callback: IAddMessageCallback; 13 | 14 | beforeEach(() => { 15 | messages = []; 16 | callback = Parser.createAddMessageCallback(messages, 'foo.ts', () => lineNumber); 17 | }); 18 | 19 | test('single call', () => { 20 | lineNumber = 16; 21 | 22 | callback({ 23 | text: 'Foo' 24 | }); 25 | 26 | expect(messages).toEqual([ 27 | { 28 | text: 'Foo', 29 | references: ['foo.ts:16'] 30 | } 31 | ]); 32 | }); 33 | 34 | test('multiple calls', () => { 35 | lineNumber = 16; 36 | 37 | callback({ 38 | text: 'Foo' 39 | }); 40 | 41 | lineNumber = 17; 42 | 43 | callback({ 44 | text: 'Bar' 45 | }); 46 | 47 | expect(messages).toEqual([ 48 | { 49 | text: 'Foo', 50 | references: ['foo.ts:16'] 51 | }, 52 | { 53 | text: 'Bar', 54 | references: ['foo.ts:17'] 55 | } 56 | ]); 57 | }); 58 | 59 | test('plural', () => { 60 | lineNumber = 16; 61 | 62 | callback({ 63 | text: 'Foo', 64 | textPlural: 'Foos' 65 | }); 66 | 67 | expect(messages).toEqual([ 68 | { 69 | text: 'Foo', 70 | textPlural: 'Foos', 71 | references: ['foo.ts:16'] 72 | } 73 | ]); 74 | }); 75 | 76 | test('context', () => { 77 | lineNumber = 16; 78 | 79 | callback({ 80 | text: 'Foo', 81 | context: 'Context' 82 | }); 83 | 84 | expect(messages).toEqual([ 85 | { 86 | text: 'Foo', 87 | context: 'Context', 88 | references: ['foo.ts:16'] 89 | } 90 | ]); 91 | }); 92 | 93 | test('custom line number', () => { 94 | lineNumber = 16; 95 | 96 | callback({ 97 | text: 'Foo', 98 | lineNumber: 100 99 | }); 100 | 101 | expect(messages).toEqual([ 102 | { 103 | text: 'Foo', 104 | references: ['foo.ts:100'] 105 | } 106 | ]); 107 | }); 108 | 109 | test('custom file name', () => { 110 | lineNumber = 16; 111 | 112 | callback({ 113 | text: 'Foo', 114 | fileName: 'bar.ts' 115 | }); 116 | 117 | expect(messages).toEqual([ 118 | { 119 | text: 'Foo', 120 | references: ['bar.ts:16'] 121 | } 122 | ]); 123 | }); 124 | 125 | test('custom file name and line number', () => { 126 | lineNumber = 16; 127 | 128 | callback({ 129 | text: 'Foo', 130 | fileName: 'bar.ts', 131 | lineNumber: 100 132 | }); 133 | 134 | expect(messages).toEqual([ 135 | { 136 | text: 'Foo', 137 | references: ['bar.ts:100'] 138 | } 139 | ]); 140 | }); 141 | 142 | test('string literal file name', () => { 143 | lineNumber = 16; 144 | callback = Parser.createAddMessageCallback(messages, Parser.STRING_LITERAL_FILENAME, () => lineNumber); 145 | 146 | callback({ 147 | text: 'Foo' 148 | }); 149 | 150 | expect(messages).toEqual([ 151 | { 152 | text: 'Foo' 153 | } 154 | ]); 155 | }); 156 | 157 | test('comments', () => { 158 | lineNumber = 16; 159 | 160 | callback({ 161 | text: 'Foo', 162 | comments: ['Comment 1', 'Comment 2'] 163 | }); 164 | 165 | expect(messages).toEqual([ 166 | { 167 | text: 'Foo', 168 | references: ['foo.ts:16'], 169 | comments: ['Comment 1', 'Comment 2'] 170 | } 171 | ]); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /tests/utils/content.test.ts: -------------------------------------------------------------------------------- 1 | import { IContentOptions, normalizeContent } from '../../src/utils/content'; 2 | 3 | describe('Content Utils', () => { 4 | 5 | describe('normalizeContent', () => { 6 | 7 | type Scenario = 'default' | 'noTrim' | 'preserveIndentation' | 'noTrimPreserveIndentation' | 'replaceNewlinesCRLF' | 'replaceNewlinesCRLFPreserveIndentation'; 8 | 9 | const scenarios: {[scenario in Scenario]: IContentOptions} = { 10 | default: { 11 | preserveIndentation: false, 12 | replaceNewLines: false, 13 | trimWhiteSpace: true 14 | }, 15 | noTrim: { 16 | preserveIndentation: false, 17 | replaceNewLines: false, 18 | trimWhiteSpace: false 19 | }, 20 | preserveIndentation: { 21 | preserveIndentation: true, 22 | replaceNewLines: false, 23 | trimWhiteSpace: true 24 | }, 25 | noTrimPreserveIndentation: { 26 | preserveIndentation: true, 27 | replaceNewLines: false, 28 | trimWhiteSpace: false 29 | }, 30 | replaceNewlinesCRLF: { 31 | preserveIndentation: false, 32 | replaceNewLines: '\r\n', 33 | trimWhiteSpace: true 34 | }, 35 | replaceNewlinesCRLFPreserveIndentation: { 36 | preserveIndentation: true, 37 | replaceNewLines: '\r\n', 38 | trimWhiteSpace: true 39 | } 40 | }; 41 | 42 | function registerNormalizeContentTests(newLine: string, whitespace: string): void { 43 | 44 | function testCase(summary: string, source: string, expectedResults: { [scenario in Scenario]: string }): void { 45 | const whitespacePlaceholder = / {4}/g; 46 | source = source.replace(/\n/g, newLine).replace(whitespacePlaceholder, whitespace); 47 | 48 | describe(summary, () => { 49 | for (let scenario of Object.keys(scenarios)) { 50 | test(scenario, () => { 51 | expect(normalizeContent(source, (scenarios as any)[scenario])) 52 | .toBe((expectedResults as any)[scenario].replace(whitespacePlaceholder, whitespace)); 53 | }); 54 | } 55 | }); 56 | } 57 | 58 | testCase('single line', 59 | 'Foo Bar', 60 | { 61 | default: 62 | 'Foo Bar', 63 | noTrim: 64 | 'Foo Bar', 65 | preserveIndentation: 66 | 'Foo Bar', 67 | noTrimPreserveIndentation: 68 | 'Foo Bar', 69 | replaceNewlinesCRLF: 70 | 'Foo Bar', 71 | replaceNewlinesCRLFPreserveIndentation: 72 | 'Foo Bar' 73 | } 74 | ); 75 | 76 | testCase('leading and trailing newline', 77 | '\n' + 78 | 'Foo Bar\n', 79 | { 80 | default: 81 | 'Foo Bar', 82 | noTrim: 83 | '\n' + 84 | 'Foo Bar\n', 85 | preserveIndentation: 86 | 'Foo Bar', 87 | noTrimPreserveIndentation: 88 | '\n' + 89 | 'Foo Bar\n', 90 | replaceNewlinesCRLF: 91 | 'Foo Bar', 92 | replaceNewlinesCRLFPreserveIndentation: 93 | 'Foo Bar' 94 | } 95 | ); 96 | 97 | testCase('leading and trailing newline with indentation', 98 | '\n' + 99 | ' Foo Bar\n', 100 | { 101 | default: 102 | 'Foo Bar', 103 | noTrim: 104 | '\n' + 105 | 'Foo Bar\n', 106 | preserveIndentation: 107 | ' Foo Bar', 108 | noTrimPreserveIndentation: 109 | '\n' + 110 | ' Foo Bar\n', 111 | replaceNewlinesCRLF: 112 | 'Foo Bar', 113 | replaceNewlinesCRLFPreserveIndentation: 114 | ' Foo Bar' 115 | } 116 | ); 117 | 118 | testCase('indented leading newline', 119 | ' \n' + 120 | ' Foo Bar\n', 121 | { 122 | default: 123 | 'Foo Bar', 124 | noTrim: 125 | '\n' + 126 | 'Foo Bar\n', 127 | preserveIndentation: 128 | ' Foo Bar', 129 | noTrimPreserveIndentation: 130 | ' \n' + 131 | ' Foo Bar\n', 132 | replaceNewlinesCRLF: 133 | 'Foo Bar', 134 | replaceNewlinesCRLFPreserveIndentation: 135 | ' Foo Bar' 136 | } 137 | ); 138 | 139 | testCase('multiple leading newlines', 140 | '\n' + 141 | '\n' + 142 | 'Foo Bar', 143 | { 144 | default: 145 | 'Foo Bar', 146 | noTrim: 147 | '\n' + 148 | '\n' + 149 | 'Foo Bar', 150 | preserveIndentation: 151 | 'Foo Bar', 152 | noTrimPreserveIndentation: 153 | '\n' + 154 | '\n' + 155 | 'Foo Bar', 156 | replaceNewlinesCRLF: 157 | 'Foo Bar', 158 | replaceNewlinesCRLFPreserveIndentation: 159 | 'Foo Bar' 160 | } 161 | ); 162 | 163 | testCase('multiple trailing newlines', 164 | 'Foo Bar\n' + 165 | '\n', 166 | { 167 | default: 168 | 'Foo Bar', 169 | noTrim: 170 | 'Foo Bar\n' + 171 | '\n', 172 | preserveIndentation: 173 | 'Foo Bar', 174 | noTrimPreserveIndentation: 175 | 'Foo Bar\n' + 176 | '\n', 177 | replaceNewlinesCRLF: 178 | 'Foo Bar', 179 | replaceNewlinesCRLFPreserveIndentation: 180 | 'Foo Bar' 181 | } 182 | ); 183 | 184 | testCase('multiple content lines', 185 | '\n' + 186 | 'Foo\n' + 187 | 'Bar\n', 188 | { 189 | default: 190 | 'Foo\n' + 191 | 'Bar', 192 | noTrim: 193 | '\n' + 194 | 'Foo\n' + 195 | 'Bar\n', 196 | preserveIndentation: 197 | 'Foo\n' + 198 | 'Bar', 199 | noTrimPreserveIndentation: 200 | '\n' + 201 | 'Foo\n' + 202 | 'Bar\n', 203 | replaceNewlinesCRLF: 204 | 'Foo\r\n' + 205 | 'Bar', 206 | replaceNewlinesCRLFPreserveIndentation: 207 | 'Foo\r\n' + 208 | 'Bar' 209 | } 210 | ); 211 | 212 | testCase('multiple content lines with indentation', 213 | '\n' + 214 | ' Foo\n' + 215 | ' Bar\n', 216 | { 217 | default: 218 | 'Foo\n' + 219 | 'Bar', 220 | noTrim: 221 | '\n' + 222 | 'Foo\n' + 223 | 'Bar\n', 224 | preserveIndentation: 225 | ' Foo\n' + 226 | ' Bar', 227 | noTrimPreserveIndentation: 228 | '\n' + 229 | ' Foo\n' + 230 | ' Bar\n', 231 | replaceNewlinesCRLF: 232 | 'Foo\r\n' + 233 | 'Bar', 234 | replaceNewlinesCRLFPreserveIndentation: 235 | ' Foo\r\n' + 236 | ' Bar' 237 | } 238 | ); 239 | } 240 | 241 | describe('LF & spaces', () => { 242 | 243 | registerNormalizeContentTests('\n', ' '); 244 | }); 245 | 246 | describe('LF & tabs', () => { 247 | 248 | registerNormalizeContentTests('\n', '\t'); 249 | }); 250 | 251 | describe('CRLF & spaces', () => { 252 | 253 | registerNormalizeContentTests('\r\n', ' '); 254 | }); 255 | 256 | describe('CRLF & tabs', () => { 257 | 258 | registerNormalizeContentTests('\r\n', '\t'); 259 | }); 260 | }); 261 | }); 262 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist/", 4 | "target": "ES6", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "newLine": "LF", 8 | "strict": true 9 | }, 10 | "files": [ 11 | "src/index.ts" 12 | ], 13 | "exclude": [ 14 | "node_modules" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "curly": true, 5 | "comment-format": [ 6 | true, 7 | "check-space" 8 | ], 9 | "eofline": true, 10 | "indent": [ 11 | true, 12 | "spaces" 13 | ], 14 | "interface-name": true, 15 | "member-access": true, 16 | "member-ordering": [ 17 | true, 18 | "variables-before-functions", 19 | "static-before-instance", 20 | "public-before-private" 21 | ], 22 | "no-eval": true, 23 | "no-shadowed-variable": true, 24 | "no-internal-module": true, 25 | "no-trailing-whitespace": true, 26 | "no-unsafe-finally": true, 27 | "no-var-keyword": true, 28 | "one-line": [ 29 | true, 30 | "check-catch", 31 | "check-finally", 32 | "check-else", 33 | "check-open-brace", 34 | "check-whitespace" 35 | ], 36 | "quotemark": [ 37 | true, 38 | "single", 39 | "jsx-double" 40 | ], 41 | "radix": true, 42 | "semicolon": [ 43 | true, 44 | "always" 45 | ], 46 | "trailing-comma": [ 47 | true, 48 | { 49 | "multiline": "never", 50 | "singleline": "never" 51 | } 52 | ], 53 | "triple-equals": true, 54 | "typedef": [ 55 | true, 56 | "call-signature", 57 | "parameter", 58 | "property-declaration", 59 | "member-variable-declaration" 60 | ], 61 | "typedef-whitespace": [ 62 | true, 63 | { 64 | "call-signature": "nospace", 65 | "index-signature": "nospace", 66 | "parameter": "nospace", 67 | "property-declaration": "nospace", 68 | "variable-declaration": "nospace" 69 | }, 70 | { 71 | "call-signature": "space", 72 | "index-signature": "space", 73 | "parameter": "space", 74 | "property-declaration": "space", 75 | "variable-declaration": "space" 76 | } 77 | ], 78 | "variable-name": [ 79 | true, 80 | "check-format", 81 | "ban-keywords" 82 | ], 83 | "whitespace": [ 84 | true, 85 | "check-branch", 86 | "check-decl", 87 | "check-operator", 88 | "check-module", 89 | "check-separator", 90 | "check-type", 91 | "check-preblock" 92 | ] 93 | } 94 | } 95 | --------------------------------------------------------------------------------