diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..b012da2c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +{ + "name": "Node.js & TypeScript", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] + } + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index d46623ba..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - root: true, - extends: '@react-native-community', - settings: { - react: { - version: require('./package.json').peerDependencies.react, - }, - } -}; diff --git a/.gitignore b/.gitignore index e3e176f1..68b7c6f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ package-lock.json yarn-error.log yarn.lock .eslintcache +/dist +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/.npmignore b/.npmignore index 5a3dd218..f34c5929 100644 --- a/.npmignore +++ b/.npmignore @@ -3,3 +3,4 @@ /bin /docs /doc +/dist \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..4b0d4ad5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "bracketSpacing": false, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 5c4de1a4..00000000 --- a/.prettierrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - bracketSpacing: false, - jsxBracketSameLine: true, - singleQuote: true, - trailingComma: 'all', -}; diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1e9031c3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsserver.experimental.enableProjectDiagnostics": true +} diff --git a/README.md b/README.md index dd58d859..ef107361 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ -# React Native Markdown Display [![npm version](https://badge.fury.io/js/react-native-markdown-display.svg)](https://badge.fury.io/js/react-native-markdown-display) [![Known Vulnerabilities](https://snyk.io/test/github/iamacup/react-native-markdown-display/badge.svg)](https://snyk.io/test/github/iamacup/react-native-markdown-display) +# React Native Markdown Display -**This is a fork of [iamacup/react-native-markdown-display](https://github.com/iamacup/react-native-markdown-display) that increases the depended upon versions of react to 18 and react-native to v0.68. This makes it compatible with Expo SDK 46** +It a 100% compatible CommonMark renderer, a react-native markdown renderer done right. This is **not** a web-view markdown renderer but a renderer that uses native components for all its elements. These components can be overwritten and styled as needed. + +## Credit + +- [mientjan/react-native-markdown-renderer](https://github.com/mientjan/react-native-markdown-renderer): Original library +- [iamacup/react-native-markdown-display](https://github.com/iamacup/react-native-markdown-display): Fork +- [jonasmerlin/react-native-markdown-display](https://github.com/jonasmerlin/react-native-markdown-display): Second Fork +- [@jthoward64](https://github.com/jthoward64): Typescript port -It a 100% compatible CommonMark renderer, a react-native markdown renderer done right. This is __not__ a web-view markdown renderer but a renderer that uses native components for all its elements. These components can be overwritten and styled as needed. ### Compatibility with react-native-markdown-renderer @@ -10,14 +16,8 @@ This is intended to be a replacement for react-native-markdown-renderer, with a ### Install -#### Yarn -```npm -yarn add react-native-markdown-display -``` - -#### NPM -```npm -npm install -S react-native-markdown-display +```sh +npm install @ukdanceblue/react-native-markdown-display ``` ### Get Started @@ -26,7 +26,7 @@ npm install -S react-native-markdown-display import React from 'react'; import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; -import Markdown from 'react-native-markdown-display'; +import Markdown from '@ukdanceblue/react-native-markdown-display'; const copy = `# h1 Heading 8-) @@ -56,32 +56,29 @@ const App: () => React$Node = () => { export default App; ``` - ### Props and Functions The `` object takes the following common props: -| Property | Default | Required | Description -| --- | --- | --- | --- -| `children` | N/A | `true` | The markdown string to render, or the [pre-processed tree](#pre-processing) -| `style` | [source](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/styles.js) | `false` | An object to override the styling for the various rules, [see style section below](#rules-and-styles) for more info -| `mergeStyle` | `true` | `false` | If true, when a style is supplied, the individual items are merged with the default styles instead of overwriting them -| `rules` | [source](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/renderRules.js) | `false` | An object of rules that specify how to render each markdown item, [see rules section below](#rules) for more info -| `onLinkPress` | `import { Linking } from 'react-native';` and `Linking.openURL(url);` | `false` | A handler function to change click behaviour, [see handling links section below](#handling-links) for more info -| `debugPrintTree` | `false` | `false` | Will print the AST tree to the console to help you see what the markdown is being translated to - +| Property | Default | Required | Description | +| ---------------- | ----------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `children` | N/A | `true` | The markdown string to render, or the [pre-processed tree](#pre-processing) | +| `style` | [source](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/styles.js) | `false` | An object to override the styling for the various rules, [see style section below](#rules-and-styles) for more info | +| `mergeStyle` | `true` | `false` | If true, when a style is supplied, the individual items are merged with the default styles instead of overwriting them | +| `rules` | [source](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/renderRules.js) | `false` | An object of rules that specify how to render each markdown item, [see rules section below](#rules) for more info | +| `onLinkPress` | `import { Linking } from 'react-native';` and `Linking.openURL(url);` | `false` | A handler function to change click behaviour, [see handling links section below](#handling-links) for more info | +| `debugPrintTree` | `false` | `false` | Will print the AST tree to the console to help you see what the markdown is being translated to | And some additional, less used options: -| Property | Default | Required | Description -| --- | --- | --- | --- -| `renderer` | `instanceOf(AstRenderer)` | `false` | Used to specify a custom renderer, you can not use the rules or styles props with a custom renderer. -| `markdownit` | `instanceOf(MarkdownIt)` | `false` | A custom markdownit instance with your configuration, default is `MarkdownIt({typographer: true})` -| `maxTopLevelChildren` | `null` | `false` | If defined as a number will only render out first `n` many top level children, then will try to render out `topLevelMaxExceededItem` -| `topLevelMaxExceededItem` | `...` | `false` | Will render when `maxTopLevelChildren` is hit. Make sure to give it a key! -| `allowedImageHandlers` | `['data:image/png;base64', 'data:image/gif;base64', 'data:image/jpeg;base64', 'https://', 'http://']` | `false` | Any image that does not start with one of these will have the `defaultImageHandler` value prepended to it (unless `defaultImageHandler` is null in which case it won't try to render anything) -| `defaultImageHandler` | `http://` | `false` | Will be prepended to an image url if it does not start with something in the `allowedImageHandlers` array, if this is set to null, it won't try to recover but will just not render anything instead. - +| Property | Default | Required | Description | +| ------------------------- | ----------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `renderer` | `instanceOf(AstRenderer)` | `false` | Used to specify a custom renderer, you can not use the rules or styles props with a custom renderer. | +| `markdownit` | `instanceOf(MarkdownIt)` | `false` | A custom markdownit instance with your configuration, default is `MarkdownIt({typographer: true})` | +| `maxTopLevelChildren` | `null` | `false` | If defined as a number will only render out first `n` many top level children, then will try to render out `topLevelMaxExceededItem` | +| `topLevelMaxExceededItem` | `...` | `false` | Will render when `maxTopLevelChildren` is hit. Make sure to give it a key! | +| `allowedImageHandlers` | `['data:image/png;base64', 'data:image/gif;base64', 'data:image/jpeg;base64', 'https://', 'http://']` | `false` | Any image that does not start with one of these will have the `defaultImageHandler` value prepended to it (unless `defaultImageHandler` is null in which case it won't try to render anything) | +| `defaultImageHandler` | `http://` | `false` | Will be prepended to an image url if it does not start with something in the `allowedImageHandlers` array, if this is set to null, it won't try to recover but will just not render anything instead. | # Syntax Support @@ -97,14 +94,13 @@ And some additional, less used options: ###### h6 Heading ``` -| iOS | Android -| --- | --- -| | +| iOS | Android | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| | |

-
Horizontal Rules

@@ -119,16 +115,13 @@ And some additional, less used options: Some text below ``` -| iOS | Android -| --- | --- -| | - +| iOS | Android | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| | |

- -
Emphasis

@@ -144,14 +137,13 @@ And some additional, less used options: ~~Strikethrough~~ ``` -| iOS | Android -| --- | --- -| | +| iOS | Android | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| | |

-
Blockquotes

@@ -161,14 +153,13 @@ And some additional, less used options: > > > ...or with spaces between arrows. ``` -| iOS | Android -| --- | --- -| | +| iOS | Android | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| | |

-
Lists

@@ -195,48 +186,47 @@ And some additional, less used options: 58. bar ``` -| iOS | Android -| --- | --- -| | +| iOS | Android | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| | |

-
Code

``` Inline \`code\` - Indented code +Indented code // Some comments line 1 of code line 2 of code line 3 of code +Block code "fences" - Block code "fences" +\`\`\` +Sample text here... +\`\`\` - \`\`\` - Sample text here... - \`\`\` +Syntax highlighting - Syntax highlighting +\`\`\` js +var foo = function (bar) { +return bar++; +}; - \`\`\` js - var foo = function (bar) { - return bar++; - }; +console.log(foo(5)); +\`\`\` - console.log(foo(5)); - \`\`\` ``` | iOS | Android | --- | --- -| | +| |

@@ -246,24 +236,26 @@ And some additional, less used options:

``` - | Option | Description | - | ------ | ----------- | - | data | path to data files to supply the data that will be passed into templates. | - | engine | engine to be used for processing templates. Handlebars is the default. | - | ext | extension to be used for dest files. | - Right aligned columns +| Option | Description | +| ------ | ------------------------------------------------------------------------- | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + +Right aligned columns + +| Option | Description | +| -----: | ------------------------------------------------------------------------: | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | - | Option | Description | - | ------:| -----------:| - | data | path to data files to supply the data that will be passed into templates. | - | engine | engine to be used for processing templates. Handlebars is the default. | - | ext | extension to be used for dest files. | ``` | iOS | Android | --- | --- -| | +| |

@@ -272,16 +264,18 @@ And some additional, less used options:

``` - [link text](https://www.google.com) - [link with title](https://www.google.com "title text!") +[link text](https://www.google.com) + +[link with title](https://www.google.com "title text!") + +Autoconverted link https://www.google.com (enable linkify to see) - Autoconverted link https://www.google.com (enable linkify to see) ``` | iOS | Android | --- | --- -| | +| |

@@ -290,21 +284,23 @@ And some additional, less used options:

``` - ![Minion](https://octodex.github.com/images/minion.png) - ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") - Like links, Images also have a footnote style syntax +![Minion](https://octodex.github.com/images/minion.png) +![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") - ![Alt text][id] +Like links, Images also have a footnote style syntax - With a reference later in the document defining the URL location: +![Alt text][id] + +With a reference later in the document defining the URL location: + +[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" - [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" ``` | iOS | Android | --- | --- -| | +| |

@@ -314,20 +310,22 @@ And some additional, less used options:

``` - Enable typographer option to see result. - (c) (C) (r) (R) (tm) (TM) (p) (P) +- +Enable typographer option to see result. - test.. test... test..... test?..... test!.... +(c) (C) (r) (R) (tm) (TM) (p) (P) +- - !!!!!! ???? ,, -- --- +test.. test... test..... test?..... test!.... - "Smartypants, double quotes" and 'single quotes' -``` +!!!!!! ???? ,, -- --- + +"Smartypants, double quotes" and 'single quotes' + +```` | iOS | Android | --- | --- -| | +| |

