16

I'm having some troubles loading markdown files (.md) into my react native (non-detached expo project).

Found this awesome package that allows me to render it. But can't figure out how to load the local .md file as a string.

import react from 'react';
import {PureComponent} from 'react-native';
import Markdown from 'react-native-markdown-renderer';

const copy = `# h1 Heading 8-)

| 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. |
`;

export default class Page extends PureComponent {

  static propTypes = {};
  static defaultProps = {};

  render() {
    return (
        <Markdown>{copy}</Markdown>
    );
  }
}

BTW: I tried googling, but can't get the suggestions to work

https://forums.expo.io/t/loading-non-media-assets-markdown/522/2?u=norfeldtconsulting

I tried the suggested answers for reactjs on SO, but the problem seems to be that it only accepts .js and .json files

Norfeldt
  • 8,272
  • 23
  • 96
  • 152
  • 1
    Check this SO question https://stackoverflow.com/questions/42928530/how-do-i-load-a-markdown-file-into-a-react-component. This already has an answer – Hemadri Dasari Sep 29 '18 at 19:23
  • Possible duplicate of [How do I load a markdown file into a react component?](https://stackoverflow.com/questions/42928530/how-do-i-load-a-markdown-file-into-a-react-component) – Hemadri Dasari Sep 29 '18 at 19:25
  • 1
    I have tested these answers and this question is not a duplicate since it seems to work differently in react native expo. – Norfeldt Sep 30 '18 at 09:26
  • @Think-Twice I have updated my question – Norfeldt Sep 30 '18 at 09:33

4 Answers4

17

Thanks to @Filipe's response, I got some guidance and got a working example that will fit your needs.

In my case, I had a .md file on the assets/markdown/ folder, the file is called test-1.md

The trick is to get a local url for the file, and then use the fetch API to get its content as a string.

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Markdown from 'react-native-markdown-renderer';
const copy = `# h1 Heading 8-)

| 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. |
`;

export default class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      copy: copy
    }
  }

  componentDidMount() {
    this.fetchLocalFile();
  }

  fetchLocalFile = async () => {
    let file = Expo.Asset.fromModule(require("./assets/markdown/test-1.md"))
    await file.downloadAsync() // Optional, saves file into cache
    file = await fetch(file.uri)
    file = await file.text()

    this.setState({copy: file});
  }


  render() {
    return (
        <Markdown>{this.state.copy}</Markdown>
    );
  }
}

EDIT: In order to get get rid of the error

Unable to resolve "./assets/markdown/test-1.md" from "App.js"

you would need to add the packagerOpts part of @Filipe's snippet into your app.json file.

app.json

{
  "expo": {
    ...
    "assetBundlePatterns": [
      "**/*"
    ],
    "packagerOpts": {
      "assetExts": ["md"]
    },
    ...
  }
}

EDIT 2: Answering to @Norfeldt's comment: Although I use react-native init when working on my own projects, and I'm therefore not very familiar with Expo, I got this Expo Snack that might have some answers for you: https://snack.expo.io/Hk8Ghxoqm.

It won't work on the expo snack because of the issues reading non-JSON files, but you can test it locally if you wish.

Using file.downloadAsync() will prevent the app making XHR calls to a server where your file is hosted within that app session (as long as the user does not close and re-open the app).

If you change the file or modify the file (simulated with a call to Expo.FileSystem.writeAsStringAsync()), it should display the updated as long as your component re-renders and re-downloads the file.

This will happen every time your app is closed and re-open, as the file.localUri is not persisted per sessions as far as I'm concerned, so your app will always call file.downloadAsync() at least once every time it's opened. So you should have no problems displaying an updated file.

I also took some time to test the speed of using fetch versus using Expo.FileSystem.readAsStringAsync(), and they were on average the same. Often times Expo.FileSystem.readAsStringAsync was ~200 ms faster, but it 's not a deal breaker in my opinion.

I created three different methods for fetching the same file.

