diff --git a/.gitignore b/.gitignore index 5d64756..3b93bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ buck-out/ # Bundle artifact *.jsbundle +/coverage +/.vscode/.react diff --git a/.vscode/launch.json b/.vscode/launch.json index ad8349c..107bf29 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,15 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest All", + "program": "${workspaceRoot}/node_modules/jest/bin/jest", + "args": ["--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, { "name": "Attach to packager", "program": "${workspaceRoot}/.vscode/launchReactNative.js", diff --git a/App.js b/App.js index b21503d..2040478 100644 --- a/App.js +++ b/App.js @@ -24,13 +24,41 @@ export default class App extends Component { return ( - Welcome to React Native! - To get started, edit App.js - {instructions} + + + <StyledText'} + style={styles.jsx} + textStyles={jsxStyles} + /> + ="Happy <b>Styling</b>!"'} + style={[styles.jsx, styles.jsxProp]} + textStyles={jsxStyles} + /> + ={styles.header}"} + style={[styles.jsx, styles.jsxProp]} + textStyles={jsxStyles} + /> + + + ); } @@ -40,24 +68,73 @@ const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', - alignItems: 'center', backgroundColor: '#F5FCFF', }, welcome: { fontSize: 20, textAlign: 'center', - margin: 10, + padding: 30, }, - instructions: { - textAlign: 'center', + instruction: { + fontSize: 18, + textAlign: 'left', color: '#333333', - marginBottom: 5, + padding: 30, + }, + header: { + fontSize: 24, + color: 'orange', + textAlign: 'center', + padding: 30, + }, + jsxContainer: { + backgroundColor: '#333', + padding: 10, + }, + jsx: { + textAlign: 'left', + paddingLeft: 20, + paddingVertical: 4, + fontWeight: '500', + fontSize: 18, + fontFamily: 'courier', + }, + jsxProp: { + paddingLeft: 40, + color: '#8EDDFF', }, }); const textStyles = StyleSheet.create({ - b: { - fontWeight: 'bold', + demo: { + textShadowOffset: { width: 2, height: 2 }, + textShadowColor: '#555555', + textShadowRadius: 6, + fontSize: 24, + fontStyle: 'italic', color: '#22AA44', + }, + code: { + fontWeight: 'bold', + fontFamily: 'courier', + fontSize: 18, + } +}); + +const jsxStyles = StyleSheet.create({ + ltgt: { + color: '#888', + }, + comp: { + color: '#00CCB0', + }, + eq: { + color: 'white', + }, + brace: { + color: '#4797D6', + }, + string: { + color: '#D68E76', } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/README.md b/README.md index 477db2c..c666cc6 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,117 @@ ## Introduction +The purpose of this library is to support easy rendering of mixed text styles. + + + +The library implements a `StyledText` component taking an HTML-like text and a styles object as input properties. ## Installation +To install the library into your project, run yarn or npm: + +`yarn add react-native-styled-text` + +or + +`npm i react-native-styled-text` ## Examples -### API +### Using default styles +For simple styling `StyledText` supports some predefined styles: + +* b: **bold** +* i: *italic* + +Example: + +```javascript +import { StyleSheet } from 'react-native'; +import { StyledText } from 'react-native-styled-text'; + +... + +... + +const styles = StyleSheet.create({ + header: { + fontSize: 24, + color: 'orange', + textAlign: 'center', + padding: 30, + }, +}); + +``` + +Renders as + + + +### Using custom styles +For richer styling, you set the `textStyles` property of `StyledText` to an object (`StyleSheet`) containing your custom text styles and apply the styles in the `text` property. + +Example: + +```javascript +import { StyleSheet } from 'react-native'; +import { StyledText } from 'react-native-styled-text'; + +... + +... + +const styles = StyleSheet.create({ + welcome: { + fontSize: 20, + textAlign: 'center', + padding: 30, + }, +}); + +const textStyles = StyleSheet.create({ + demo: { + textShadowOffset: { width: 2, height: 2 }, + textShadowColor: '#555555', + textShadowRadius: 6, + fontSize: 24, + fontStyle: 'italic', + color: '#22AA44', + }, +}); + +``` + +Renders as + + + + +## API +`StyledText` exposes the following properties: + +| Name | Description | +| ---- | ----------- | +| text | String with style tags for mixed styling of the text. Each style tag must match one of the styles provided in textStyles or one of the default styles, see below. | +| style | Base style for the component, typically including layout properties. (Optional) | +| textStyles | Object (`StyleSheet`) containing definition of the styles used in the provided text. (Optional) | + +The following default styles are defined: + +| Name | Description | +| ---- | ----------- | +| b | **bold** | +| i | *italic* | + + ### Contributors Bjørn Egil Hansen (@bjornegil) diff --git a/docs/example.png b/docs/example.png new file mode 100644 index 0000000..5a91fbd Binary files /dev/null and b/docs/example.png differ diff --git a/docs/happyStyling.png b/docs/happyStyling.png new file mode 100644 index 0000000..8e6a380 Binary files /dev/null and b/docs/happyStyling.png differ diff --git a/docs/welcome.png b/docs/welcome.png new file mode 100644 index 0000000..1f2166c Binary files /dev/null and b/docs/welcome.png differ diff --git a/lib/StyledText/lexicalAnalyzer.js b/lib/StyledText/lexicalAnalyzer.js index d06ca17..78478e4 100644 --- a/lib/StyledText/lexicalAnalyzer.js +++ b/lib/StyledText/lexicalAnalyzer.js @@ -1,13 +1,19 @@ - export type Token = { type: string, lexeme: string, } -export const TOKEN_BEGIN_TAG = 'beginTag'; -export const TOKEN_END_TAG = 'endTag'; +export const TOKEN_BEGIN_TAG = 'begin tag'; +export const TOKEN_END_TAG = 'end tag'; export const TOKEN_TEXT = 'text'; +const replaceCodes = (text: string) => { + return text + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') +} + export const scan = (text: string): Array => { const tagRegex = /<([\w-]+)>|<\/([\w-]*)>/; const tokens = []; @@ -17,18 +23,20 @@ export const scan = (text: string): Array => { const tag = tagRegex.exec(remainingText); if (!tag) { - tokens.push({ type: TOKEN_TEXT, lexeme: remainingText }); + const lexeme = replaceCodes(remainingText); + tokens.push({ type: TOKEN_TEXT, lexeme }); remainingText = ''; break; } if (tag.index > 0) { const headText = remainingText.substring(0, tag.index); - tokens.push({ type: TOKEN_TEXT, lexeme: headText }); + const lexeme = replaceCodes(headText); + tokens.push({ type: TOKEN_TEXT, lexeme }); remainingText = remainingText.substring(tag.index); } - const styleName = tag[1]; + const styleName = tag[1] || tag[2]; if (tag[0].startsWith(', startIndex: number) const token = tokens[index]; switch (token.type) { case TOKEN_BEGIN_TAG: { - const { styledText, length } = parseStyledText(tokens, index); - mixedText.push(styledText); - index += length; - break; - } + const { styledText, length } = parseStyledText(tokens, index); + mixedText.push(styledText); + index += length; + break; + } case TOKEN_TEXT: mixedText.push(token.lexeme); index++; break; - default: break; + default: + console.warn('Unexpected ' + token.type + ': ' + token.lexeme); + index++; + break; } } @@ -72,6 +75,12 @@ const parseMixedText = (tokens: Array, startIndex: number) export const parse = (text: string): Mixed => { const tokens = scan(text); - const { mixedText } = parseMixedText(tokens, 0); + const { mixedText, length } = parseMixedText(tokens, 0); + + if (length < tokens.length) { + const unexpectedToken = tokens[length]; + console.warn('Unexpected ' + unexpectedToken.type + ': ' + unexpectedToken.lexeme); + } + return mixedText; }; diff --git a/lib/StyledText/renderer.js b/lib/StyledText/renderer.js index b67378b..b96da7c 100644 --- a/lib/StyledText/renderer.js +++ b/lib/StyledText/renderer.js @@ -1,8 +1,17 @@ import * as React from 'react'; -import { Text } from 'react-native'; +import { Text, StyleSheet } from 'react-native'; import { parse, Mixed } from './parser'; +const defaultStyles = { + b: { + fontWeight: 'bold', + }, + i: { + fontStyle: 'italic', + }, +}; + const verifyTextStyles = (mixedText: Mixed, textStyles: Object) => { const styleNames = []; mixedText.map(element => { @@ -15,27 +24,31 @@ const verifyTextStyles = (mixedText: Mixed, textStyles: Object) => { }); styleNames.forEach((styleName) => { - if (!textStyles[styleName]) { + if (!textStyles[styleName] && !defaultStyles[styleName]) { console.warn('react-native-styled-text: style "' + styleName + '" is not defined'); } }); } -const renderMixedText = (mixedText: Mixed, textStyles: Object) => mixedText.map(element => ( +const renderMixedText = (mixedText: Mixed, textStyles: Object) => mixedText.map((element, index) => ( typeof element === 'string' ? element : React.createElement( Text, - { style: textStyles[element.styleName] }, - renderMixedText(element.mixedText), + { + style: textStyles[element.styleName] || defaultStyles[element.styleName], + key: index + }, + renderMixedText(element.mixedText, textStyles), ) )); export const renderStyledText = (text, style, textStyles) => { const mixedText = parse(text); - verifyTextStyles(mixedText, textStyles); + const styles = textStyles || {} + verifyTextStyles(mixedText, styles); - const textElements = renderMixedText(mixedText, textStyles); + const textElements = renderMixedText(mixedText, styles); return React.createElement( Text, diff --git a/lib/__tests__/lexicalAnalyzer.spec.js b/lib/__tests__/lexicalAnalyzer.spec.js new file mode 100644 index 0000000..073ca79 --- /dev/null +++ b/lib/__tests__/lexicalAnalyzer.spec.js @@ -0,0 +1,73 @@ +import { + scan, + Token, + TOKEN_BEGIN_TAG, + TOKEN_END_TAG, + TOKEN_TEXT, +} from '../StyledText/lexicalAnalyzer'; + +describe('components/FormattedText/lexicalAnalyzer', () => { + describe('scan', () => { + it('should return text if plain text', () => { + const text = 'Testing { + const text = ''; + const tokens = scan(text); + expect(tokens.length).toBe(1); + expect(tokens[0].type).toBe(TOKEN_BEGIN_TAG); + expect(tokens[0].lexeme).toBe('style1'); + }); + + it('should recognize named end tag', () => { + const text = ''; + const tokens = scan(text); + expect(tokens.length).toBe(1); + expect(tokens[0].type).toBe(TOKEN_END_TAG); + expect(tokens[0].lexeme).toBe('style1'); + }); + + it('should recognize empty end tag', () => { + const text = ''; + const tokens = scan(text); + expect(tokens.length).toBe(1); + expect(tokens[0].type).toBe(TOKEN_END_TAG); + expect(tokens[0].lexeme).toBe(''); + }); + + it('should handle mixed text', () => { + const text = 'Testing adsftext2234'; + const tokens = scan(text); + expect(tokens.length).toBe(7); + expect(tokens[0].type).toBe(TOKEN_TEXT); + expect(tokens[0].lexeme).toBe('Testing '); + expect(tokens[1].type).toBe(TOKEN_BEGIN_TAG); + expect(tokens[1].lexeme).toBe('style1'); + expect(tokens[2].type).toBe(TOKEN_TEXT); + expect(tokens[2].lexeme).toBe('adsf'); + expect(tokens[3].type).toBe(TOKEN_BEGIN_TAG); + expect(tokens[3].lexeme).toBe('style2'); + expect(tokens[4].type).toBe(TOKEN_TEXT); + expect(tokens[4].lexeme).toBe('text2'); + expect(tokens[5].type).toBe(TOKEN_END_TAG); + expect(tokens[5].lexeme).toBe('style2'); + expect(tokens[6].type).toBe(TOKEN_TEXT); + expect(tokens[6].lexeme).toBe('234'); + }); + + it('should translate > < "', () => { + const text = '<">'; + const tokens = scan(text); + expect(tokens.length).toBe(2); + expect(tokens[0].type).toBe(TOKEN_BEGIN_TAG); + expect(tokens[0].lexeme).toBe('style1'); + expect(tokens[1].type).toBe(TOKEN_TEXT); + expect(tokens[1].lexeme).toBe('<">'); + }); + }); +}); diff --git a/lib/__tests__/styledText.spec.js b/lib/__tests__/parser.spec.js similarity index 80% rename from lib/__tests__/styledText.spec.js rename to lib/__tests__/parser.spec.js index 5cc65aa..0aa8d4e 100644 --- a/lib/__tests__/styledText.spec.js +++ b/lib/__tests__/parser.spec.js @@ -1,6 +1,16 @@ import { parse } from '../StyledText/parser'; +let warnings = []; +const warn = msg => warnings.push(msg); + describe('components/FormattedText/parser', () => { + beforeAll(() => { + console['warn'] = warn; + }); + beforeEach(() => { + warnings = []; + }); + describe('parseText', () => { it('should return text if not formatted', () => { const text = 'Testing { expect(mixedText[1].mixedText[1].mixedText[0]).toBe('text2'); expect(mixedText[1].mixedText[2]).toBe('234'); }); + + it('should give warning on tag name mismatch', () => { + const text = 'adsf'; + parse(text); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('style name mismatch'); + expect(warnings[0]).toContain('style1'); + expect(warnings[0]).toContain('style2'); + }); + + it('should give warning on unexpected end tag', () => { + const text = 'adsf'; + parse(text); + expect(warnings.length).toBe(1); + expect(warnings[0].toLowerCase()).toContain('unexpected end tag'); + expect(warnings[0]).toContain('style1'); + }); }); }); diff --git a/lib/package.json b/lib/package.json index ae8100c..cea8c88 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "react-native-styled-text", - "version": "0.0.1", + "version": "0.1.0", "description": "A React Native component for easy rendering of styled text.", "main": "./index.js", "scripts": { diff --git a/package.json b/package.json index dc4d32f..0d018f5 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "react-native-styled-text", - "version": "0.0.1", + "name": "react-native-styled-text-ex", + "version": "0.1.0", "private": true, "scripts": { "start": "node node_modules/react-native/local-cli/cli.js start", - "test": "jest" + "test": "jest --coverage --watchAll" }, "dependencies": { "prop-types": "^15.6.2",