@@ -351,7 +349,7 @@ import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; import Markdown, { MarkdownIt } from 'react-native-markdown-display'; import blockEmbedPlugin from 'markdown-it-block-embed'; -const markdownItInstance = +const markdownItInstance = MarkdownIt({typographer: true}) .use(blockEmbedPlugin, { containerClassName: "video-embed" @@ -386,7 +384,7 @@ const App: () => React$Node = () => { export default App; -``` +```` In the console, we will see the following rendered tree: @@ -401,15 +399,13 @@ body With the following error message: ``` -Warning, unknown render rule encountered: video. 'unknown' render rule used (by default, returns null - nothing rendered) +Warning, unknown render rule encountered: video. 'unknown' render rule used (by default, returns null - nothing rendered) ``` - #### Step 2 We need to create the **render rules** and **styles** to handle this new **'video'** component - ```jsx import React from 'react'; import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; @@ -417,7 +413,7 @@ import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; import Markdown, { MarkdownIt } from 'react-native-markdown-display'; import blockEmbedPlugin from 'markdown-it-block-embed'; -const markdownItInstance = +const markdownItInstance = MarkdownIt({typographer: true}) .use(blockEmbedPlugin, { containerClassName: "video-embed" @@ -455,7 +451,7 @@ const App: () => React$Node = () => { Return a video component instead of this text component! ); } - + }} > {copy} @@ -501,14 +497,15 @@ And all of the video properties needed to render something meaningful are on the You can do some additional debugging of what the markdown instance is spitting out like this: ```jsx -import Markdown, { MarkdownIt } from 'react-native-markdown-display'; -import blockEmbedPlugin from 'markdown-it-block-embed'; +import Markdown, {MarkdownIt} from "react-native-markdown-display"; +import blockEmbedPlugin from "markdown-it-block-embed"; -const markdownItInstance = - MarkdownIt({typographer: true}) - .use(blockEmbedPlugin, { - containerClassName: "video-embed" - }); +const markdownItInstance = MarkdownIt({typographer: true}).use( + blockEmbedPlugin, + { + containerClassName: "video-embed", + }, +); const copy = ` # Some header @@ -523,7 +520,6 @@ console.log(astTree); //this contains the html that would be generated - not used by react-native-markdown-display but useful for reference const html = markdownItInstance.render(copy); console.log(html); - ``` The above code will output something like this: @@ -549,11 +545,9 @@ html:
``` -

-
All Markdown for Testing

@@ -712,7 +706,6 @@ Typographic Replacements

- # Rules and Styles ### How to style stuff @@ -723,11 +716,10 @@ Think of the implementation like applying styles in CSS. changes to the `body` e **Be careful when styling 'text':** the text rule is not applied to all rendered text, most notably list bullet points. If you want to, for instance, color all text, change the `body` style. -
Example

- + ```jsx import React from 'react'; @@ -783,7 +775,7 @@ export default App;

-### Styles +### Styles Styles are used to override how certain rules are styled. The existing implementation is [here](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/styles.js) @@ -925,46 +917,45 @@ export default App;

- ### All rules and their associated styles: -| Render Rule | Style(s) | -| ------ | ----------- | -| `body` | `body` | -| `heading1` | `heading1` | -| `heading2` | `heading2` | -| `heading3` | `heading3` | -| `heading4` | `heading4` | -| `heading5` | `heading5` | -| `heading6` | `heading6` | -| `hr` | `hr` | -| `strong` | `strong` | -| `em` | `em` | -| `s` | `s` | -| `blockquote` | `blockquote` | -| `bullet_list` | `bullet_list` | -| `ordered_list` | `ordered_list` | -| `list_item` | `list_item` - This is a special case that contains a set of pseudo classes that don't align to the render rule: `ordered_list_icon`, `ordered_list_content`, `bullet_list_icon`, `bullet_list_content` | -| `code_inline` | `code_inline` | -| `code_block` | `code_block` | -| `fence` | `fence` | -| `table` | `table` | -| `thead` | `thead` | -| `tbody` | `tbody` | -| `th` | `th` | -| `tr` | `tr` | -| `td` | `td` | -| `link` | `link` | -| `blocklink` | `blocklink` | -| `image` | `image` | -| `text` | `text` | -| `textgroup` | `textgroup` | -| `paragraph` | `paragraph` | -| `hardbreak` | `hardbreak` | -| `softbreak` | `softbreak` | -| `pre` | `pre` | -| `inline` | `inline` | -| `span` | `span` | +| Render Rule | Style(s) | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `body` | `body` | +| `heading1` | `heading1` | +| `heading2` | `heading2` | +| `heading3` | `heading3` | +| `heading4` | `heading4` | +| `heading5` | `heading5` | +| `heading6` | `heading6` | +| `hr` | `hr` | +| `strong` | `strong` | +| `em` | `em` | +| `s` | `s` | +| `blockquote` | `blockquote` | +| `bullet_list` | `bullet_list` | +| `ordered_list` | `ordered_list` | +| `list_item` | `list_item` - This is a special case that contains a set of pseudo classes that don't align to the render rule: `ordered_list_icon`, `ordered_list_content`, `bullet_list_icon`, `bullet_list_content` | +| `code_inline` | `code_inline` | +| `code_block` | `code_block` | +| `fence` | `fence` | +| `table` | `table` | +| `thead` | `thead` | +| `tbody` | `tbody` | +| `th` | `th` | +| `tr` | `tr` | +| `td` | `td` | +| `link` | `link` | +| `blocklink` | `blocklink` | +| `image` | `image` | +| `text` | `text` | +| `textgroup` | `textgroup` | +| `paragraph` | `paragraph` | +| `hardbreak` | `hardbreak` | +| `softbreak` | `softbreak` | +| `pre` | `pre` | +| `inline` | `inline` | +| `span` | `span` | # Handling Links @@ -988,7 +979,7 @@ const onLinkPress = (url) => { // some custom logic return false; } - + // return true to open with `Linking.openURL // return false to handle it yourself return true @@ -1071,7 +1062,6 @@ export default App;

- # Disabling Specific Types of Markdown You can dissable any type of markdown you want, which is very useful in a mobile environment, by passing the markdownit property like below. Note that for convenience we also export the `MarkdownIt` instance so you don't have to include it as a project dependency directly just to remove some types of markdown. @@ -1117,7 +1107,6 @@ export default App; A full list of things you can turn off is [here](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/commonmark.js) - ### Pre Processing It is possible to need to pre-process the data outside of this library ([related discussion here](https://github.com/iamacup/react-native-markdown-display/issues/79)). As a result, you can pass an AST tree directly as the children like this: @@ -1160,7 +1149,6 @@ const App: () => React$Node = () => { export default App; ``` - ### Other Notes This is a fork of [react-native-markdown-renderer](https://github.com/mientjan/react-native-markdown-renderer), a library that unfortunately has not been updated for some time so i took all of the outstanding pull requests from that library and tested + merged as necessary. diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 00000000..05647d55 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,35 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo diff --git a/demo/App.tsx b/demo/App.tsx new file mode 100644 index 00000000..d304476b --- /dev/null +++ b/demo/App.tsx @@ -0,0 +1,55 @@ +import { StatusBar } from "expo-status-bar"; +import { Text, View } from "react-native"; + +import Markdown from "@ukdanceblue/react-native-markdown-display"; +import { useState } from "react"; +import Picker from "react-native-picker-select"; +import * as Samples from "./sampleFiles"; + +const sampleList = Object.entries(Samples); + +export default function App() { + const [selectedSample, setSelectedSample] = useState(sampleList[0][1]); + + return ( + + + ({label: key, value}))} + onValueChange={(value: string) => { + setSelectedSample(value); + }} + /> + + + {selectedSample} + + + {selectedSample} + + + + ); +} diff --git a/demo/app.json b/demo/app.json new file mode 100644 index 00000000..bc8f04e8 --- /dev/null +++ b/demo/app.json @@ -0,0 +1,27 @@ +{ + "expo": { + "name": "demo", + "slug": "demo", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + } + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/demo/assets/adaptive-icon.png b/demo/assets/adaptive-icon.png new file mode 100644 index 00000000..03d6f6b6 Binary files /dev/null and b/demo/assets/adaptive-icon.png differ diff --git a/demo/assets/favicon.png b/demo/assets/favicon.png new file mode 100644 index 00000000..e75f697b Binary files /dev/null and b/demo/assets/favicon.png differ diff --git a/demo/assets/icon.png b/demo/assets/icon.png new file mode 100644 index 00000000..a0b1526f Binary files /dev/null and b/demo/assets/icon.png differ diff --git a/demo/assets/splash.png b/demo/assets/splash.png new file mode 100644 index 00000000..0e89705a Binary files /dev/null and b/demo/assets/splash.png differ diff --git a/demo/babel.config.js b/demo/babel.config.js new file mode 100644 index 00000000..2900afe9 --- /dev/null +++ b/demo/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 00000000..bdca7ce5 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,29 @@ +{ + "name": "demo", + "version": "1.0.0", + "main": "expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + "dependencies": { + "@expo/metro-runtime": "~3.2.3", + "@react-native-picker/picker": "2.7.5", + "@ukdanceblue/react-native-markdown-display": "^0.1.3", + "expo": "~51.0.28", + "expo-status-bar": "~1.12.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-native": "0.74.5", + "react-native-picker-select": "^9.3.1", + "react-native-web": "~0.19.10" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "~18.2.45", + "typescript": "^5.1.3" + }, + "private": true +} diff --git a/demo/sampleFiles.ts b/demo/sampleFiles.ts new file mode 100644 index 00000000..a0272bab --- /dev/null +++ b/demo/sampleFiles.ts @@ -0,0 +1,246 @@ +export const markdownItDemo = `--- +__Advertisement :)__ + +- __[pica](https://nodeca.github.io/pica/demo/)__ - high quality and fast image + resize in browser. +- __[babelfish](https://github.com/nodeca/babelfish/)__ - developer friendly + i18n with plurals support and easy syntax. + +You will like those projects! + +--- + +# h1 Heading 8-) +## h2 Heading +### h3 Heading +#### h4 Heading +##### h5 Heading +###### h6 Heading + + +## Horizontal Rules + +___ + +--- + +*** + + +## Typographic replacements + +Enable typographer option to see result. + +(c) (C) (r) (R) (tm) (TM) (p) (P) +- + +test.. test... test..... test?..... test!.... + +!!!!!! ???? ,, -- --- + +"Smartypants, double quotes" and 'single quotes' + + +## Emphasis + +**This is bold text** + +__This is bold text__ + +*This is italic text* + +_This is italic text_ + +~~Strikethrough~~ + + +## Blockquotes + + +> Blockquotes can also be nested... +>> ...by using additional greater-than signs right next to each other... +> > > ...or with spaces between arrows. + + +## Lists + +Unordered + ++ Create a list by starting a line with \`+\`, \`-\`, or \`*\` ++ Sub-lists are made by indenting 2 spaces: + - Marker character change forces new list start: + * Ac tristique libero volutpat at + + Facilisis in pretium nisl aliquet + - Nulla volutpat aliquam velit ++ Very easy! + +Ordered + +1. Lorem ipsum dolor sit amet +2. Consectetur adipiscing elit +3. Integer molestie lorem at massa + + +1. You can use sequential numbers... +1. ...or keep all the numbers as \`1.\` + +Start numbering with offset: + +57. foo +1. bar + + +## Code + +Inline \`code\` + +Indented code + + // Some comments + line 1 of code + line 2 of code + line 3 of code + + +Block code "fences" + +\`\`\` +Sample text here... +\`\`\` + +Syntax highlighting + +\`\`\` js +var foo = function (bar) { + return bar++; +}; + +console.log(foo(5)); +\`\`\` + +## Tables + +| Option | Description | +| ------ | ----------- | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + +Right aligned columns + +| Option | Description | +| ------:| -----------:| +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + + +## Links + +[link text](http://dev.nodeca.com) + +[link with title](http://nodeca.github.io/pica/demo/ "title text!") + +Autoconverted link https://github.com/nodeca/pica (enable linkify to see) + + +## Images + +![Minion](https://octodex.github.com/images/minion.png) +![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") + +Like links, Images also have a footnote style syntax + +![Alt text][id] + +With a reference later in the document defining the URL location: + +[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" + + +## Plugins + +The killer feature of \`markdown-it\` is very effective support of +[syntax plugins](https://www.npmjs.org/browse/keyword/markdown-it-plugin). + + +### [Emojies](https://github.com/markdown-it/markdown-it-emoji) + +> Classic markup: :wink: :cry: :laughing: :yum: +> +> Shortcuts (emoticons): :-) :-( 8-) ;) + +see [how to change output](https://github.com/markdown-it/markdown-it-emoji#change-output) with twemoji. + + +### [Subscript](https://github.com/markdown-it/markdown-it-sub) / [Superscript](https://github.com/markdown-it/markdown-it-sup) + +- 19^th^ +- H~2~O + + +### [\\](https://github.com/markdown-it/markdown-it-ins) + +++Inserted text++ + + +### [\\](https://github.com/markdown-it/markdown-it-mark) + +==Marked text== + + +### [Footnotes](https://github.com/markdown-it/markdown-it-footnote) + +Footnote 1 link[^first]. + +Footnote 2 link[^second]. + +Inline footnote^[Text of inline footnote] definition. + +Duplicated footnote reference[^second]. + +[^first]: Footnote **can have markup** + + and multiple paragraphs. + +[^second]: Footnote text. + + +### [Definition lists](https://github.com/markdown-it/markdown-it-deflist) + +Term 1 + +: Definition 1 +with lazy continuation. + +Term 2 with *inline markup* + +: Definition 2 + + { some code, part of Definition 2 } + + Third paragraph of definition 2. + +_Compact style:_ + +Term 1 + ~ Definition 1 + +Term 2 + ~ Definition 2a + ~ Definition 2b + + +### [Abbreviations](https://github.com/markdown-it/markdown-it-abbr) + +This is HTML abbreviation example. + +It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on. + +*[HTML]: Hyper Text Markup Language + +### [Custom containers](https://github.com/markdown-it/markdown-it-container) + +::: warning +*here be dragons* +::: +` \ No newline at end of file diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 00000000..b9567f60 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..bc2f9e54 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,28 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ["eslint.config.mjs"], + }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": ["error", {argsIgnorePattern: "^_"}], + }, + }, + { + ignores: ["dist/"], + }, +); diff --git a/package.json b/package.json index 455dcb34..537319d3 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,7 @@ { - "name": "@jonasmerlin/react-native-markdown-display", - "version": "1.0.2", + "name": "@ukdanceblue/react-native-markdown-display", + "version": "0.1.6", "description": "Markdown renderer for react-native, with CommonMark spec support + adds syntax extensions & sugar (URL autolinking, typographer), originally created by Mient-jan Stelling as react-native-markdown-renderer", - "main": "src/index.js", - "types": "src/index.d.ts", - "scripts": { - "lint": "eslint --fix --cache ./src" - }, - "pre-commit": [ - "lint" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/jonasmerlin/react-native-markdown-display.git" - }, "keywords": [ "react", "react-native", @@ -22,35 +10,49 @@ "commonmark", "markdown-it" ], - "author": "Mient-jan Stelling and Tom Pickard + others from the community", + "repository": { + "type": "git", + "url": "git+https://github.com/ukdanceblue/react-native-markdown-display.git" + }, + "private": false, "license": "MIT", - "bugs": { - "url": "https://github.com/jonasmerlin/react-native-markdown-display/issues" + "author": "Mient-jan Stelling and Tom Pickard + others from the community, ported to TypeScript by Tag Howard", + "type": "module", + "exports": { + ".": { + "react-native": "./dist/index.js", + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + } }, - "homepage": "https://github.com/jonasmerlin/react-native-markdown-display/", - "dependencies": { - "css-to-react-native": "^3.0.0", - "markdown-it": "^13.0.1", - "prop-types": "^15.8.1", - "react-native-fit-image": "^1.5.5" + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "check": "npm run lint && npm run format && npm run build -- --noEmit", + "format": "prettier . --check", + "format-fix": "prettier . --write", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "watch": "tsc -w" }, - "peerDependencies": { - "react": "^16.2.0 || ^18.0.0", - "react-native": ">=0.50.4" + "dependencies": { + "css-to-react-native": "^3.2.0", + "markdown-it": "^14.1.0" }, "devDependencies": { - "@types/markdown-it": "^12.2.3", - "@types/react-native": ">=0.67.7", - "@babel/core": "^7.17.10", - "@babel/runtime": "^7.17.9", - "@react-native-community/eslint-config": "^3.0.2", - "@typescript-eslint/parser": "^5.23.0", - "eslint": "^8.15.0", - "json-schema": "^0.4.0", - "pre-commit": "1.2.2", - "typescript": "^4.6.4" + "@eslint/js": "^9.13.0", + "@types/eslint__js": "^8.42.3", + "@types/markdown-it": "^14.1.2", + "@types/react": "^18.0.0", + "eslint": "^9.13.0", + "prettier": "^3.3.3", + "react-native": "^0.76.0", + "typescript": "^5.6.3", + "typescript-eslint": "^8.12.0" }, - "directories": { - "doc": "doc" + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.74.0" } } diff --git a/src/index.d.ts b/src/index.d.ts deleted file mode 100644 index ae8e8e01..00000000 --- a/src/index.d.ts +++ /dev/null @@ -1,99 +0,0 @@ -// tslint:disable:max-classes-per-file -import MarkdownIt from 'markdown-it'; -import Token from 'markdown-it/lib/token'; -import {ComponentType, ReactNode} from 'react'; -import {StyleSheet, View} from 'react-native'; - -export function getUniqueID(): string; -export function openUrl(url: string): void; - -export function hasParents(parents: any[], type: string): boolean; - -export type RenderFunction = ( - node: ASTNode, - children: ReactNode[], - parentNodes: ASTNode[], - styles: any, - styleObj?: any, - // must have this so that we can have fixed overrides with more arguments - ...args: any -) => ReactNode; - -export type RenderLinkFunction = ( - node: ASTNode, - children: ReactNode[], - parentNodes: ASTNode[], - styles: any, - onLinkPress?: (url: string) => boolean, -) => ReactNode; - -export type RenderImageFunction = ( - node: ASTNode, - children: ReactNode[], - parentNodes: ASTNode[], - styles: any, - allowedImageHandlers: string[], - defaultImageHandler: string, -) => ReactNode; - -export interface RenderRules { - [name: string]: RenderFunction | undefined; - link?: RenderLinkFunction; - blocklink?: RenderLinkFunction; - image?: RenderImageFunction; -} - -export const renderRules: RenderRules; - -export interface MarkdownParser { - parse: (value: string, options: any) => Token[]; -} - -export interface ASTNode { - type: string; - sourceType: string; // original source token name - key: string; - content: string; - markup: string; - tokenIndex: number; - index: number; - attributes: Record; - children: ASTNode[]; -} - -export class AstRenderer { - constructor(renderRules: RenderRules, style?: any); - getRenderFunction(type: string): RenderFunction; - renderNode(node: any, parentNodes: ReadonlyArray): ReactNode; - render(nodes: ReadonlyArray): View; -} - -export function parser( - source: string, - renderer: (node: ASTNode) => View, - parser: MarkdownParser, -): any; - -export function stringToTokens( - source: string, - markdownIt: MarkdownParser, -): Token[]; - -export function tokensToAST(tokens: ReadonlyArray): ASTNode[]; - -export interface MarkdownProps { - children?: ReactNode; - rules?: RenderRules; - style?: StyleSheet.NamedStyles; - renderer?: AstRenderer; - markdownit?: MarkdownIt; - mergeStyle?: boolean; - debugPrintTree?: boolean; - onLinkPress?: (url: string) => boolean; -} - -type MarkdownStatic = ComponentType; -export const Markdown: MarkdownStatic; -export type Markdown = MarkdownStatic; -export {MarkdownIt}; -export default Markdown; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 59afec7c..00000000 --- a/src/index.js +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Base Markdown component - * @author Mient-jan Stelling + contributors - */ - -import React, {useMemo} from 'react'; -import {Text, StyleSheet} from 'react-native'; -import PropTypes from 'prop-types'; -import parser from './lib/parser'; -import getUniqueID from './lib/util/getUniqueID'; -import hasParents from './lib/util/hasParents'; -import openUrl from './lib/util/openUrl'; -import tokensToAST from './lib/util/tokensToAST'; -import renderRules from './lib/renderRules'; -import AstRenderer from './lib/AstRenderer'; -import MarkdownIt from 'markdown-it'; -import removeTextStyleProps from './lib/util/removeTextStyleProps'; -import {styles} from './lib/styles'; -import {stringToTokens} from './lib/util/stringToTokens'; -import FitImage from 'react-native-fit-image'; -import textStyleProps from './lib/data/textStyleProps'; - -export { - getUniqueID, - openUrl, - hasParents, - renderRules, - AstRenderer, - parser, - stringToTokens, - tokensToAST, - MarkdownIt, - styles, - removeTextStyleProps, - FitImage, - textStyleProps, -}; - -// we use StyleSheet.flatten here to make sure we have an object, in case someone -// passes in a StyleSheet.create result to the style prop -const getStyle = (mergeStyle, style) => { - let useStyles = {}; - - if (mergeStyle === true && style !== null) { - // make sure we get anything user defuned - Object.keys(style).forEach((value) => { - useStyles[value] = { - ...StyleSheet.flatten(style[value]), - }; - }); - - // combine any existing styles - Object.keys(styles).forEach((value) => { - useStyles[value] = { - ...styles[value], - ...StyleSheet.flatten(style[value]), - }; - }); - } else { - useStyles = { - ...styles, - }; - - if (style !== null) { - Object.keys(style).forEach((value) => { - useStyles[value] = { - ...StyleSheet.flatten(style[value]), - }; - }); - } - } - - Object.keys(useStyles).forEach((value) => { - useStyles['_VIEW_SAFE_' + value] = removeTextStyleProps(useStyles[value]); - }); - - return StyleSheet.create(useStyles); -}; - -const getRenderer = ( - renderer, - rules, - style, - mergeStyle, - onLinkPress, - maxTopLevelChildren, - topLevelMaxExceededItem, - allowedImageHandlers, - defaultImageHandler, - debugPrintTree, -) => { - if (renderer && rules) { - console.warn( - 'react-native-markdown-display you are using renderer and rules at the same time. This is not possible, props.rules is ignored', - ); - } - - if (renderer && style) { - console.warn( - 'react-native-markdown-display you are using renderer and style at the same time. This is not possible, props.style is ignored', - ); - } - - // these checks are here to prevent extra overhead. - if (renderer) { - if (!(typeof renderer === 'function') || renderer instanceof AstRenderer) { - return renderer; - } else { - throw new Error( - 'Provided renderer is not compatible with function or AstRenderer. please change', - ); - } - } else { - let useStyles = getStyle(mergeStyle, style); - - return new AstRenderer( - { - ...renderRules, - ...(rules || {}), - }, - useStyles, - onLinkPress, - maxTopLevelChildren, - topLevelMaxExceededItem, - allowedImageHandlers, - defaultImageHandler, - debugPrintTree, - ); - } -}; - -const Markdown = React.memo( - ({ - children, - renderer = null, - rules = null, - style = null, - mergeStyle = true, - markdownit = MarkdownIt({ - typographer: true, - }), - onLinkPress, - maxTopLevelChildren = null, - topLevelMaxExceededItem = ..., - allowedImageHandlers = [ - 'data:image/png;base64', - 'data:image/gif;base64', - 'data:image/jpeg;base64', - 'https://', - 'http://', - ], - defaultImageHandler = 'https://', - debugPrintTree = false, - }) => { - const momoizedRenderer = useMemo( - () => - getRenderer( - renderer, - rules, - style, - mergeStyle, - onLinkPress, - maxTopLevelChildren, - topLevelMaxExceededItem, - allowedImageHandlers, - defaultImageHandler, - debugPrintTree, - ), - [ - maxTopLevelChildren, - onLinkPress, - renderer, - rules, - style, - mergeStyle, - topLevelMaxExceededItem, - allowedImageHandlers, - defaultImageHandler, - debugPrintTree, - ], - ); - - const momoizedParser = useMemo(() => markdownit, [markdownit]); - - return parser(children, momoizedRenderer.render, momoizedParser); - }, -); - -Markdown.propTypes = { - children: PropTypes.oneOfType([PropTypes.node, PropTypes.array]).isRequired, - renderer: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.instanceOf(AstRenderer), - ]), - onLinkPress: PropTypes.func, - maxTopLevelChildren: PropTypes.number, - topLevelMaxExceededItem: PropTypes.any, - rules: (props, propName, componentName) => { - let invalidProps = []; - const prop = props[propName]; - - if (!prop) { - return; - } - - if (typeof prop === 'object') { - invalidProps = Object.keys(prop).filter( - (key) => typeof prop[key] !== 'function', - ); - } - - if (typeof prop !== 'object') { - return new Error( - `Invalid prop \`${propName}\` supplied to \`${componentName}\`. Must be of shape {[index:string]:function} `, - ); - } else if (invalidProps.length > 0) { - return new Error( - `Invalid prop \`${propName}\` supplied to \`${componentName}\`. These ` + - `props are not of type function \`${invalidProps.join(', ')}\` `, - ); - } - }, - markdownit: PropTypes.instanceOf(MarkdownIt), - style: PropTypes.any, - mergeStyle: PropTypes.bool, - allowedImageHandlers: PropTypes.arrayOf(PropTypes.string), - defaultImageHandler: PropTypes.string, - debugPrintTree: PropTypes.bool, -}; - -export default Markdown; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 00000000..06e8a008 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,216 @@ +import MarkdownIt from "markdown-it"; +import type { ReactNode } from "react"; +import React, { useMemo } from "react"; +import type { TextStyle, ViewStyle } from "react-native"; +import { StyleSheet, Text } from "react-native"; + +import AstRenderer from "./lib/AstRenderer"; +import parser from "./lib/parser"; +import type { RenderRules } from "./lib/renderRules"; +import renderRules from "./lib/renderRules"; +import { styles } from "./lib/styles"; +import type { ASTNode } from "./lib/types"; +import removeTextStyleProps from "./lib/util/removeTextStyleProps"; + +function getStyle( + mergeStyle: boolean, + style: StyleSheet.NamedStyles | undefined, +): ReturnType { + let useStyles: Record = {}; + + if (mergeStyle && style != null) { + Object.keys(style).forEach((value) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + useStyles[value] = { + // @ts-expect-error this is fine + ...StyleSheet.flatten(style[value]), + }; + }); + + Object.keys(styles).forEach((value) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + useStyles[value] = { + // @ts-expect-error this is fine + ...styles[value], + // @ts-expect-error this is fine + ...StyleSheet.flatten(style[value]), + }; + }); + } else { + // @ts-expect-error this is fine + useStyles = { + ...styles, + }; + + if (style != null) { + Object.keys(style).forEach((value) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + useStyles[value] = { + // @ts-expect-error this is fine + ...StyleSheet.flatten(style[value]), + }; + }); + } + } + + Object.keys(useStyles).forEach((value) => { + useStyles[`_VIEW_SAFE_${value}`] = removeTextStyleProps(useStyles[value]); + }); + + return StyleSheet.create(useStyles); +} + +const getRenderer = ( + renderer: AstRenderer | undefined, + rules: RenderRules | undefined, + style: StyleSheet.NamedStyles | undefined, + mergeStyle: boolean, + onLinkPress: ((url: string) => boolean) | undefined, + maxTopLevelChildren: number | null, + topLevelMaxExceededItem: React.ReactNode, + allowedImageHandlers: string[], + defaultImageHandler: string, + debugPrintTree: boolean, +): AstRenderer => { + if (renderer && rules) { + console.warn( + "react-native-markdown-display you are using renderer and rules at the same time. This is not possible, props.rules is ignored", + ); + } + + if (renderer && style) { + console.warn( + "react-native-markdown-display you are using renderer and style at the same time. This is not possible, props.style is ignored", + ); + } + + if (renderer) { + if ( + !(typeof renderer === "function") || + (renderer as unknown) instanceof AstRenderer + ) { + return renderer; + } else { + throw new TypeError( + "Provided renderer is not compatible with function or AstRenderer. please change", + ); + } + } else { + const useStyles = getStyle(mergeStyle, style); + + return new AstRenderer( + { + ...renderRules, + ...(rules ?? {}), + }, + useStyles, + onLinkPress, + maxTopLevelChildren, + topLevelMaxExceededItem, + allowedImageHandlers, + defaultImageHandler, + debugPrintTree, + ); + } +}; + +export interface MarkdownProps { + children: string; + rules?: RenderRules; + style?: StyleSheet.NamedStyles; + renderer?: AstRenderer; + markdownit?: MarkdownIt; + mergeStyle?: boolean; + debugPrintTree?: boolean; + onLinkPress?: (url: string) => boolean; +} + +const Markdown: React.FC< + MarkdownProps & { + maxTopLevelChildren?: number | null; + topLevelMaxExceededItem?: React.ReactNode; + allowedImageHandlers?: string[]; + defaultImageHandler?: string; + } +> = React.memo( + ({ + children, + renderer = undefined, + rules = undefined, + style = undefined, + mergeStyle = true, + markdownit = MarkdownIt({ + typographer: true, + }), + onLinkPress, + maxTopLevelChildren = null, + topLevelMaxExceededItem = ..., + allowedImageHandlers = [ + "data:image/png;base64", + "data:image/gif;base64", + "data:image/jpeg;base64", + "https://", + "http://", + ], + defaultImageHandler = "https://", + debugPrintTree = false, + }: MarkdownProps & { + maxTopLevelChildren?: number | null; + topLevelMaxExceededItem?: React.ReactNode; + allowedImageHandlers?: string[]; + defaultImageHandler?: string; + }) => { + const momoizedRenderer = useMemo( + () => + getRenderer( + renderer, + rules, + style, + mergeStyle, + onLinkPress, + maxTopLevelChildren, + topLevelMaxExceededItem, + allowedImageHandlers, + defaultImageHandler, + debugPrintTree, + ), + [ + maxTopLevelChildren, + onLinkPress, + renderer, + rules, + style, + mergeStyle, + topLevelMaxExceededItem, + allowedImageHandlers, + defaultImageHandler, + debugPrintTree, + ], + ); + + const memoizedParser = useMemo(() => markdownit, [markdownit]); + + return parser( + children, + (nodes: readonly ASTNode[]): ReactNode => momoizedRenderer.render(nodes), + memoizedParser, + ); + }, +); + +export default Markdown; + +export { default as MarkdownIt } from "markdown-it"; +export { default as AstRenderer } from "./lib/AstRenderer"; +export { default as textStyleProps } from "./lib/data/textStyleProps"; +export { default as parser } from "./lib/parser"; +export { default as renderRules } from "./lib/renderRules"; +export { styles } from "./lib/styles"; +export type { ASTNode } from "./lib/types"; +export { default as getUniqueID } from "./lib/util/getUniqueID"; +export { default as hasParents } from "./lib/util/hasParents"; +export { default as openUrl } from "./lib/util/openUrl"; +export { default as removeTextStyleProps } from "./lib/util/removeTextStyleProps"; +export { stringToTokens } from "./lib/util/stringToTokens"; +export { default as tokensToAST } from "./lib/util/tokensToAST"; + diff --git a/src/lib/AstRenderer.js b/src/lib/AstRenderer.js deleted file mode 100644 index 608e9455..00000000 --- a/src/lib/AstRenderer.js +++ /dev/null @@ -1,186 +0,0 @@ -import {StyleSheet} from 'react-native'; - -import getUniqueID from './util/getUniqueID'; -import convertAdditionalStyles from './util/convertAdditionalStyles'; - -import textStyleProps from './data/textStyleProps'; - -export default class AstRenderer { - /** - * - * @param {Object.} renderRules - * @param {any} style - */ - constructor( - renderRules, - style, - onLinkPress, - maxTopLevelChildren, - topLevelMaxExceededItem, - allowedImageHandlers, - defaultImageHandler, - debugPrintTree, - ) { - this._renderRules = renderRules; - this._style = style; - this._onLinkPress = onLinkPress; - this._maxTopLevelChildren = maxTopLevelChildren; - this._topLevelMaxExceededItem = topLevelMaxExceededItem; - this._allowedImageHandlers = allowedImageHandlers; - this._defaultImageHandler = defaultImageHandler; - this._debugPrintTree = debugPrintTree; - } - - /** - * - * @param {string} type - * @return {string} - */ - getRenderFunction = (type) => { - const renderFunction = this._renderRules[type]; - - if (!renderFunction) { - console.warn( - `Warning, unknown render rule encountered: ${type}. 'unknown' render rule used (by default, returns null - nothing rendered)`, - ); - return this._renderRules.unknown; - } - - return renderFunction; - }; - - /** - * - * @param node - * @param parentNodes - * @return {*} - */ - renderNode = (node, parentNodes, isRoot = false) => { - const renderFunction = this.getRenderFunction(node.type); - const parents = [...parentNodes]; - - if (this._debugPrintTree === true) { - let str = ''; - - for (let a = 0; a < parents.length; a++) { - str = str + '-'; - } - - console.log(`${str}${node.type}`); - } - - parents.unshift(node); - - // calculate the children first - let children = node.children.map((value) => { - return this.renderNode(value, parents); - }); - - // render any special types of nodes that have different renderRule function signatures - - if (node.type === 'link' || node.type === 'blocklink') { - return renderFunction( - node, - children, - parentNodes, - this._style, - this._onLinkPress, - ); - } - - if (node.type === 'image') { - return renderFunction( - node, - children, - parentNodes, - this._style, - this._allowedImageHandlers, - this._defaultImageHandler, - ); - } - - // We are at the bottom of some tree - grab all the parent styles - // this effectively grabs the styles from parents and - // applies them in order of priority parent (least) to child (most) - // to allow styling global, then lower down things individually - - // we have to handle list_item seperately here because they have some child - // pseudo classes that need the additional style props from parents passed down to them - if (children.length === 0 || node.type === 'list_item') { - const styleObj = {}; - - for (let a = parentNodes.length - 1; a > -1; a--) { - // grab and additional attributes specified by markdown-it - let refStyle = {}; - - if ( - parentNodes[a].attributes && - parentNodes[a].attributes.style && - typeof parentNodes[a].attributes.style === 'string' - ) { - refStyle = convertAdditionalStyles(parentNodes[a].attributes.style); - } - - // combine in specific styles for the object - if (this._style[parentNodes[a].type]) { - refStyle = { - ...refStyle, - ...StyleSheet.flatten(this._style[parentNodes[a].type]), - }; - - // workaround for list_items and their content cascading down the tree - if (parentNodes[a].type === 'list_item') { - let contentStyle = {}; - - if (parentNodes[a + 1].type === 'bullet_list') { - contentStyle = this._style.bullet_list_content; - } else if (parentNodes[a + 1].type === 'ordered_list') { - contentStyle = this._style.ordered_list_content; - } - - refStyle = { - ...refStyle, - ...StyleSheet.flatten(contentStyle), - }; - } - } - - // then work out if any of them are text styles that should be used in the end. - const arr = Object.keys(refStyle); - - for (let b = 0; b < arr.length; b++) { - if (textStyleProps.includes(arr[b])) { - styleObj[arr[b]] = refStyle[arr[b]]; - } - } - } - - return renderFunction(node, children, parentNodes, this._style, styleObj); - } - - // cull top level children - - if ( - isRoot === true && - this._maxTopLevelChildren && - children.length > this._maxTopLevelChildren - ) { - children = children.slice(0, this._maxTopLevelChildren); - children.push(this._topLevelMaxExceededItem); - } - - // render anythign else that has a normal signature - - return renderFunction(node, children, parentNodes, this._style); - }; - - /** - * - * @param nodes - * @return {*} - */ - render = (nodes) => { - const root = {type: 'body', key: getUniqueID(), children: nodes}; - return this.renderNode(root, [], true); - }; -} diff --git a/src/lib/AstRenderer.ts b/src/lib/AstRenderer.ts new file mode 100644 index 00000000..003cc62e --- /dev/null +++ b/src/lib/AstRenderer.ts @@ -0,0 +1,173 @@ +import {StyleSheet, ViewStyle} from "react-native"; + +import convertAdditionalStyles from "./util/convertAdditionalStyles"; +import getUniqueID from "./util/getUniqueID"; + +import {ReactNode} from "react"; +import textStyleProps from "./data/textStyleProps"; +import {RenderRules} from "./renderRules"; +import {ASTNode} from "./types"; + +export default class AstRenderer { + constructor( + private renderRules: RenderRules, + private style: Record, + private onLinkPress: ((url: string) => boolean) | undefined, + private maxTopLevelChildren: number | null, + private topLevelMaxExceededItem: React.ReactNode, + private allowedImageHandlers: string[], + private defaultImageHandler: string, + private debugPrintTree: boolean, + ) {} + + getRenderFunction(type: R): RenderRules[R] { + const renderFunction = this.renderRules[type]; + + if (!renderFunction as unknown) { + console.warn( + `Warning, unknown render rule encountered: ${type}. 'unknown' render rule used (by default, returns null - nothing rendered)`, + ); + return this.renderRules.unknown; + } + + return renderFunction; + } + + renderNode(node: ASTNode, parentNodes: ASTNode[], isRoot = false): ReactNode { + const parents = [...parentNodes]; + + if (this.debugPrintTree) { + let str = ""; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of parents) { + str = str + "-"; + } + + console.log(`${str}${node.type}`); + } + + parents.unshift(node); + + // calculate the children first + let children = node.children.map((value) => { + return this.renderNode(value, parents); + }); + + // render any special types of nodes that have different renderRule function signatures + + if (node.type === "link" || node.type === "blocklink") { + return this.getRenderFunction(node.type)( + node, + children, + parentNodes, + this.style, + this.onLinkPress, + ); + } + + if (node.type === "image") { + return this.getRenderFunction(node.type)( + node, + children, + parentNodes, + this.style, + this.allowedImageHandlers, + this.defaultImageHandler, + ); + } + + // We are at the bottom of some tree - grab all the parent styles + // this effectively grabs the styles from parents and + // applies them in order of priority parent (least) to child (most) + // to allow styling global, then lower down things individually + + // we have to handle list_item seperately here because they have some child + // pseudo classes that need the additional style props from parents passed down to them + if (children.length === 0 || node.type === "list_item") { + const styleObj = {}; + + for (let a = parentNodes.length - 1; a > -1; a--) { + // grab and additional attributes specified by markdown-it + let refStyle = {}; + + if ( + parentNodes[a].attributes?.style && + typeof parentNodes[a].attributes?.style === "string" + ) { + refStyle = convertAdditionalStyles( + String(parentNodes[a].attributes?.style), + ); + } + + // combine in specific styles for the object + if (this.style[parentNodes[a].type] as unknown) { + refStyle = { + ...refStyle, + ...StyleSheet.flatten(this.style[parentNodes[a].type]), + }; + + // workaround for list_items and their content cascading down the tree + if (parentNodes[a].type === "list_item") { + let contentStyle = {}; + + if (parentNodes[a + 1].type === "bullet_list") { + contentStyle = this.style.bullet_list_content; + } else if (parentNodes[a + 1].type === "ordered_list") { + contentStyle = this.style.ordered_list_content; + } + + refStyle = { + ...refStyle, + ...StyleSheet.flatten(contentStyle), + }; + } + } + + // then work out if any of them are text styles that should be used in the end. + const arr = Object.keys(refStyle) as (keyof ViewStyle)[]; + + for (const key of arr) { + if (textStyleProps.includes(key)) { + // @ts-expect-error this is fine + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + styleObj[key] = refStyle[key]; + } + } + } + + return this.getRenderFunction(node.type)( + node, + children, + parentNodes, + this.style, + styleObj, + ); + } + + // cull top level children + + if ( + isRoot && + this.maxTopLevelChildren && + children.length > this.maxTopLevelChildren + ) { + children = children.slice(0, this.maxTopLevelChildren); + children.push(this.topLevelMaxExceededItem); + } + + // render anythign else that has a normal signature + + return this.getRenderFunction(node.type)( + node, + children, + parentNodes, + this.style, + ); + } + + render(nodes: readonly ASTNode[]): ReactNode { + const root: ASTNode = {type: "body", key: getUniqueID(), children: nodes}; + return this.renderNode(root, [], true); + } +} diff --git a/src/lib/data/textStyleProps.js b/src/lib/data/textStyleProps.js deleted file mode 100644 index c48139d0..00000000 --- a/src/lib/data/textStyleProps.js +++ /dev/null @@ -1,21 +0,0 @@ -export default [ - 'textShadowOffset', - 'color', - 'fontSize', - 'fontStyle', - 'fontWeight', - 'lineHeight', - 'textAlign', - 'textDecorationLine', - 'textShadowColor', - 'fontFamily', - 'textShadowRadius', - 'includeFontPadding', - 'textAlignVertical', - 'fontVariant', - 'letterSpacing', - 'textDecorationColor', - 'textDecorationStyle', - 'textTransform', - 'writingDirection', -]; diff --git a/src/lib/data/textStyleProps.ts b/src/lib/data/textStyleProps.ts new file mode 100644 index 00000000..a9350870 --- /dev/null +++ b/src/lib/data/textStyleProps.ts @@ -0,0 +1,21 @@ +export default [ + "textShadowOffset", + "color", + "fontSize", + "fontStyle", + "fontWeight", + "lineHeight", + "textAlign", + "textDecorationLine", + "textShadowColor", + "fontFamily", + "textShadowRadius", + "includeFontPadding", + "textAlignVertical", + "fontVariant", + "letterSpacing", + "textDecorationColor", + "textDecorationStyle", + "textTransform", + "writingDirection", +]; diff --git a/src/lib/parser.js b/src/lib/parser.js deleted file mode 100644 index 4e897933..00000000 --- a/src/lib/parser.js +++ /dev/null @@ -1,27 +0,0 @@ -import tokensToAST from './util/tokensToAST'; -import {stringToTokens} from './util/stringToTokens'; -import {cleanupTokens} from './util/cleanupTokens'; -import groupTextTokens from './util/groupTextTokens'; -import omitListItemParagraph from './util/omitListItemParagraph'; - -/** - * - * @param {string} source - * @param {function} [renderer] - * @param {AstRenderer} [markdownIt] - * @return {View} - */ -export default function parser(source, renderer, markdownIt) { - if (Array.isArray(source)) { - return renderer(source); - } - - let tokens = stringToTokens(source, markdownIt); - tokens = cleanupTokens(tokens); - tokens = groupTextTokens(tokens); - tokens = omitListItemParagraph(tokens); - - const astTree = tokensToAST(tokens); - - return renderer(astTree); -} diff --git a/src/lib/parser.ts b/src/lib/parser.ts new file mode 100644 index 00000000..b5cce86b --- /dev/null +++ b/src/lib/parser.ts @@ -0,0 +1,27 @@ +import MarkdownIt, { Token } from "markdown-it"; +import { ReactNode } from "react"; +import { ASTNode } from "./types"; +import { cleanupTokens } from "./util/cleanupTokens"; +import groupTextTokens from "./util/groupTextTokens"; +import omitListItemParagraph from "./util/omitListItemParagraph"; +import { stringToTokens } from "./util/stringToTokens"; +import tokensToAST from "./util/tokensToAST"; + +export default function parser( + source: string, + renderer: (source: ASTNode[]) => ReactNode, + markdownIt: MarkdownIt, +): ReactNode { + if (Array.isArray(source)) { + return renderer(source); + } + + let tokens: (Token)[] = stringToTokens(source, markdownIt); + tokens = cleanupTokens(tokens); + tokens = groupTextTokens(tokens); + tokens = omitListItemParagraph(tokens); + + const astTree = tokensToAST(tokens); + + return renderer(astTree); +} diff --git a/src/lib/renderRules.js b/src/lib/renderRules.js deleted file mode 100644 index 6f2ed8d4..00000000 --- a/src/lib/renderRules.js +++ /dev/null @@ -1,347 +0,0 @@ -import React from 'react'; -import { - Text, - TouchableWithoutFeedback, - View, - Platform, - StyleSheet, -} from 'react-native'; -import FitImage from 'react-native-fit-image'; - -import openUrl from './util/openUrl'; -import hasParents from './util/hasParents'; - -import textStyleProps from './data/textStyleProps'; - -const renderRules = { - // when unknown elements are introduced, so it wont break - unknown: (node, children, parent, styles) => null, - - // The main container - body: (node, children, parent, styles) => ( - - {children} - - ), - - // Headings - heading1: (node, children, parent, styles) => ( - - {children} - - ), - heading2: (node, children, parent, styles) => ( - - {children} - - ), - heading3: (node, children, parent, styles) => ( - - {children} - - ), - heading4: (node, children, parent, styles) => ( - - {children} - - ), - heading5: (node, children, parent, styles) => ( - - {children} - - ), - heading6: (node, children, parent, styles) => ( - - {children} - - ), - - // Horizontal Rule - hr: (node, children, parent, styles) => ( - - ), - - // Emphasis - strong: (node, children, parent, styles) => ( - - {children} - - ), - em: (node, children, parent, styles) => ( - - {children} - - ), - s: (node, children, parent, styles) => ( - - {children} - - ), - - // Blockquotes - blockquote: (node, children, parent, styles) => ( - - {children} - - ), - - // Lists - bullet_list: (node, children, parent, styles) => ( - - {children} - - ), - ordered_list: (node, children, parent, styles) => ( - - {children} - - ), - // this is a unique and quite annoying render rule because it has - // child items that can be styled (the list icon and the list content) - // outside of the AST tree so there are some work arounds in the - // AST renderer specifically to get the styling right here - list_item: (node, children, parent, styles, inheritedStyles = {}) => { - // we need to grab any text specific stuff here that is applied on the list_item style - // and apply it onto bullet_list_icon. the AST renderer has some workaround code to make - // the content classes apply correctly to the child AST tree items as well - // as code that forces the creation of the inheritedStyles object for list_items - const refStyle = { - ...inheritedStyles, - ...StyleSheet.flatten(styles.list_item), - }; - - const arr = Object.keys(refStyle); - - const modifiedInheritedStylesObj = {}; - - for (let b = 0; b < arr.length; b++) { - if (textStyleProps.includes(arr[b])) { - modifiedInheritedStylesObj[arr[b]] = refStyle[arr[b]]; - } - } - - if (hasParents(parent, 'bullet_list')) { - return ( - - - {Platform.select({ - android: '\u2022', - ios: '\u00B7', - default: '\u2022', - })} - - {children} - - ); - } - - if (hasParents(parent, 'ordered_list')) { - const orderedListIndex = parent.findIndex( - (el) => el.type === 'ordered_list', - ); - - const orderedList = parent[orderedListIndex]; - let listItemNumber; - - if (orderedList.attributes && orderedList.attributes.start) { - listItemNumber = orderedList.attributes.start + node.index; - } else { - listItemNumber = node.index + 1; - } - - return ( - - - {listItemNumber} - {node.markup} - - {children} - - ); - } - - // we should not need this, but just in case - return ( - - {children} - - ); - }, - - // Code - code_inline: (node, children, parent, styles, inheritedStyles = {}) => ( - - {node.content} - - ), - code_block: (node, children, parent, styles, inheritedStyles = {}) => { - // we trim new lines off the end of code blocks because the parser sends an extra one. - let {content} = node; - - if ( - typeof node.content === 'string' && - node.content.charAt(node.content.length - 1) === '\n' - ) { - content = node.content.substring(0, node.content.length - 1); - } - - return ( - - {content} - - ); - }, - fence: (node, children, parent, styles, inheritedStyles = {}) => { - // we trim new lines off the end of code blocks because the parser sends an extra one. - let {content} = node; - - if ( - typeof node.content === 'string' && - node.content.charAt(node.content.length - 1) === '\n' - ) { - content = node.content.substring(0, node.content.length - 1); - } - - return ( - - {content} - - ); - }, - - // Tables - table: (node, children, parent, styles) => ( - - {children} - - ), - thead: (node, children, parent, styles) => ( - - {children} - - ), - tbody: (node, children, parent, styles) => ( - - {children} - - ), - th: (node, children, parent, styles) => ( - - {children} - - ), - tr: (node, children, parent, styles) => ( - - {children} - - ), - td: (node, children, parent, styles) => ( - - {children} - - ), - - // Links - link: (node, children, parent, styles, onLinkPress) => ( - openUrl(node.attributes.href, onLinkPress)}> - {children} - - ), - blocklink: (node, children, parent, styles, onLinkPress) => ( - openUrl(node.attributes.href, onLinkPress)} - style={styles.blocklink}> - {children} - - ), - - // Images - image: ( - node, - children, - parent, - styles, - allowedImageHandlers, - defaultImageHandler, - ) => { - const {src, alt} = node.attributes; - - // we check that the source starts with at least one of the elements in allowedImageHandlers - const show = - allowedImageHandlers.filter((value) => { - return src.toLowerCase().startsWith(value.toLowerCase()); - }).length > 0; - - if (show === false && defaultImageHandler === null) { - return null; - } - - const imageProps = { - indicator: true, - key: node.key, - style: styles._VIEW_SAFE_image, - source: { - uri: show === true ? src : `${defaultImageHandler}${src}`, - }, - }; - - if (alt) { - imageProps.accessible = true; - imageProps.accessibilityLabel = alt; - } - - return ; - }, - - // Text Output - text: (node, children, parent, styles, inheritedStyles = {}) => ( - - {node.content} - - ), - textgroup: (node, children, parent, styles) => ( - - {children} - - ), - paragraph: (node, children, parent, styles) => ( - - {children} - - ), - hardbreak: (node, children, parent, styles) => ( - - {'\n'} - - ), - softbreak: (node, children, parent, styles) => ( - - {'\n'} - - ), - - // Believe these are never used but retained for completeness - pre: (node, children, parent, styles) => ( - - {children} - - ), - inline: (node, children, parent, styles) => ( - - {children} - - ), - span: (node, children, parent, styles) => ( - - {children} - - ), -}; - -export default renderRules; diff --git a/src/lib/renderRules.tsx b/src/lib/renderRules.tsx new file mode 100644 index 00000000..fbb64e4a --- /dev/null +++ b/src/lib/renderRules.tsx @@ -0,0 +1,455 @@ +import type { ReactNode } from "react"; +import type { ViewStyle } from "react-native"; +import { + Image, + Platform, + StyleSheet, + Text, + TouchableWithoutFeedback, + View, +} from "react-native"; + +import textStyleProps from "./data/textStyleProps"; +import type { ASTNode } from "./types"; +import hasParents from "./util/hasParents"; +import openUrl from "./util/openUrl"; + +export type RenderFunction = ( + node: ASTNode, + children: ReactNode[], + parentNodes: ASTNode[], + styles: Record, +) => ReactNode; + +export type InheritingRenderFunction = ( + node: ASTNode, + children: ReactNode[], + parentNodes: ASTNode[], + styles: Record, + inheritedStyles?: Record, +) => ReactNode; + +export type RenderLinkFunction = ( + node: ASTNode, + children: ReactNode[], + parentNodes: ASTNode[], + styles: Record, + onLinkPress?: (url: string) => boolean, +) => ReactNode; + +export type RenderImageFunction = ( + node: ASTNode, + children: ReactNode[], + parentNodes: ASTNode[], + styles: Record, + allowedImageHandlers: string[], + defaultImageHandler: string | null, +) => ReactNode; + +export type SomeRenderFunction = + | RenderFunction + | InheritingRenderFunction + | RenderLinkFunction + | RenderImageFunction; + +export interface RenderRules { + unknown: RenderFunction; + body: RenderFunction; + heading1: RenderFunction; + heading2: RenderFunction; + heading3: RenderFunction; + heading4: RenderFunction; + heading5: RenderFunction; + heading6: RenderFunction; + hr: RenderFunction; + strong: RenderFunction; + em: RenderFunction; + s: RenderFunction; + blockquote: RenderFunction; + bullet_list: RenderFunction; + ordered_list: RenderFunction; + list_item: InheritingRenderFunction; + code_inline: InheritingRenderFunction; + code_block: InheritingRenderFunction; + fence: InheritingRenderFunction; + table: RenderFunction; + thead: RenderFunction; + tbody: RenderFunction; + th: RenderFunction; + tr: RenderFunction; + td: RenderFunction; + link: RenderLinkFunction; + blocklink: RenderLinkFunction; + image: RenderImageFunction; + text: InheritingRenderFunction; + textgroup: RenderFunction; + paragraph: RenderFunction; + hardbreak: RenderFunction; + softbreak: RenderFunction; + pre: RenderFunction; + inline: RenderFunction; + span: RenderFunction; +} + +const renderRules: RenderRules = { + // when unknown elements are introduced, so it wont break + unknown: () => null, + + // The main container + body: (node, children, _parent, styles) => ( + + {children} + + ), + + // Headings + heading1: (node, children, _parent, styles) => ( + + {children} + + ), + heading2: (node, children, _parent, styles) => ( + + {children} + + ), + heading3: (node, children, _parent, styles) => ( + + {children} + + ), + heading4: (node, children, _parent, styles) => ( + + {children} + + ), + heading5: (node, children, _parent, styles) => ( + + {children} + + ), + heading6: (node, children, _parent, styles) => ( + + {children} + + ), + + // Horizontal Rule + hr: (node, _children, _parent, styles) => ( + + ), + + // Emphasis + strong: (node, children, _parent, styles) => ( + + {children} + + ), + em: (node, children, _parent, styles) => ( + + {children} + + ), + s: (node, children, _parent, styles) => ( + + {children} + + ), + + // Blockquotes + blockquote: (node, children, _parent, styles) => ( + + {children} + + ), + + // Lists + bullet_list: (node, children, _parent, styles) => ( + + {children} + + ), + ordered_list: (node, children, _parent, styles) => ( + + {children} + + ), + // this is a unique and quite annoying render rule because it has + // child items that can be styled (the list icon and the list content) + // outside of the AST tree so there are some work arounds in the + // AST renderer specifically to get the styling right here + list_item: (node, children, parent, styles, inheritedStyles = {}) => { + // we need to grab any text specific stuff here that is applied on the list_item style + // and apply it onto bullet_list_icon. the AST renderer has some workaround code to make + // the content classes apply correctly to the child AST tree items as well + // as code that forces the creation of the inheritedStyles object for list_items + const refStyle = { + ...inheritedStyles, + ...StyleSheet.flatten(styles.list_item), + }; + + const arr = Object.keys(refStyle); + + const modifiedInheritedStylesObj: Record = {}; + + for (const key of arr) { + if (textStyleProps.includes(key)) { + // @ts-expect-error - this is fine + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + modifiedInheritedStylesObj[key] = refStyle[key]; + } + } + + if (hasParents(parent, "bullet_list")) { + return ( + + + {Platform.select({ + android: "\u2022", + ios: "\u00B7", + default: "\u2022", + })} + + {children} + + ); + } + + if (hasParents(parent, "ordered_list")) { + const orderedListIndex = parent.findIndex( + (el) => el.type === "ordered_list", + ); + + const orderedList = parent[orderedListIndex]; + let listItemNumber; + + // @ts-expect-error - this is fine + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, prefer-const + listItemNumber = orderedList.attributes.start + ? // @ts-expect-error - this is fine + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + orderedList.attributes.start + node.index + : (node.index ?? 0) + 1; + + return ( + + + {listItemNumber} + {node.markup} + + {children} + + ); + } + + // we should not need this, but just in case + return ( + + {children} + + ); + }, + + // Code + code_inline: (node, _children, _parent, styles, inheritedStyles = {}) => ( + + {node.content} + + ), + code_block: ( + {content, key}, + _children, + _parent, + styles, + inheritedStyles = {}, + ) => { + // we trim new lines off the end of code blocks because the parser sends an extra one. + + if (typeof content === "string" && content.endsWith("\n")) { + content = content.substring(0, content.length - 1); + } + + return ( + + {content} + + ); + }, + fence: ({content, key}, _children, _parent, styles, inheritedStyles = {}) => { + // we trim new lines off the end of code blocks because the parser sends an extra one. + + if (typeof content === "string" && content.endsWith("\n")) { + content = content.substring(0, content.length - 1); + } + + return ( + + {content} + + ); + }, + + // Tables + table: (node, children, _parent, styles) => ( + + {children} + + ), + thead: (node, children, _parent, styles) => ( + + {children} + + ), + tbody: (node, children, _parent, styles) => ( + + {children} + + ), + th: (node, children, _parent, styles) => ( + + {children} + + ), + tr: (node, children, _parent, styles) => ( + + {children} + + ), + td: (node, children, _parent, styles) => ( + + {children} + + ), + + // Links + link: (node, children, _parent, styles, onLinkPress) => ( + { + openUrl(String(node.attributes?.href), onLinkPress); + }) + } + > + {children} + + ), + blocklink: (node, children, _parent, styles, onLinkPress) => ( + { + openUrl(String(node.attributes?.href), onLinkPress); + }) + } + style={styles.blocklink} + > + {children} + + ), + + // Images + image: ( + node, + _children, + _parent, + styles, + allowedImageHandlers, + defaultImageHandler, + ) => { + if (!node.attributes) { + return null; + } + + const {src, alt} = node.attributes; + + if (typeof src !== "string") { + return null; + } + + // we check that the source starts with at least one of the elements in allowedImageHandlers + const show = allowedImageHandlers.some((value) => { + return src.toLowerCase().startsWith(value.toLowerCase()); + }); + + if (!show && defaultImageHandler === null) { + return null; + } + + const imageProps = { + indicator: true, + key: node.key, + style: styles._VIEW_SAFE_image, + source: { + uri: show ? src : `${String(defaultImageHandler)}${src}`, + }, + }; + + if (alt) { + // @ts-expect-error - this is fine + imageProps.accessible = true; + // @ts-expect-error - this is fine + imageProps.accessibilityLabel = alt; + } + + if (imageProps.style.overflow === "scroll") { + throw new Error( + "Image style property 'overflow' is set to 'scroll'. This is not supported", + ); + } + // @ts-expect-error - this is fine + return ; + }, + + // Text Output + text: (node, _children, _parent, styles, inheritedStyles = {}) => ( + + {node.content} + + ), + textgroup: (node, children, _parent, styles) => ( + + {children} + + ), + paragraph: (node, children, _parent, styles) => ( + + {children} + + ), + hardbreak: (node, _children, _parent, styles) => ( + + {"\n"} + + ), + softbreak: (node, _children, _parent, styles) => ( + + {"\n"} + + ), + + // Believe these are never used but retained for completeness + pre: (node, children, _parent, styles) => ( + + {children} + + ), + inline: (node, children, _parent, styles) => ( + + {children} + + ), + span: (node, children, _parent, styles) => ( + + {children} + + ), +}; + +export default renderRules; diff --git a/src/lib/styles.js b/src/lib/styles.ts similarity index 63% rename from src/lib/styles.js rename to src/lib/styles.ts index e1b5e39e..1590643b 100644 --- a/src/lib/styles.js +++ b/src/lib/styles.ts @@ -1,4 +1,4 @@ -import {Platform} from 'react-native'; +import {Platform} from "react-native"; // this is converted to a stylesheet internally at run time with StyleSheet.create( export const styles = { @@ -7,51 +7,51 @@ export const styles = { // Headings heading1: { - flexDirection: 'row', + flexDirection: "row", fontSize: 32, }, heading2: { - flexDirection: 'row', + flexDirection: "row", fontSize: 24, }, heading3: { - flexDirection: 'row', + flexDirection: "row", fontSize: 18, }, heading4: { - flexDirection: 'row', + flexDirection: "row", fontSize: 16, }, heading5: { - flexDirection: 'row', + flexDirection: "row", fontSize: 13, }, heading6: { - flexDirection: 'row', + flexDirection: "row", fontSize: 11, }, // Horizontal Rule hr: { - backgroundColor: '#000000', + backgroundColor: "#000000", height: 1, }, // Emphasis strong: { - fontWeight: 'bold', + fontWeight: "bold", }, em: { - fontStyle: 'italic', + fontStyle: "italic", }, s: { - textDecorationLine: 'line-through', + textDecorationLine: "line-through", }, // Blockquotes blockquote: { - backgroundColor: '#F5F5F5', - borderColor: '#CCC', + backgroundColor: "#F5F5F5", + borderColor: "#CCC", borderLeftWidth: 4, marginLeft: 5, paddingHorizontal: 5, @@ -61,8 +61,8 @@ export const styles = { bullet_list: {}, ordered_list: {}, list_item: { - flexDirection: 'row', - justifyContent: 'flex-start', + flexDirection: "row", + justifyContent: "flex-start", }, // @pseudo class, does not have a unique render rule bullet_list_icon: { @@ -86,46 +86,46 @@ export const styles = { // Code code_inline: { borderWidth: 1, - borderColor: '#CCCCCC', - backgroundColor: '#f5f5f5', + borderColor: "#CCCCCC", + backgroundColor: "#f5f5f5", padding: 10, borderRadius: 4, ...Platform.select({ - ['ios']: { - fontFamily: 'Courier', + ["ios"]: { + fontFamily: "Courier", }, - ['android']: { - fontFamily: 'monospace', + ["android"]: { + fontFamily: "monospace", }, }), }, code_block: { borderWidth: 1, - borderColor: '#CCCCCC', - backgroundColor: '#f5f5f5', + borderColor: "#CCCCCC", + backgroundColor: "#f5f5f5", padding: 10, borderRadius: 4, ...Platform.select({ - ['ios']: { - fontFamily: 'Courier', + ["ios"]: { + fontFamily: "Courier", }, - ['android']: { - fontFamily: 'monospace', + ["android"]: { + fontFamily: "monospace", }, }), }, fence: { borderWidth: 1, - borderColor: '#CCCCCC', - backgroundColor: '#f5f5f5', + borderColor: "#CCCCCC", + backgroundColor: "#f5f5f5", padding: 10, borderRadius: 4, ...Platform.select({ - ['ios']: { - fontFamily: 'Courier', + ["ios"]: { + fontFamily: "Courier", }, - ['android']: { - fontFamily: 'monospace', + ["android"]: { + fontFamily: "monospace", }, }), }, @@ -133,7 +133,7 @@ export const styles = { // Tables table: { borderWidth: 1, - borderColor: '#000000', + borderColor: "#000000", borderRadius: 3, }, thead: {}, @@ -144,8 +144,8 @@ export const styles = { }, tr: { borderBottomWidth: 1, - borderColor: '#000000', - flexDirection: 'row', + borderColor: "#000000", + flexDirection: "row", }, td: { flex: 1, @@ -154,11 +154,11 @@ export const styles = { // Links link: { - textDecorationLine: 'underline', + textDecorationLine: "underline", }, blocklink: { flex: 1, - borderColor: '#000000', + borderColor: "#000000", borderBottomWidth: 1, }, @@ -173,14 +173,14 @@ export const styles = { paragraph: { marginTop: 10, marginBottom: 10, - flexWrap: 'wrap', - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'flex-start', - width: '100%', + flexWrap: "wrap", + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "flex-start", + width: "100%", }, hardbreak: { - width: '100%', + width: "100%", height: 1, }, softbreak: {}, diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 00000000..d5cd4d07 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,16 @@ +import type {RenderRules} from "./renderRules"; + +export interface ASTNode { + type: T; + sourceType?: string; // original source token name + sourceInfo?: string; + sourceMeta?: unknown; + block?: boolean; + key: string; + content?: string; + markup?: string; + tokenIndex?: number; + index?: number; + attributes?: Record; + children: readonly ASTNode[]; +} diff --git a/src/lib/util/Token.js b/src/lib/util/Token.js deleted file mode 100644 index 9a7c18c5..00000000 --- a/src/lib/util/Token.js +++ /dev/null @@ -1,8 +0,0 @@ -export default class Token { - constructor(type, nesting = 0, children = null, block = false) { - this.type = type; - this.nesting = nesting; - this.children = children; - this.block = block; - } -} diff --git a/src/lib/util/cleanupTokens.js b/src/lib/util/cleanupTokens.ts similarity index 50% rename from src/lib/util/cleanupTokens.js rename to src/lib/util/cleanupTokens.ts index 621b761d..e4c03dff 100644 --- a/src/lib/util/cleanupTokens.js +++ b/src/lib/util/cleanupTokens.ts @@ -1,22 +1,26 @@ -import getTokenTypeByToken from './getTokenTypeByToken'; -import flattenInlineTokens from './flattenInlineTokens'; -import renderInlineAsText from './renderInlineAsText'; - -export function cleanupTokens(tokens) { +import { Token } from "markdown-it"; +import flattenInlineTokens from "./flattenInlineTokens"; +import getTokenTypeByToken from "./getTokenTypeByToken"; +import renderInlineAsText from "./renderInlineAsText"; + +export function cleanupTokens( + tokens: (Token)[], +): (Token)[] { tokens = flattenInlineTokens(tokens); tokens.forEach((token) => { token.type = getTokenTypeByToken(token); // set image and hardbreak to block elements - if (token.type === 'image' || token.type === 'hardbreak') { + if (token.type === "image" || token.type === "hardbreak") { token.block = true; } // Set img alt text - if (token.type === 'image') { - token.attrs[token.attrIndex('alt')][1] = renderInlineAsText( - token.children, - ); + if (token.type === "image") { + if (token.attrs) + token.attrs[token.attrIndex("alt")][1] = renderInlineAsText( + token.children ?? [], + ); } }); @@ -24,26 +28,26 @@ export function cleanupTokens(tokens) { * changing a link token to a blocklink to fix issue where link tokens with * nested non text tokens breaks component */ - const stack = []; - tokens = tokens.reduce((acc, token, index) => { - if (token.type === 'link' && token.nesting === 1) { + const stack: (Token)[] = []; + tokens = tokens.reduce<(Token)[]>((acc, token) => { + if (token.type === "link" && token.nesting === 1) { stack.push(token); } else if ( stack.length > 0 && - token.type === 'link' && + token.type === "link" && token.nesting === -1 ) { if (stack.some((stackToken) => stackToken.block)) { - stack[0].type = 'blocklink'; + stack[0].type = "blocklink"; stack[0].block = true; - token.type = 'blocklink'; + token.type = "blocklink"; token.block = true; } stack.push(token); while (stack.length) { - acc.push(stack.shift()); + acc.push(stack.shift()!); } } else if (stack.length > 0) { stack.push(token); diff --git a/src/lib/util/convertAdditionalStyles.js b/src/lib/util/convertAdditionalStyles.js deleted file mode 100644 index 3cf4e40e..00000000 --- a/src/lib/util/convertAdditionalStyles.js +++ /dev/null @@ -1,25 +0,0 @@ -import cssToReactNative from 'css-to-react-native'; - -export default function convertAdditionalStyles(style) { - const rules = style.split(';'); - - const tuples = rules - .map((rule) => { - let [key, value] = rule.split(':'); - - if (key && value) { - key = key.trim(); - value = value.trim(); - return [key, value]; - } else { - return null; - } - }) - .filter((x) => { - return x != null; - }); - - const conv = cssToReactNative(tuples); - - return conv; -} diff --git a/src/lib/util/convertAdditionalStyles.ts b/src/lib/util/convertAdditionalStyles.ts new file mode 100644 index 00000000..19d38b0e --- /dev/null +++ b/src/lib/util/convertAdditionalStyles.ts @@ -0,0 +1,25 @@ +import cssToReactNative, {Style, StyleTuple} from "css-to-react-native"; + +export default function convertAdditionalStyles(style: string): Style { + const rules = style.split(";"); + + const tuples: StyleTuple[] = rules + .map((rule): StyleTuple | null => { + let [key, value] = rule.split(":"); + + if (key && value) { + key = key.trim(); + value = value.trim(); + return [key, value]; + } else { + return null; + } + }) + .filter((x) => { + return x != null; + }); + + const conv = cssToReactNative(tuples); + + return conv; +} diff --git a/src/lib/util/flattenInlineTokens.js b/src/lib/util/flattenInlineTokens.js deleted file mode 100644 index 4fe3b605..00000000 --- a/src/lib/util/flattenInlineTokens.js +++ /dev/null @@ -1,14 +0,0 @@ -export default function flattenTokens(tokens) { - return tokens.reduce((acc, curr) => { - if (curr.type === 'inline' && curr.children && curr.children.length > 0) { - const children = flattenTokens(curr.children); - while (children.length) { - acc.push(children.shift()); - } - } else { - acc.push(curr); - } - - return acc; - }, []); -} diff --git a/src/lib/util/flattenInlineTokens.ts b/src/lib/util/flattenInlineTokens.ts new file mode 100644 index 00000000..35561f37 --- /dev/null +++ b/src/lib/util/flattenInlineTokens.ts @@ -0,0 +1,18 @@ +import { Token } from "markdown-it"; + +export default function flattenTokens( + tokens: (Token)[], +): (Token )[] { + return tokens.reduce((acc, curr) => { + if (curr.type === "inline" && curr.children && curr.children.length > 0) { + const children = flattenTokens(curr.children); + while (children.length) { + acc.push(children.shift()!); + } + } else { + acc.push(curr); + } + + return acc; + }, []); +} diff --git a/src/lib/util/getTokenTypeByToken.js b/src/lib/util/getTokenTypeByToken.ts similarity index 52% rename from src/lib/util/getTokenTypeByToken.js rename to src/lib/util/getTokenTypeByToken.ts index c4955105..03f3a2c8 100644 --- a/src/lib/util/getTokenTypeByToken.js +++ b/src/lib/util/getTokenTypeByToken.ts @@ -1,3 +1,6 @@ +import { Token } from "markdown-it"; +import { RenderRules } from "../renderRules"; + const regSelectOpenClose = /_open|_close/g; /** @@ -23,16 +26,20 @@ const regSelectOpenClose = /_open|_close/g; * @param token * @return {String} */ -export default function getTokenTypeByToken(token) { - let cleanedType = 'unknown'; +export default function getTokenTypeByToken( + token: Token, +): keyof RenderRules { + let cleanedType: keyof RenderRules | "heading" = "unknown"; if (token.type) { - cleanedType = token.type.replace(regSelectOpenClose, ''); + cleanedType = token.type.replace(regSelectOpenClose, "") as + | keyof RenderRules + | "heading"; } switch (cleanedType) { - case 'heading': { - cleanedType = `${cleanedType}${token.tag.substr(1)}`; + case "heading": { + cleanedType = `${cleanedType}${token.tag.substring(1) as "1" | "2" | "3" | "4" | "5" | "6"}`; break; } default: { diff --git a/src/lib/util/getUniqueID.js b/src/lib/util/getUniqueID.ts similarity index 100% rename from src/lib/util/getUniqueID.js rename to src/lib/util/getUniqueID.ts diff --git a/src/lib/util/groupTextTokens.js b/src/lib/util/groupTextTokens.js deleted file mode 100644 index fb38e3d1..00000000 --- a/src/lib/util/groupTextTokens.js +++ /dev/null @@ -1,25 +0,0 @@ -import Token from './Token'; - -export default function groupTextTokens(tokens) { - const result = []; - - let hasGroup = false; - - tokens.forEach((token, index) => { - if (!token.block && !hasGroup) { - hasGroup = true; - result.push(new Token('textgroup', 1)); - result.push(token); - } else if (!token.block && hasGroup) { - result.push(token); - } else if (token.block && hasGroup) { - hasGroup = false; - result.push(new Token('textgroup', -1)); - result.push(token); - } else { - result.push(token); - } - }); - - return result; -} diff --git a/src/lib/util/groupTextTokens.ts b/src/lib/util/groupTextTokens.ts new file mode 100644 index 00000000..8c6fee32 --- /dev/null +++ b/src/lib/util/groupTextTokens.ts @@ -0,0 +1,29 @@ +import MarkdownIt, { type Token } from "markdown-it"; + +const {core: {State: {prototype: {Token}}}} = new MarkdownIt(); + +export default function groupTextTokens( + tokens: Token[], +): (Token)[] { + const result: Token[] = []; + + let hasGroup = false; + + tokens.forEach((token) => { + if (!token.block && !hasGroup) { + hasGroup = true; + result.push(new Token("textgroup", "", 1)); + result.push(token); + } else if (!token.block && hasGroup) { + result.push(token); + } else if (token.block && hasGroup) { + hasGroup = false; + result.push(new Token("textgroup", "", -1)); + result.push(token); + } else { + result.push(token); + } + }); + + return result; +} diff --git a/src/lib/util/hasParents.js b/src/lib/util/hasParents.js deleted file mode 100644 index 57287540..00000000 --- a/src/lib/util/hasParents.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * - * @param {Array} parents - * @param {string} type - * @return {boolean} - */ -export default function hasParents(parents, type) { - return parents.findIndex((el) => el.type === type) > -1; -} diff --git a/src/lib/util/hasParents.ts b/src/lib/util/hasParents.ts new file mode 100644 index 00000000..53bf84c1 --- /dev/null +++ b/src/lib/util/hasParents.ts @@ -0,0 +1,5 @@ +import {ASTNode} from "../types"; + +export default function hasParents(parents: ASTNode[], type: string): boolean { + return parents.findIndex((el) => el.type === type) > -1; +} diff --git a/src/lib/util/omitListItemParagraph.js b/src/lib/util/omitListItemParagraph.ts similarity index 71% rename from src/lib/util/omitListItemParagraph.js rename to src/lib/util/omitListItemParagraph.ts index 8efc08ae..239f982a 100644 --- a/src/lib/util/omitListItemParagraph.js +++ b/src/lib/util/omitListItemParagraph.ts @@ -1,6 +1,10 @@ -export default function omitListItemParagraph(tokens) { +import { Token } from "markdown-it"; + +export default function omitListItemParagraph( + tokens: Token[], +): Token[] { // used to ensure that we remove the correct ending paragraph token - let depth = null; + let depth: number | null = null; return tokens.filter((token, index) => { // update depth if we've already removed a starting paragraph token if (depth !== null) { @@ -8,13 +12,13 @@ export default function omitListItemParagraph(tokens) { } // check for a list_item token followed by paragraph token (to remove) - if (token.type === 'list_item' && token.nesting === 1 && depth === null) { + if (token.type === "list_item" && token.nesting === 1 && depth === null) { const next = index + 1 in tokens ? tokens[index + 1] : null; - if (next && next.type === 'paragraph' && next.nesting === 1) { + if (next && next.type === "paragraph" && next.nesting === 1) { depth = 0; return true; } - } else if (token.type === 'paragraph') { + } else if (token.type === "paragraph") { if (token.nesting === 1 && depth === 1) { // remove the paragraph token immediately after the list_item token return false; diff --git a/src/lib/util/openUrl.js b/src/lib/util/openUrl.js deleted file mode 100644 index 1c1bf198..00000000 --- a/src/lib/util/openUrl.js +++ /dev/null @@ -1,12 +0,0 @@ -import {Linking} from 'react-native'; - -export default function openUrl(url, customCallback) { - if (customCallback) { - const result = customCallback(url); - if (url && result && typeof result === 'boolean') { - Linking.openURL(url); - } - } else if (url) { - Linking.openURL(url); - } -} diff --git a/src/lib/util/openUrl.ts b/src/lib/util/openUrl.ts new file mode 100644 index 00000000..06be37cb --- /dev/null +++ b/src/lib/util/openUrl.ts @@ -0,0 +1,19 @@ +import {Linking} from "react-native"; + +export default function openUrl( + url: string, + customCallback?: (url: string) => boolean, +): void { + if (customCallback) { + const result = customCallback(url); + if (url && result && typeof result === "boolean") { + Linking.openURL(url).catch((err: unknown) => { + console.warn(err); + }); + } + } else if (url) { + Linking.openURL(url).catch((err: unknown) => { + console.warn(err); + }); + } +} diff --git a/src/lib/util/removeTextStyleProps.js b/src/lib/util/removeTextStyleProps.js deleted file mode 100644 index 773fec86..00000000 --- a/src/lib/util/removeTextStyleProps.js +++ /dev/null @@ -1,15 +0,0 @@ -import textStyleProps from '../data/textStyleProps'; - -export default function removeTextStyleProps(style) { - const intersection = textStyleProps.filter((value) => - Object.keys(style).includes(value), - ); - - const obj = {...style}; - - intersection.forEach((value) => { - delete obj[value]; - }); - - return obj; -} diff --git a/src/lib/util/removeTextStyleProps.ts b/src/lib/util/removeTextStyleProps.ts new file mode 100644 index 00000000..bb44078f --- /dev/null +++ b/src/lib/util/removeTextStyleProps.ts @@ -0,0 +1,18 @@ +import {ViewStyle} from "react-native"; +import textStyleProps from "../data/textStyleProps"; + +export default function removeTextStyleProps(style: ViewStyle): ViewStyle { + const intersection = textStyleProps.filter((value) => + Object.keys(style).includes(value), + ); + + const obj = {...style}; + + intersection.forEach((value) => { + // @ts-expect-error this is fine + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete obj[value]; + }); + + return obj; +} diff --git a/src/lib/util/renderInlineAsText.js b/src/lib/util/renderInlineAsText.js deleted file mode 100644 index 274efae9..00000000 --- a/src/lib/util/renderInlineAsText.js +++ /dev/null @@ -1,13 +0,0 @@ -export default function renderInlineAsText(tokens) { - var result = ''; - - for (var i = 0, len = tokens.length; i < len; i++) { - if (tokens[i].type === 'text') { - result += tokens[i].content; - } else if (tokens[i].type === 'image') { - result += renderInlineAsText(tokens[i].children); - } - } - - return result; -} diff --git a/src/lib/util/renderInlineAsText.ts b/src/lib/util/renderInlineAsText.ts new file mode 100644 index 00000000..5bf26105 --- /dev/null +++ b/src/lib/util/renderInlineAsText.ts @@ -0,0 +1,15 @@ +import {Token} from "markdown-it"; + +export default function renderInlineAsText(tokens: Token[]): string { + let result = ""; + + for (let i = 0, len = tokens.length; i < len; i++) { + if (tokens[i].type === "text") { + result += tokens[i].content; + } else if (tokens[i].type === "image") { + result += renderInlineAsText(tokens[i].children!); + } + } + + return result; +} diff --git a/src/lib/util/splitTextNonTextNodes.js b/src/lib/util/splitTextNonTextNodes.js deleted file mode 100644 index e8111695..00000000 --- a/src/lib/util/splitTextNonTextNodes.js +++ /dev/null @@ -1,14 +0,0 @@ -export default function splitTextNonTextNodes(children) { - return children.reduce( - (acc, curr) => { - if (curr.type.displayName === 'Text') { - acc.textNodes.push(curr); - } else { - acc.nonTextNodes.push(curr); - } - - return acc; - }, - {textNodes: [], nonTextNodes: []}, - ); -} diff --git a/src/lib/util/stringToTokens.js b/src/lib/util/stringToTokens.js deleted file mode 100644 index 6ac77f37..00000000 --- a/src/lib/util/stringToTokens.js +++ /dev/null @@ -1,10 +0,0 @@ -export function stringToTokens(source, markdownIt) { - let result = []; - try { - result = markdownIt.parse(source, {}); - } catch (err) { - console.warn(err); - } - - return result; -} diff --git a/src/lib/util/stringToTokens.ts b/src/lib/util/stringToTokens.ts new file mode 100644 index 00000000..ceb48e70 --- /dev/null +++ b/src/lib/util/stringToTokens.ts @@ -0,0 +1,15 @@ +import MarkdownIt, {Token} from "markdown-it"; + +export function stringToTokens( + source: string, + markdownIt: MarkdownIt, +): Token[] { + let result: Token[] = []; + try { + result = markdownIt.parse(source, {}); + } catch (err) { + console.warn(err); + } + + return result; +} diff --git a/src/lib/util/tokensToAST.js b/src/lib/util/tokensToAST.ts similarity index 57% rename from src/lib/util/tokensToAST.js rename to src/lib/util/tokensToAST.ts index b0ed265c..2c736ade 100644 --- a/src/lib/util/tokensToAST.js +++ b/src/lib/util/tokensToAST.ts @@ -1,13 +1,9 @@ -import getUniqueID from './getUniqueID'; -import getTokenTypeByToken from './getTokenTypeByToken'; +import { Token } from "markdown-it"; +import { ASTNode } from "../types"; +import getTokenTypeByToken from "./getTokenTypeByToken"; +import getUniqueID from "./getUniqueID"; -/** - * - * @param {{type: string, tag:string, content: string, children: *, attrs: Array, meta, info, block: boolean}} token - * @param {number} tokenIndex - * @return {{type: string, content, tokenIndex: *, index: number, attributes: {}, children: *}} - */ -function createNode(token, tokenIndex) { +function createNode(token: Token, tokenIndex: number): ASTNode { const type = getTokenTypeByToken(token); const content = token.content; @@ -24,26 +20,21 @@ function createNode(token, tokenIndex) { type, sourceType: token.type, sourceInfo: token.info, - sourceMeta: token.meta, + sourceMeta: token.meta as unknown, block: token.block, markup: token.markup, - key: getUniqueID() + '_' + type, + key: getUniqueID() + "_" + type, content, tokenIndex, index: 0, attributes, - children: tokensToAST(token.children), + children: token.children ? tokensToAST(token.children) : [], }; } -/** - * - * @param {Array<{type: string, tag:string, content: string, children: *, attrs: Array}>}tokens - * @return {Array} - */ -export default function tokensToAST(tokens) { - let stack = []; - let children = []; +export default function tokensToAST(tokens?: (Token)[]): ASTNode[] { + const stack = []; + let children: ASTNode[] = []; if (!tokens || tokens.length === 0) { return []; @@ -55,9 +46,9 @@ export default function tokensToAST(tokens) { if ( !( - astNode.type === 'text' && + astNode.type === "text" && astNode.children.length === 0 && - astNode.content === '' + astNode.content === "" ) ) { astNode.index = children.length; @@ -65,9 +56,12 @@ export default function tokensToAST(tokens) { if (token.nesting === 1) { children.push(astNode); stack.push(children); + // @ts-expect-error read-only is not a concern children = astNode.children; } else if (token.nesting === -1) { + // @ts-expect-error we know it's defined children = stack.pop(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (token.nesting === 0) { children.push(astNode); } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..910bffee --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,101 @@ +{ + "compilerOptions": { + "noEmit": false, + /* Visit https://aka.ms/tsconfig to read more about this file */ + /* Projects */ + "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + /* Language and Environment */ + "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "lib": [ + "ES2021", + "ES2022.Error" + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + "jsx": "react-native" /* Specify what JSX code is generated. */, + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, + "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + /* Modules */ + "module": "CommonJS" /* Specify what module code is generated. */, + "rootDir": "./src" /* Specify the root folder within your source files. */, + "moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + /* Emit */ + "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, + "declarationMap": true /* Create sourcemaps for d.ts files. */, + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": true /* Create source map files for emitted JavaScript files. */, + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + /* Interop Constraints */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, + "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + "strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */, + "noImplicitThis": true /* Enable error reporting when 'this' is given the type 'any'. */, + "useUnknownInCatchVariables": true /* Default catch clause variables as 'unknown' instead of 'any'. */, + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */, + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + "noUncheckedIndexedAccess": false /* Add 'undefined' to a type when accessed using an index. */, + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + /* Completeness */ + "skipDefaultLibCheck": false /* Skip type checking .d.ts files that are included with TypeScript. */, + // "skipLibCheck": false /* Skip type checking all .d.ts files. */ + "types": [] + }, + "include": ["./src/**/*"] +}