export default class MarkdownRenderer extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      copy: ""
    }
  }

  componentDidMount() {
    this.fetch()
  }

  fetch = () => {
    if (this.state.copy) {
      // Clear current state, then refetch data
      this.setState({copy: ""}, this.fetch)
      return;
    }
    let asset = Expo.Asset.fromModule(md)
    const id = Math.floor(Math.random()  * 100) % 40;
    console.log(`[${id}] Started fetching data`, asset.localUri)
    let start = new Date(), end;

    const save = (res) => {
      this.setState({copy: res})
      let end = new Date();
      console.info(`[${id}] Completed fetching data in ${(end - start) / 1000} seconds`)
    }

    // Using Expo.FileSystem.readAsStringAsync.
    // Makes it a single asynchronous call, but must always use localUri
    // Therefore, downloadAsync is required
    let method1 = () => {
      if (!asset.localUri) {
        asset.downloadAsync().then(()=>{
          Expo.FileSystem.readAsStringAsync(asset.localUri).then(save)
        })
      } else {
        Expo.FileSystem.readAsStringAsync(asset.localUri).then(save)
      }
    }

    // Use fetch ensuring the usage of a localUri
    let method2 = () => {
      if (!asset.localUri) {
        asset.downloadAsync().then(()=>{
          fetch(asset.localUri).then(res => res.text()).then(save)
        })
      } else {
        fetch(asset.localUri).then(res => res.text()).then(save)
      }
    }

    // Use fetch but using `asset.uri` (not the local file)
    let method3 = () => {
      fetch(asset.uri).then(res => res.text()).then(save)
    }

    // method1()
    // method2()
    method3()
  }

  changeText = () => {
    let asset = Expo.Asset.fromModule(md)
    Expo.FileSystem.writeAsStringAsync(asset.localUri, "Hello World");
  }

  render() {
    return (
        <ScrollView style={{maxHeight: "90%"}}>
          <Button onPress={this.fetch} title="Refetch"/>
          <Button onPress={this.changeText} title="Change Text"/>
            <Markdown>{this.state.copy}</Markdown>
        </ScrollView>
    );
  }
}

Just alternate between the three to see the difference in the logs.

Luis Rizo
  • 2,009
  • 4
  • 15
  • 34
  • Thank you so much! I clearly didn't have the skills to figure out how to fetch and extract the text out of it. Do the `file.downloadAsync()` mean that it won't download the file each time I use the App? What if I change the content of the markdown after deploying and do an OTA - with it use the cached file or detect that it has been changed and cache the new one instead? – Norfeldt Oct 09 '18 at 10:41
  • Tried my best to answer your questions in my updated answer – Luis Rizo Oct 10 '18 at 04:26
  • In order for this to work though, you also need to modify your metro.config.js file: `const defaultAssetExts = require("metro-config/src/defaults/defaults").assetExts; module.exports = { resolver: { //... assetExts: [ ...defaultAssetExts, "md" ], //... };` – Saad Zafar Oct 24 '19 at 12:06
  • If anyone see's this answer with expo 40, it's not going to work - check out my answer below. – Jono May 02 '21 at 20:37
  • For newer expo versions, check the following https://docs.expo.dev/guides/customizing-metro/ to allow MD extension in `metro.config.js`. The file must be imported statically, dynamic use of `require('file.md')` did not work for me yet. – Nour Wolf Feb 18 '23 at 21:55
2

From what I know, this cannot be done within expo. I use react-native and run it on my mobile for development.

react-native use Metro as the default bundler, which also suffers from similar problems. You have to use haul bundler instead.

npm install --save-dev haul

npx haul init

npx haul start --platform android

In a seperate terminal run react-native run-android. This would use haul instead of metro to bundle the files.

To add the markdown file, install raw-loader and edit the haul.config.js file. raw-loader imports any file as a string.

Customise your haul.config.js to look something like this:

import { createWebpackConfig } from "haul";
export default {
 webpack: env => {
  const config = createWebpackConfig({
    entry: './index.js',
  })(env);
  config.module.rules.push({
      test: /\.md$/,
      use: 'raw-loader'
   })
  return config;
 }
};

Now you can import the markdown file by using const example = require('./example.md')

Haul supports webpack configuration so you can add any custom babel transform you want.

