Skip to content

Commit

Permalink
feat(plugin-sfc): extract SFC blocks as structured data
Browse files Browse the repository at this point in the history
BREAKING CHANGE: the type of `env.sfcBlocks` has been changed
  • Loading branch information
meteorlxy committed Jul 26, 2022
1 parent 5697fed commit 5a0aa54
Show file tree
Hide file tree
Showing 9 changed files with 343 additions and 37 deletions.
7 changes: 4 additions & 3 deletions packages/plugin-sfc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

A [markdown-it](https://github.com/markdown-it/markdown-it) plugin to help transforming markdown to [Vue SFC](https://vuejs.org/guide/scaling-up/sfc.html).

- Extract all SFC blocks except `<template>` from rendered result to markdown-it `env.sfcBlocks`.
- Avoid rendering `<script>` and `<style>` tags and extract them into to markdown-it `env.sfcBlocks`.
- Support extracting custom blocks.
- Provide `env.sfcBlocks.template` for convenience.

## Install

Expand Down Expand Up @@ -39,7 +40,7 @@ console.log('bar')
env,
);

const sfc = `<template>${rendered}</template>${env.sfcBlocks.join('')}`;
console.log(env.sfcBlocks);
```

## Options
Expand All @@ -56,4 +57,4 @@ const sfc = `<template>${rendered}</template>${env.sfcBlocks.join('')}`;

By default, only `<script>` and `<style>` tags will be extracted. You can set this option to support SFC custom blocks in markdown.

For example, if you set this option to `['foo']`, the `<foo>` tag in your markdown content will also be extracted to `env.sfcBlocks` and won't appear in the rendered result.
For example, if you set this option to `['i18n']`, the `<i18n>` tag in your markdown content will be extracted to `env.sfcBlocks.customBlocks` and won't appear in the rendered result.
3 changes: 3 additions & 0 deletions packages/plugin-sfc/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const TAG_NAME_SCRIPT = 'script';
export const TAG_NAME_STYLE = 'style';
export const TAG_NAME_TEMPLATE = 'template';
2 changes: 2 additions & 0 deletions packages/plugin-sfc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './constants.js';
export * from './sfc-plugin.js';
export * from './sfc-regexp.js';
export * from './types.js';
85 changes: 68 additions & 17 deletions packages/plugin-sfc/src/sfc-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,91 @@
import type { MarkdownItEnv } from '@mdit-vue/types';
import type { PluginWithOptions } from 'markdown-it';
import {
TAG_NAME_SCRIPT,
TAG_NAME_STYLE,
TAG_NAME_TEMPLATE,
} from './constants.js';
import { SCRIPT_SETUP_TAG_OPEN_REGEXP, createSfcRegexp } from './sfc-regexp.js';
import type { SfcRegExpMatchArray } from './sfc-regexp.js';
import type { SfcPluginOptions } from './types.js';

/**
* Avoid rendering vue SFC script / style / custom blocks
* Get Vue SFC blocks
*
* Extract them into env
* Extract them into env and avoid rendering them
*/
export const sfcPlugin: PluginWithOptions<SfcPluginOptions> = (
md,
{ customBlocks = [] }: SfcPluginOptions = {},
): void => {
// extract `<script>`, `<style>` and other user defined custom blocks
const sfcBlocks = Array.from(new Set(['script', 'style', ...customBlocks]));
const sfcBlocksRegexp = new RegExp(
`^<(${sfcBlocks.join('|')})(?=(\\s|>|$))`,
'i',
);

const rawRule = md.renderer.rules.html_block!;
const sfcRegexp = createSfcRegexp({ customBlocks });

// wrap the original render function
const render = md.render.bind(md);
md.render = (src, env: MarkdownItEnv = {}) => {
// initialize `env.sfcBlocks`
env.sfcBlocks = {
template: null,
script: null,
scriptSetup: null,
styles: [],
customBlocks: [],
};

// call the original render function to get the rendered result
const rendered = render(src, env);

// create template block from the rendered result
env.sfcBlocks.template = {
type: TAG_NAME_TEMPLATE,
content: `<${TAG_NAME_TEMPLATE}>${rendered}</${TAG_NAME_TEMPLATE}>`,
contentStripped: rendered,
tagOpen: `<${TAG_NAME_TEMPLATE}>`,
tagClose: `</${TAG_NAME_TEMPLATE}>`,
};

return rendered;
};

// wrap the original html_block renderer rule
const htmlBlockRule = md.renderer.rules.html_block!;
md.renderer.rules.html_block = (
tokens,
idx,
options,
env: MarkdownItEnv,
self,
) => {
const content = tokens[idx].content;
// skip if `env.sfcBlocks` is not initialized
if (!env.sfcBlocks) {
return htmlBlockRule(tokens, idx, options, env, self);
}

// get the html_block token
const token = tokens[idx];
const content = token.content;

// try to match sfc
const match = content.match(sfcRegexp) as SfcRegExpMatchArray | null;
if (!match) {
return htmlBlockRule(tokens, idx, options, env, self);
}

// extract sfc blocks to env and do not render them
if (sfcBlocksRegexp.test(content.trim())) {
env.sfcBlocks ??= [];
env.sfcBlocks.push(content);
return '';
// extract sfc blocks to `env.sfcBlocks`
const sfcBlock = match.groups;
if (sfcBlock.type === TAG_NAME_SCRIPT) {
if (SCRIPT_SETUP_TAG_OPEN_REGEXP.test(sfcBlock.tagOpen)) {
env.sfcBlocks.scriptSetup = sfcBlock;
} else {
env.sfcBlocks.script = sfcBlock;
}
} else if (sfcBlock.type === TAG_NAME_STYLE) {
env.sfcBlocks.styles.push(sfcBlock);
} else {
env.sfcBlocks.customBlocks.push(sfcBlock);
}

return rawRule(tokens, idx, options, env, self);
// avoid rendering sfc blocks
return '';
};
};
23 changes: 23 additions & 0 deletions packages/plugin-sfc/src/sfc-regexp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { TAG_NAME_SCRIPT, TAG_NAME_STYLE } from './constants.js';
import type { SfcBlock, SfcPluginOptions } from './types.js';

export const SCRIPT_SETUP_TAG_OPEN_REGEXP = /^<script\s+.*?\bsetup\b.*?>$/is;

export interface SfcRegExpMatchArray extends Omit<RegExpMatchArray, 'groups'> {
groups: SfcBlock;
}

/**
* Generate RegExp for sfc blocks
*/
export const createSfcRegexp = ({
customBlocks,
}: Required<Pick<SfcPluginOptions, 'customBlocks'>>): RegExp => {
const sfcTags = Array.from(
new Set([TAG_NAME_SCRIPT, TAG_NAME_STYLE, ...customBlocks]),
).join('|');
return new RegExp(
`^\\s*(?<content>(?<tagOpen><(?<type>${sfcTags})\\s?.*?>)(?<contentStripped>.*)(?<tagClose><\\/\\k<type>\\s*>))\\s*$`,
'is',
);
};
40 changes: 38 additions & 2 deletions packages/plugin-sfc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,47 @@ export interface SfcPluginOptions {
customBlocks?: string[];
}

/**
* SFC block that extracted from markdown
*/
export interface SfcBlock {
/**
* The type of the block
*/
type: string;

/**
* The content, including open-tag and close-tag
*/
content: string;

/**
* The content that stripped open-tag and close-tag off
*/
contentStripped: string;

/**
* The open-tag
*/
tagOpen: string;

/**
* The close-tag
*/
tagClose: string;
}

declare module '@mdit-vue/types' {
interface MarkdownItEnv {
/**
* The rendered HTML string of SFC blocks that extracted by `@mdit-vue/plugin-sfc`
* SFC blocks that extracted by `@mdit-vue/plugin-sfc`
*/
sfcBlocks?: string[];
sfcBlocks?: {
template: SfcBlock | null;
script: SfcBlock | null;
scriptSetup: SfcBlock | null;
styles: SfcBlock[];
customBlocks: SfcBlock[];
};
}
}
166 changes: 166 additions & 0 deletions packages/plugin-sfc/tests/__snapshots__/sfc-plugin.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Vitest Snapshot v1

exports[`@mdit-vue/plugin-sfc > sfc-plugin > should extract custom blocks correctly 1`] = `
"<h1>hello vuepress</h1>
<p>{{ msg }}</p>
"
`;

exports[`@mdit-vue/plugin-sfc > sfc-plugin > should extract custom blocks correctly 2`] = `
{
"customBlocks": [
{
"content": "<docs>
extra hoisted tag
</docs>",
"contentStripped": "
extra hoisted tag
",
"tagClose": "</docs>",
"tagOpen": "<docs>",
"type": "docs",
},
],
"script": {
"content": "<script>
export default {
setup() {
return {
msg: 'script'
}
}
}
</script>",
"contentStripped": "
export default {
setup() {
return {
msg: 'script'
}
}
}
",
"tagClose": "</script>",
"tagOpen": "<script>",
"type": "script",
},
"scriptSetup": {
"content": "<script setup lang=\\"ts\\">
const foo = 'scriptSetup'
</script>",
"contentStripped": "
const foo = 'scriptSetup'
",
"tagClose": "</script>",
"tagOpen": "<script setup lang=\\"ts\\">",
"type": "script",
},
"styles": [
{
"content": "<style lang=\\"stylus\\">
.h1
red
</style>",
"contentStripped": "
.h1
red
",
"tagClose": "</style>",
"tagOpen": "<style lang=\\"stylus\\">",
"type": "style",
},
],
"template": {
"content": "<template><h1>hello vuepress</h1>
<p>{{ msg }}</p>
</template>",
"contentStripped": "<h1>hello vuepress</h1>
<p>{{ msg }}</p>
",
"tagClose": "</template>",
"tagOpen": "<template>",
"type": "template",
},
}
`;
exports[`@mdit-vue/plugin-sfc > sfc-plugin > should extract default sfc blocks correctly 1`] = `
"<h1>hello vuepress</h1>
<p>{{ msg }}</p>
<docs>
extra hoisted tag
</docs>
"
`;
exports[`@mdit-vue/plugin-sfc > sfc-plugin > should extract default sfc blocks correctly 2`] = `
{
"customBlocks": [],
"script": {
"content": "<script>
export default {
setup() {
return {
msg: 'script'
}
}
}
</script>",
"contentStripped": "
export default {
setup() {
return {
msg: 'script'
}
}
}
",
"tagClose": "</script>",
"tagOpen": "<script>",
"type": "script",
},
"scriptSetup": {
"content": "<script setup lang=\\"ts\\">
const foo = 'scriptSetup'
</script>",
"contentStripped": "
const foo = 'scriptSetup'
",
"tagClose": "</script>",
"tagOpen": "<script setup lang=\\"ts\\">",
"type": "script",
},
"styles": [
{
"content": "<style lang=\\"stylus\\">
.h1
red
</style>",
"contentStripped": "
.h1
red
",
"tagClose": "</style>",
"tagOpen": "<style lang=\\"stylus\\">",
"type": "style",
},
],
"template": {
"content": "<template><h1>hello vuepress</h1>
<p>{{ msg }}</p>
<docs>
extra hoisted tag
</docs>
</template>",
"contentStripped": "<h1>hello vuepress</h1>
<p>{{ msg }}</p>
<docs>
extra hoisted tag
</docs>
",
"tagClose": "</template>",
"tagOpen": "<template>",
"type": "template",
},
}
`;

0 comments on commit 5a0aa54

Please sign in to comment.