diff --git a/packages/vant-markdown-loader/README.md b/packages/vant-markdown-loader/README.md
new file mode 100644
index 000000000..5536263c3
--- /dev/null
+++ b/packages/vant-markdown-loader/README.md
@@ -0,0 +1,30 @@
+# vant-markdown-loader
+
+Simple and fast vue markdown loader, transform markdown to vue component.
+
+## Install
+
+### NPM
+
+```shell
+npm i @vant/markdown-loader -S
+```
+
+### YARN
+
+```shell
+yarn add @vant/markdown-loader
+```
+
+## Options
+
+- `enableMetaData`: Default `false`. Whether to use [front-matter](https://github.com/jxson/front-matter) to extract markdown meta data
+
+- `linkOpen`: Default `true`. Whether to add target="_blank" to all links
+
+- `wrapper(html, fm)`: Format the returned content using a custom function
+ - `html`: The result of [markdown-it](https://github.com/markdown-it/markdown-it)'s render
+ - `fm`: See [fm(string)](https://github.com/jxson/front-matter#fmstring). If `enableMetaData` option is `false`, the value is `undefined`.
+ - `attributes`
+ - `body`
+ - `frontmatter`
diff --git a/packages/vant-markdown-loader/package.json b/packages/vant-markdown-loader/package.json
new file mode 100644
index 000000000..5d1bb202d
--- /dev/null
+++ b/packages/vant-markdown-loader/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@vant/markdown-loader",
+ "version": "4.1.1",
+ "description": "Simple and fast vue markdown loader",
+ "main": "src/index.js",
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "license": "MIT",
+ "repository": "https://github.com/youzan/vant/tree/dev/packages/vant-markdown-loader",
+ "dependencies": {
+ "front-matter": "^4.0.2",
+ "highlight.js": "^10.7.1",
+ "loader-utils": "^2.0.0",
+ "markdown-it": "^12.0.4",
+ "markdown-it-anchor": "^7.1.0",
+ "transliteration": "^2.2.0"
+ }
+}
diff --git a/packages/vant-markdown-loader/src/card-wrapper.js b/packages/vant-markdown-loader/src/card-wrapper.js
new file mode 100644
index 000000000..6216cdef9
--- /dev/null
+++ b/packages/vant-markdown-loader/src/card-wrapper.js
@@ -0,0 +1,16 @@
+module.exports = function cardWrapper(html) {
+ const group = html
+ .replace(/
{
+ if (fragment.indexOf('${fragment}`;
+ }
+
+ return fragment;
+ })
+ .join('');
+};
diff --git a/packages/vant-markdown-loader/src/extract-demo.js b/packages/vant-markdown-loader/src/extract-demo.js
new file mode 100644
index 000000000..384d36963
--- /dev/null
+++ b/packages/vant-markdown-loader/src/extract-demo.js
@@ -0,0 +1,42 @@
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const parser = require('./md-parser');
+
+function hyphenate(str) {
+ return str.replace(/\B([A-Z])/g, '-$1').toLowerCase();
+}
+
+module.exports = function extraDemo(content) {
+ const isWin = /^win/.test(os.platform());
+ const markdownDir = path.dirname(this.resourcePath);
+ const demoLinks = [];
+
+ content = content.replace(
+ /([\s\S]*?)<\/demo-code>/g,
+ function (_, attrs, link) {
+ link = link.trim(); // 去换行符
+ const tag = 'demo-code-' + hyphenate(path.basename(link, '.vue'));
+ let fullLink;
+ if (isWin) {
+ fullLink = path.posix.join(...markdownDir.split(path.sep), link);
+ } else {
+ fullLink = path.join(markdownDir, link);
+ }
+ demoLinks.indexOf(fullLink) === -1 && demoLinks.push(fullLink);
+ const demoContent = fs.readFileSync(fullLink, { encoding: 'utf8' });
+ const demoParseredContent = parser.render(
+ '```html\n' + demoContent + '\n```'
+ );
+ return `
+
+ <${tag} />
+
+ `;
+ }
+ );
+
+ return [content, demoLinks];
+};
diff --git a/packages/vant-markdown-loader/src/highlight.js b/packages/vant-markdown-loader/src/highlight.js
new file mode 100644
index 000000000..c5fed8c16
--- /dev/null
+++ b/packages/vant-markdown-loader/src/highlight.js
@@ -0,0 +1,10 @@
+const hljs = require('highlight.js');
+
+module.exports = function highlight(str, lang) {
+ if (lang && hljs.getLanguage(lang)) {
+ // https://github.com/highlightjs/highlight.js/issues/2277
+ return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
+ }
+
+ return '';
+};
diff --git a/packages/vant-markdown-loader/src/index.js b/packages/vant-markdown-loader/src/index.js
new file mode 100644
index 000000000..ece432bcf
--- /dev/null
+++ b/packages/vant-markdown-loader/src/index.js
@@ -0,0 +1,117 @@
+const path = require('path');
+const loaderUtils = require('loader-utils');
+const frontMatter = require('front-matter');
+const parser = require('./md-parser');
+const linkOpen = require('./link-open');
+const cardWrapper = require('./card-wrapper');
+const extractDemo = require('./extract-demo');
+const sideEffectTags = require('./side-effect-tags');
+
+function camelize(str) {
+ return `-${str}`.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''));
+}
+
+const sharedVueOptions = `mounted() {
+ const anchors = [].slice.call(this.$el.querySelectorAll('h2, h3, h4, h5'));
+
+ anchors.forEach(anchor => {
+ anchor.addEventListener('click', this.scrollToAnchor);
+ });
+ },
+
+ methods: {
+ scrollToAnchor(event) {
+ if (event.target.id) {
+ this.$router.push({
+ name: this.$route.name,
+ hash: '#' + event.target.id
+ })
+ }
+ }
+ },
+`;
+
+function wrapper(content) {
+ let demoLinks;
+ [content, demoLinks] = extractDemo.call(this, content);
+ content = cardWrapper(content);
+
+ // 不包含 demo-code 的 md 文件,直接使绑定 HTML
+ if (demoLinks.length === 0) {
+ content = escape(content);
+
+ return `
+
+`;
+ }
+
+ // 包含 demo-code 的 md 文件,需要走模版渲染
+ let styles;
+ [content, styles] = sideEffectTags(content);
+
+ return `
+
+
+
+
+
+
+${styles.join('\n')}
+`;
+}
+
+module.exports = function (source) {
+ let options = loaderUtils.getOptions(this) || {};
+ this.cacheable && this.cacheable();
+
+ options = {
+ wrapper,
+ linkOpen: true,
+ ...options,
+ };
+
+ let fm;
+
+ if (options.enableMetaData) {
+ fm = frontMatter(source);
+ source = fm.body;
+ }
+
+ if (options.linkOpen) {
+ linkOpen(parser);
+ }
+
+ return options.wrapper.call(this, parser.render(source), fm);
+};
diff --git a/packages/vant-markdown-loader/src/link-open.js b/packages/vant-markdown-loader/src/link-open.js
new file mode 100644
index 000000000..0e513226a
--- /dev/null
+++ b/packages/vant-markdown-loader/src/link-open.js
@@ -0,0 +1,18 @@
+// add target="_blank" to all links
+module.exports = function linkOpen(md) {
+ const defaultRender =
+ md.renderer.rules.link_open ||
+ function(tokens, idx, options, env, self) {
+ return self.renderToken(tokens, idx, options);
+ };
+
+ md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
+ const aIndex = tokens[idx].attrIndex('target');
+
+ if (aIndex < 0) {
+ tokens[idx].attrPush(['target', '_blank']); // add new attribute
+ }
+
+ return defaultRender(tokens, idx, options, env, self);
+ };
+};
diff --git a/packages/vant-markdown-loader/src/md-parser.js b/packages/vant-markdown-loader/src/md-parser.js
new file mode 100644
index 000000000..3940d886f
--- /dev/null
+++ b/packages/vant-markdown-loader/src/md-parser.js
@@ -0,0 +1,14 @@
+const MarkdownIt = require('markdown-it');
+const markdownItAnchor = require('markdown-it-anchor');
+const highlight = require('./highlight');
+const { slugify } = require('transliteration');
+
+const parser = new MarkdownIt({
+ html: true,
+ highlight,
+}).use(markdownItAnchor, {
+ level: 2,
+ slugify,
+});
+
+module.exports = parser;
diff --git a/packages/vant-markdown-loader/src/side-effect-tags.js b/packages/vant-markdown-loader/src/side-effect-tags.js
new file mode 100644
index 000000000..a7f99fd5b
--- /dev/null
+++ b/packages/vant-markdown-loader/src/side-effect-tags.js
@@ -0,0 +1,14 @@
+module.exports = function sideEffectTags(content) {
+ const styles = [];
+
+ // 从模版中移除 script 标签
+ content = content.replace(/