illiteratewriter
  • 4,155
  • 1
  • 23
  • 44
  • thank you so much for taking the time to try and answer my question. I never knew of `haul` - seems pretty interesting. I would like to avoid ejecting from expo (I like the OTA feature), so I'll keep the question open a little longer and if no better answer comes - then I'll mark your answer as the answer. – Norfeldt Oct 08 '18 at 04:47
  • thanks to you, I never had to edit the `haul.config.js` file, and now I know how to :) – illiteratewriter Oct 08 '18 at 08:37
  • I'm so glad that you got something out of it as well. Again, thank you very much for your answer. – Norfeldt Oct 08 '18 at 10:32
2

I don't know exactly where the problem lies, but I added html files to the project, and I'd imagine it would be very similar.

Inside your app.json, try adding these fields:

"assetBundlePatterns": [
  "assets/**",
],
"packagerOpts": {
  "assetExts": ["md"]
},

The packagerOpts makes it so the standalone will bundle the .md files. I'd imagine you already have an assets folder, but just in case you don't, you will need one.

Then, on AppLoading, loading the assets with Asset.loadAsync might not be needed, but it's a good idea to rule out. Check out the documentation on how to use it.

When importing the file, there are three ways you might want to do so, that change depending on the environment. I'll copy this excerpt from my Medium article:

In the simulator, you can access any file in the project. Thus, source={require(./pathToFile.html)} works. However, when you build a standalone, it doesn’t work quite in the same way. I mean, at least for android it doesn’t. The android webView doesn’t recognise asset:/// uris for some reason. You have to get the file:/// path. Thankfully, that is very easy. The assets are bundled inside file:///android_asset (Careful, don’t write assets), and Expo.Asset.fromModule(require(‘./pathToFile.html')).localUri returns asset:///nameOfFile.html. But that’s not all. For the first few times, this uri will be correct. However, after a while, it changes into another file scheme, and can’t be accessed in the same way. Instead, you’ll have to access the localUri directly. Thus, the complete solution is:

/* Outside of return */
const { localUri } = Expo.Asset.fromModule(require('./pathToFile.html'));
/* On the webView */
source={
  Platform.OS === ‘android’
  ? {
    uri: localUri.includes('ExponentAsset')
      ? localUri
      : ‘file:///android_asset/’ + localUri.substr(9),
  }
  : require(‘./pathToFile.html’)
}

(A constant part of the uri is ExponentAsset, that’s why I chose to check if that was part of it)

That should probably solve your problem. If it doesn't, comment what's going wrong and I'll try to help you further. Cheers!

Filipe
  • 866
  • 5
  • 16
  • I might be stupid, but I don't know how to read the file content. The package I'm trying to use don't have a source prop but expect me to drop it as a string. Doing a `console.log(Expo.Asset.fromModule(require('./pathToFile.html')))` reveals no raw content.. – Norfeldt Oct 08 '18 at 18:39
  • Extracting the `uri` allows me to "open" the file in a browser via `Linking.openURL(uri)` – Norfeldt Oct 08 '18 at 18:48
  • `downloadCallbacks: [] downloaded: false downloading: false hash: "43d299a4986521da6b28a00eb764b2a7" localUri: null name: "C05" type: "md" uri: "http://localhost:19001/assets/assets/markdown/C05.md?platform=ios&hash=43d299a4986521da6b28a00eb764b2a7"` – Norfeldt Oct 08 '18 at 19:13
  • Check my answer, it should answer your questions. – Luis Rizo Oct 08 '18 at 20:51
  • Yes, I'm sorry, I didn't take a look at the library you were using. @Luiz Rizo's answer should do it. – Filipe Oct 08 '18 at 21:12
2

If you want to load .md file with react-native cli (without expo). I've got a solution for you)

  1. Add https://github.com/feats/babel-plugin-inline-import to your project
  2. Add config .babelrc file with code inside:
{
   "presets": ["module:metro-react-native-babel-preset"],
   "plugins": [
       [
           "inline-import",
           {
               "extensions": [".md", ".txt"]
           }
       ],
       [
           "module-resolver",
           {
               "root": ["./src"],
               "alias": {}
           }
       ]
   ]
}
  1. Add to your metro.config.js such code
const metroDefault = require('metro-config/src/defaults/defaults.js');
...
  resolver: {
    sourceExts: metroDefault.sourceExts.concat(['md', 'txt']),
  }
....
  1. Reload your app
Kirill Kohan
  • 181
  • 1
  • 2
  • 5