diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e6fc720b8..d1a7ba9e21 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "cSpell.words": [ "artalk", "bumpp", + "codepen", "commitlint", "composables", "darkmode", @@ -41,6 +42,7 @@ "giscus", "globby", "gtag", + "jsfiddle", "jsonld", "katex", "lazyload", diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index b27210e7f5..0b18f9532f 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -7,6 +7,7 @@ import { catalogPlugin } from '@vuepress/plugin-catalog' import { commentPlugin } from '@vuepress/plugin-comment' import { docsearchPlugin } from '@vuepress/plugin-docsearch' import { feedPlugin } from '@vuepress/plugin-feed' +import { markdownDemoPlugin } from '@vuepress/plugin-markdown-demo' import { markdownExtPlugin } from '@vuepress/plugin-markdown-ext' import { markdownImagePlugin } from '@vuepress/plugin-markdown-image' import { markdownIncludePlugin } from '@vuepress/plugin-markdown-include' @@ -78,6 +79,11 @@ export default defineUserConfig({ plugins: [ catalogPlugin(), commentPlugin({ provider: 'Giscus' }), + markdownDemoPlugin({ + html: true, + vue: true, + react: true, + }), docsearchPlugin(), feedPlugin({ hostname: 'https://ecosystem.vuejs.press', diff --git a/docs/package.json b/docs/package.json index 93135dbb74..641b1eacc8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,6 +17,7 @@ "@vuepress/plugin-catalog": "workspace:*", "@vuepress/plugin-comment": "workspace:*", "@vuepress/plugin-copy-code": "workspace:*", + "@vuepress/plugin-markdown-demo": "workspace:*", "@vuepress/plugin-docsearch": "workspace:*", "@vuepress/plugin-feed": "workspace:*", "@vuepress/plugin-markdown-ext": "workspace:*", diff --git a/docs/plugins/markdown/markdown-demo.md b/docs/plugins/markdown/markdown-demo.md new file mode 100644 index 0000000000..b267f58248 --- /dev/null +++ b/docs/plugins/markdown/markdown-demo.md @@ -0,0 +1,404 @@ +# markdown-demo + + + +Adding code snippets and preview them. + + + +::: info + +The plugin allows you to insert code snippets and render the source code and the rendered result in the browser for you. If users like to try it, they can click CodePen or JSFiddle button to open the demo in CodePen or JSFiddle and edit them online. + +This means that any snippets are standalone, and do not have access to any components or data in your project. Neither can we read your local file system in users' browser, nor can Codepen and JSFiddle access Vue components in your project. + +::: + +## Usage + +```bash +npm i -D @vuepress/plugin-markdown-demo@next +``` + +```ts +import { markdownDemoPlugin } from '@vuepress/plugin-markdown-demo' + +export default { + plugins: [ + markdownDemoPlugin({ + // options + }), + ], +} +``` + +## Syntax + +You should use the following syntax: + +````md +::: [type]-demo Optional title text + +```html + + + +``` + +```json +// json block is for config +{ + // your config here (optional) +} +``` + +::: +```` + +::: tip + +The json block is optional, for config please see [config](../../../config.md#demo). + +::: + +The plugin support 3 types: + +- html +- vue +- react + +### Available Languages + +You can use different language in your demo block. + +When language which can not run on browsers are given, demo display will be disabled because the browser can not natively run them. The plugin will only show the code and provide you a button to open it in CodePen. + +Available HTML languages: + +- `"html"` (default) +- `"slim"` +- `"haml"` +- `"markdown"` + +> You can also use `md` in code block. + +Available JS languages: + +- `"javascript"` (default) +- `"coffeescript"` +- `"babel"` +- `"livescript"` +- `"typescript"` + +> You can also use `js`, `ts`, `coffee` and `ls` in code block. + +Available CSS languages: + +- `"css"` (default) +- `"less"` +- `"scss"` +- `"sass"` +- `"stylus"` + +> You can also use `styl` in code block. + +### Vue Demo + +A Vue demo should only contain a vue component to be rendered, only vue(or html) and json code blocks are allowed: + +````md +::: vue-demo Optional title text + +```vue + + + + +``` + +```json +// Config (Optional) +``` + +::: +```` + +### React Demo + +A React demo should only contain a react component to be rendered, only js, css and json code blocks are allowed: + +````md +::: react-demo Optional title text + +```js +// your script, must export a react component through `export default` +``` + +```css +/* Your css content */ +``` + +```json +// Config (Optional) +``` + +::: +```` + +### Limitation + +We use "ShadowDOM" to make style isolation, and we already replace `document` with `shadowRoot`. + +- ShadowRoot has almost the same APIs as document, but they are not the same. +- To access the page document, please visit `window.document`. + +The plugin will default treat the code as is, if you want advanced features like ESNext grammar or JSX, you should set transpile to `true` in the plugin options. + +## Demo + +:::: md-demo Normal demo + +::: html-demo HTML Demo + +```html +

VuePress Theme Hope

+

is very powerful!

+``` + +```js +document.querySelector('#very').addEventListener('click', () => { + alert('Very powerful') +}) +``` + +```css +span { + color: red; +} +``` + +::: + +:::: + +:::: md-demo A Vue Composition Demo + +::: vue-demo Vue demo 1 + +```vue + + + +``` + +::: + +:::: + +:::: md-demo A Vue Option Demo + +::: vue-demo Vue demo 2 + +```vue + + + +``` + +::: + +:::: + +:::: md-demo A function-based React Demo + +::: react-demo React demo 1 + +```js +const { useState } = React + +export default () => { + const [message, setMessage] = useState(' powerful') + + const handler = () => { + setMessage(` very${message}`) + } + + return ( +
+ vuepress-theme-hope is + + {message} + + ! +
+ ) +} +``` + +```css +.box #powerful { + color: blue; +} +``` + +::: + +:::: + +:::: md-demo A class-based React Demo + +::: react-demo React demo 2 + +```js +export default class App extends React.Component { + constructor(props) { + super(props) + this.state = { message: ' powerful' } + } + + handler() { + this.setState((state) => ({ + message: ` very${state.message}`, + })) + } + + render() { + return ( +
+ vuepress-theme-hope is + + {this.state.message}! + +
+ ) + } +} +``` + +```css +.box #powerful { + color: blue; +} +``` + +::: + +:::: + +:::: md-demo A demo using language not supported by browsers + +::: html-demo HTML demo + +```md +# Title + +is very powerful! +``` + +```ts +const message: string = 'VuePress Theme Hope' + +document.querySelector('h1').innerHTML = message +``` + +```scss +h1 { + font-style: italic; + + + p { + color: red; + } +} +``` + +::: + +:::: + +## Options + +### html + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether enable html demo + +### vue + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether enable vue demo + +### react + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether enable react demo diff --git a/plugins/markdown/plugin-markdown-demo/package.json b/plugins/markdown/plugin-markdown-demo/package.json new file mode 100644 index 0000000000..c0e89114ef --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/package.json @@ -0,0 +1,62 @@ +{ + "name": "@vuepress/plugin-markdown-demo", + "version": "2.0.0-rc.66", + "description": "VuePress plugin - demo", + "keywords": [ + "vuepress-plugin", + "vuepress", + "plugin", + "markdown", + "demo" + ], + "homepage": "https://ecosystem.vuejs.press/plugins/markdown/demo.html", + "bugs": { + "url": "https://github.com/vuepress/ecosystem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuepress/ecosystem.git", + "directory": "plugins/markdown/plugin-markdown-demo" + }, + "license": "MIT", + "author": { + "name": "Mr.Hope", + "email": "mister-hope@outlook.com", + "url": "https://mister-hope.com" + }, + "type": "module", + "exports": { + ".": "./lib/node/index.js", + "./package.json": "./package.json" + }, + "main": "./lib/node/index.js", + "types": "./lib/node/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", + "clean": "rimraf --glob ./lib ./*.tsbuildinfo", + "style": "sass src:lib --embed-sources --style=compressed" + }, + "dependencies": { + "@mdit/plugin-container": "^0.14.0", + "@mdit/plugin-demo": "^0.14.0", + "@types/markdown-it": "^14.1.2", + "@vuepress/helper": "workspace:*", + "@vueuse/core": "^12.0.0", + "balloon-css": "^1.2.0", + "vue": "^3.5.13" + }, + "peerDependencies": { + "vuepress": "2.0.0-rc.19" + }, + "devDependencies": { + "@types/babel__core": "7.20.5", + "markdown-it": "^14.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/markdown/plugin-markdown-demo/rollup.config.ts b/plugins/markdown/plugin-markdown-demo/rollup.config.ts new file mode 100644 index 0000000000..78555c95c5 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/rollup.config.ts @@ -0,0 +1,8 @@ +import { rollupBundle } from '../../../scripts/rollup.js' + +export default [ + ...rollupBundle('node/index', { + external: ['@mdit/plugin-container', '@mdit/plugin-demo'], + }), + ...rollupBundle('client/config'), +] diff --git a/plugins/markdown/plugin-markdown-demo/src/client/components/VPDemo.ts b/plugins/markdown/plugin-markdown-demo/src/client/components/VPDemo.ts new file mode 100644 index 0000000000..5c68653298 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/components/VPDemo.ts @@ -0,0 +1,314 @@ +import { LoadingIcon, decodeData, wait } from '@vuepress/helper/client' +import { useEventListener, useToggle } from '@vueuse/core' +import type { PropType, SlotsType, VNode } from 'vue' +import { computed, defineComponent, h, onMounted, ref, shallowRef } from 'vue' + +import type { DemoOptions } from '../../shared/index.js' +import { + CODEPEN_SVG, + JSFIDDLE_SVG, + getCodeInfo, + getNormalInfo, + getReactInfo, + getVueInfo, + injectCSS, + injectScript, + loadNormalScripts, + loadReactScripts, + loadVue, +} from '../utils/index.js' + +import 'balloon-css/balloon.css' +import '../styles/vp-demo.css' + +declare const __DEMO_DELAY__: number + +export default defineComponent({ + name: 'VPDemo', + + props: { + /** + * Code demo id + * + * 代码演示 id + */ + id: { + type: String, + required: true, + }, + + /** + * Code demo type + * + * 代码演示类型 + */ + type: { + type: String as PropType<'normal' | 'react' | 'vue'>, + default: 'normal', + }, + + /** + * Code demo title + * + * 代码演示标题 + */ + title: { + type: String, + default: '', + }, + + /** + * Code demo config + * + * 代码演示配置 + */ + config: { + type: String, + default: '', + }, + + /** + * Code demo code content + * + * 代码演示代码内容 + */ + code: { + type: String, + required: true, + }, + }, + + slots: Object as SlotsType<{ + default: () => VNode[] + }>, + + setup(props, { slots }) { + const [isExpanded, toggleIsExpand] = useToggle(false) + const demoWrapper = shallowRef() + const codeContainer = shallowRef() + const height = ref('0') + const loaded = ref(false) + + const config = computed( + () => + JSON.parse( + props.config ? decodeData(props.config) : '{}', + ) as Partial, + ) + + const codeInfo = computed(() => { + const codeConfig = JSON.parse(decodeData(props.code)) as Record< + string, + string + > + + return getCodeInfo(codeConfig) + }) + + const demoInfo = computed(() => + props.type === 'react' + ? getReactInfo(codeInfo.value, config.value) + : props.type === 'vue' + ? getVueInfo(codeInfo.value, config.value) + : getNormalInfo(codeInfo.value, config.value), + ) + + const isLegal = computed(() => demoInfo.value.isLegal) + + const initDom = (innerHTML = false): void => { + // Attach a shadow root to demo + + const shadowRoot = demoWrapper.value!.attachShadow({ mode: 'open' }) + const appElement = document.createElement('div') + + appElement.classList.add('code-demo-app') + shadowRoot.appendChild(appElement) + + if (isLegal.value) { + if (innerHTML) appElement.innerHTML = demoInfo.value.html + injectCSS(shadowRoot, demoInfo.value) + injectScript(props.id, shadowRoot, demoInfo.value) + + height.value = '0' + } else { + height.value = 'auto' + } + + loaded.value = true + } + + const loadDemo = async (): Promise => { + const { transpile } = demoInfo.value + + switch (props.type) { + case 'react': { + await loadReactScripts(transpile) + initDom() + break + } + case 'vue': { + await loadVue(transpile) + initDom() + break + } + + case 'normal': + default: { + await loadNormalScripts(transpile) + initDom(true) + } + } + } + + useEventListener('beforeprint', () => { + toggleIsExpand(true) + }) + + onMounted(async () => { + await wait(__DEMO_DELAY__) + await loadDemo() + }) + + return (): VNode => + h('div', { class: 'vp-demo-container', id: props.id }, [ + h('div', { class: 'vp-demo-container-header' }, [ + isLegal.value + ? h('button', { + 'type': 'button', + 'title': 'toggle', + 'aria-hidden': true, + 'class': [ + 'vp-demo-toggle-button', + isExpanded.value ? 'down' : 'end', + ], + 'onClick': () => { + height.value = isExpanded.value + ? '0' + : `${codeContainer.value!.clientHeight + 13.8}px` + toggleIsExpand() + }, + }) + : null, + props.title + ? h( + 'span', + { class: 'vp-demo-container-title' }, + decodeURIComponent(props.title), + ) + : null, + + isLegal.value && demoInfo.value.jsfiddle !== false + ? h( + 'form', + { + class: 'code-demo-jsfiddle', + target: '_blank', + action: 'https://jsfiddle.net/api/post/library/pure/', + method: 'post', + }, + [ + h('input', { + type: 'hidden', + name: 'html', + value: demoInfo.value.html, + }), + h('input', { + type: 'hidden', + name: 'js', + value: demoInfo.value.js, + }), + h('input', { + type: 'hidden', + name: 'css', + value: demoInfo.value.css, + }), + h('input', { type: 'hidden', name: 'wrap', value: '1' }), + h('input', { type: 'hidden', name: 'panel_js', value: '3' }), + h('input', { + type: 'hidden', + name: 'resources', + value: [ + ...demoInfo.value.cssLib, + ...demoInfo.value.jsLib, + ].join(','), + }), + h('button', { + 'type': 'submit', + 'class': 'jsfiddle-button', + 'innerHTML': JSFIDDLE_SVG, + 'aria-label': 'JSFiddle', + 'data-balloon-pos': 'down', + }), + ], + ) + : null, + + !isLegal.value || demoInfo.value.codepen !== false + ? h( + 'form', + { + class: 'code-demo-codepen', + target: '_blank', + action: 'https://codepen.io/pen/define', + method: 'post', + }, + [ + h('input', { + type: 'hidden', + name: 'data', + value: JSON.stringify({ + html: demoInfo.value.html, + html_pre_processor: codeInfo.value.html[1] ?? 'none', + js: demoInfo.value.js, + js_pre_processor: codeInfo.value.js.length + ? codeInfo.value.js[1] + : demoInfo.value.jsx + ? 'babel' + : 'none', + js_external: demoInfo.value.jsLib.join(';'), + css: demoInfo.value.css, + css_pre_processor: codeInfo.value.css[1] ?? 'none', + css_external: demoInfo.value.cssLib.join(';'), + + layout: demoInfo.value.codepenLayout ?? 'left', + editors: demoInfo.value.codepenEditors ?? '101', + }), + }), + h('button', { + 'type': 'submit', + 'innerHTML': CODEPEN_SVG, + 'class': 'codepen-button', + 'aria-label': 'Codepen', + 'data-balloon-pos': 'down', + }), + ], + ) + : null, + ]), + loaded.value ? null : h(LoadingIcon, { class: 'vp-demo-loading' }), + h('div', { + ref: demoWrapper, + class: 'vp-demo-display', + style: { + display: isLegal.value && loaded.value ? 'block' : 'none', + }, + }), + + h( + 'div', + { + class: 'vp-demo-code-wrapper', + style: { height: height.value }, + }, + h( + 'div', + { + ref: codeContainer, + class: 'vp-demo-codes', + }, + slots.default(), + ), + ), + ]) + }, +}) diff --git a/plugins/markdown/plugin-markdown-demo/src/client/config.ts b/plugins/markdown/plugin-markdown-demo/src/client/config.ts new file mode 100644 index 0000000000..2d6fc43313 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/config.ts @@ -0,0 +1,10 @@ +import { defineClientConfig } from 'vuepress/client' +import VPDemo from './components/VPDemo.js' + +import './styles/vars.css' + +export default defineClientConfig({ + enhance: ({ app }) => { + app.component('VPDemo', VPDemo) + }, +}) diff --git a/plugins/markdown/plugin-markdown-demo/src/client/shims.d.ts b/plugins/markdown/plugin-markdown-demo/src/client/shims.d.ts new file mode 100644 index 0000000000..b2c9abe7ac --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/shims.d.ts @@ -0,0 +1,7 @@ +import type Babel from '@babel/core' + +declare global { + interface Window { + Babel?: typeof Babel + } +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/styles/vars.css b/plugins/markdown/plugin-markdown-demo/src/client/styles/vars.css new file mode 100644 index 0000000000..5144848ab2 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/styles/vars.css @@ -0,0 +1,12 @@ +:root { + --demo-c-accent: var(--vp-c-accent-bg); + --demo-c-bg-header: #eee; + --demo-c-bg-button: #ccc; + --demo-c-bg-button-hover: #aaa; +} + +[data-theme='dark'] { + --demo-c-bg-header: #333; + --demo-c-bg-button: #555; + --demo-c-bg-button-hover: #777; +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/styles/vp-demo.scss b/plugins/markdown/plugin-markdown-demo/src/client/styles/vp-demo.scss new file mode 100644 index 0000000000..3b3898c0c0 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/styles/vp-demo.scss @@ -0,0 +1,198 @@ +.vp-demo-container { + overflow: hidden; + + margin: 0.75rem 0; + border: 1px solid var(--vp-c-border); + border-radius: 0.5rem; + + transition: border-color var(--vp-t-color); + + &:hover { + box-shadow: 0 2px 12px var(--vp-c-shadow); + } +} + +.vp-demo-container-header { + position: relative; + + display: flex; + align-items: center; + + padding: 0.5rem 0.75rem; + + background: var(--demo-c-bg-header); + + font-weight: 500; + text-align: start; + + transition: + background var(--vp-t-color), + border-color var(--vp-t-color); +} + +.vp-demo-container-title { + vertical-align: top; + flex: 1; + + overflow: hidden; + + font-size: 1.25rem; + text-overflow: ellipsis; + white-space: nowrap; + + &:first-child { + margin-inline-start: 0.5rem; + } +} + +.vp-demo-toggle-button { + position: relative; + + display: inline-block; + vertical-align: middle; + + width: 1em; + height: 1em; + margin: 8px 12px 8px 8px; + padding: 0; + border: none; + border-radius: 50%; + + background-color: var(--demo-c-bg-button); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgb(0,0,0,0.5)' d='M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + outline: none; + + font-size: 24px; + line-height: normal; + + cursor: pointer; + + transition: all 0.3s; + + transform: rotate(90deg); + + @media print { + display: none; + } + + [data-theme='dark'] & { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgb(255,255,255,0.5)' d='M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'/%3E%3C/svg%3E"); + } + + [dir='rtl'] & { + transform: rotate(-90deg); + } + + &.expanded { + transform: rotate(180deg); + + [dir='rtl'] & { + transform: rotate(-180deg); + } + } + + &:hover { + background-color: var(--demo-c-bg-button-hover); + } +} + +// hide settings block +.language-json { + display: none; +} + +.codepen-button, +.jsfiddle-button { + position: relative; + + box-sizing: content-box; + width: 1.25rem; + height: 1.25rem; + margin: 0 4px; + padding: 4px; + border: none; + border-radius: 50%; + + background: var(--vp-c-control); + outline: none; + + cursor: pointer; + + transition: background var(--vp-t-color); + + @media print { + display: none; + } + + svg { + width: 1.25rem; + height: 1.25rem; + transition: fill var(--vp-t-color); + fill: var(--vp-c-text-mute); + } + + &:hover { + background: var(--vp-c-control-hover); + } +} + +.vp-demo-loading { + color: var(--vp-c-accent-bg); +} + +.vp-demo-display { + position: relative; + overflow: auto; + max-height: 400px; + padding: 20px; + + // border-bottom: 1px solid var(--vp-c-border); + // transform: border-color var(--vp-t-color); + + @media print { + page-break-inside: avoid; + } +} + +.vp-demo-code-wrapper { + overflow: hidden; + transition: height 0.5s; + + @media print { + height: auto !important; + } +} + +.vp-demo-codes > div[class*='language-'] { + border-radius: 0; + + @media (max-width: 419px) { + margin: 0.8rem 0; + + &.line-numbers-mode::after { + display: none; + } + + .line-numbers-wrapper { + display: none; + } + + pre[class*='language-'] { + padding: 1.25rem 1.25rem 1rem; + } + } + + &.line-numbers-mode::after { + border-radius: 0; + } + + &:first-child pre { + margin-top: 0 !important; + } + + &:only-child pre { + margin-bottom: 0 !important; + } +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/getCodeInfo.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/getCodeInfo.ts new file mode 100644 index 0000000000..6a602f0c50 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/getCodeInfo.ts @@ -0,0 +1,36 @@ +import { keys } from '@vuepress/helper/client' +import { PRE_PROCESSOR_CONFIG } from './preprocessorConfig.js' +import type { CodeInfo } from './typings.js' + +const LANGUAGES = ['html', 'js', 'css'] as const + +export const getCodeInfo = (code: Record): CodeInfo => { + const codeInfo: CodeInfo = { + html: [], + js: [], + css: [], + isLegal: false, + } + + LANGUAGES.forEach((type) => { + const match = keys(code).filter((language) => + PRE_PROCESSOR_CONFIG[type].types.includes(language), + ) + + if (match.length) { + const language = match[0] + + codeInfo[type] = [ + code[language].replace(/^\n|\n$/g, ''), + PRE_PROCESSOR_CONFIG[type].map[language] ?? language, + ] + } + }) + + codeInfo.isLegal = + (!codeInfo.html.length || codeInfo.html[1] === 'none') && + (!codeInfo.js.length || codeInfo.js[1] === 'none') && + (!codeInfo.css.length || codeInfo.css[1] === 'none') + + return codeInfo +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/getDemoOptions.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/getDemoOptions.ts new file mode 100644 index 0000000000..7fb0bb4c11 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/getDemoOptions.ts @@ -0,0 +1,13 @@ +import type { DemoOptions } from '../../shared/index.js' + +declare const __DEMO_OPTIONS__: Partial + +const { jsLib, cssLib, ...options } = __DEMO_OPTIONS__ + +export const getDemoOptions = (config: Partial): DemoOptions => ({ + transpile: false, + ...options, + ...config, + jsLib: Array.from(new Set([...(jsLib ?? []), ...(config.jsLib ?? [])])), + cssLib: Array.from(new Set([...(cssLib ?? []), ...(config.cssLib ?? [])])), +}) diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/icons.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/icons.ts new file mode 100644 index 0000000000..c5d4fbb43e --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/icons.ts @@ -0,0 +1,5 @@ +export const CODEPEN_SVG = + '' + +export const JSFIDDLE_SVG = + '' diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/index.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/index.ts new file mode 100644 index 0000000000..1cc97b7651 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/index.ts @@ -0,0 +1,8 @@ +export * from './getCodeInfo.js' +export * from './icons.js' +export * from './normal.js' +export * from './react.js' +export * from './vue.js' +export * from './preprocessorConfig.js' +export * from './utils.js' +export type * from './typings.js' diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/normal.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/normal.ts new file mode 100644 index 0000000000..30f8a09ec7 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/normal.ts @@ -0,0 +1,27 @@ +import type { DemoOptions } from '../../shared/index.js' +import { getDemoOptions } from './getDemoOptions.js' +import type { CodeInfo, DemoInfo } from './typings.js' +import { BABEL_LINK, loadScript } from './utils.js' + +export const getNormalInfo = ( + code: CodeInfo, + config: Partial, +): DemoInfo => { + const codeConfig = getDemoOptions(config) + const js = code.js[0] ?? '' + + return { + ...codeConfig, + html: code.html[0] ?? '', + js, + css: code.css[0] ?? '', + isLegal: code.isLegal, + getScript: (): string => + codeConfig.transpile && window.Babel + ? (window.Babel.transform(js, { presets: ['es2015'] })?.code ?? '') + : js, + } +} + +export const loadNormalScripts = (transpile: boolean): Promise => + transpile ? loadScript(BABEL_LINK) : Promise.resolve() diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/preprocessorConfig.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/preprocessorConfig.ts new file mode 100644 index 0000000000..206afbbfac --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/preprocessorConfig.ts @@ -0,0 +1,44 @@ +export type PreProcessorType = 'css' | 'html' | 'js' + +export const PRE_PROCESSOR_CONFIG: Record< + PreProcessorType, + { + types: string[] + map: Record + } +> = { + html: { + types: ['html', 'slim', 'haml', 'md', 'markdown', 'vue'], + map: { + html: 'none', + vue: 'none', + md: 'markdown', + }, + }, + js: { + types: [ + 'js', + 'javascript', + 'coffee', + 'coffeescript', + 'ts', + 'typescript', + 'ls', + 'livescript', + ], + map: { + js: 'none', + javascript: 'none', + coffee: 'coffeescript', + ls: 'livescript', + ts: 'typescript', + }, + }, + css: { + types: ['css', 'less', 'sass', 'scss', 'stylus', 'styl'], + map: { + css: 'none', + styl: 'stylus', + }, + }, +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/react.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/react.ts new file mode 100644 index 0000000000..aa32c974a9 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/react.ts @@ -0,0 +1,60 @@ +import type { DemoOptions } from '../../shared/index.js' +import { getDemoOptions } from './getDemoOptions.js' +import type { CodeInfo, DemoInfo } from './typings.js' +import { BABEL_LINK, appDivWrapper, cjsWrapper, loadScript } from './utils.js' + +declare const __DEMO_REACT_LINK__: string +declare const __DEMO_REACT_DOM_LINK__: string + +const REACT_LINK = __DEMO_REACT_LINK__ +const REACT_DOM_LINK = __DEMO_REACT_DOM_LINK__ + +const getReactTemplate = (code: string): string => + `${code + .replace('export default ', 'const $reactApp = ') + .replace( + /App\.__style__(\s*)=(\s*)`([\s\S]*)?`/, + '', + )};\nReactDOM.createRoot(document.getElementById("app")).render(React.createElement($reactApp))` + +export const getReactInfo = ( + code: CodeInfo, + config: Partial, +): DemoInfo => { + const demoOptions = getDemoOptions(config) + const js = code.js[0] ?? '' + + return { + ...demoOptions, + html: appDivWrapper(''), + js: getReactTemplate(js), + css: + code.css[0] ?? + code.js[0] + ?.replace(/App\.__style__(?:\s*)=(?:\s*)`([\s\S]*)?`/, '$1') + .trim() ?? + '', + isLegal: code.isLegal, + jsLib: [REACT_LINK, REACT_DOM_LINK, ...demoOptions.jsLib], + jsx: true, + getScript: (): string => { + const scriptStr = window.Babel + ? (window.Babel.transform(js, { + presets: ['es2015', 'react'], + })?.code ?? '') + : js + + return `window.ReactDOM.createRoot(document.firstElementChild).render(window.React.createElement(${cjsWrapper( + scriptStr, + )}))` + }, + } +} + +export const loadReactScripts = (transpile: boolean): Promise => { + const promises = [loadScript(REACT_LINK), loadScript(REACT_DOM_LINK)] + + if (transpile) promises.push(loadScript(BABEL_LINK)) + + return Promise.all(promises) +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/typings.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/typings.ts new file mode 100644 index 0000000000..fa77dee664 --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/typings.ts @@ -0,0 +1,17 @@ +import type { DemoOptions } from '../../shared/index.js' + +export interface CodeInfo { + html: [] | [code: string, type: string] + js: [] | [code: string, type: string] + css: [] | [code: string, type: string] + isLegal: boolean +} + +export interface DemoInfo extends DemoOptions { + html: string + js: string + css: string + isLegal: boolean + jsx?: boolean + getScript: () => string +} diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/utils.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/utils.ts new file mode 100644 index 0000000000..c5c1b7b84e --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/utils.ts @@ -0,0 +1,102 @@ +import { isDef, isPlainObject, keys } from '@vuepress/helper/client' + +import type { DemoInfo } from './typings.js' + +declare const __DEMO_BABEL_LINK__: string + +export const BABEL_LINK = __DEMO_BABEL_LINK__ + +export type ScriptLoadState = Record> + +const state: ScriptLoadState = {} + +export const h = ( + tag: string, + attrs?: Record, + children?: HTMLElement[], +): HTMLElement => { + const node = document.createElement(tag) + + if (isPlainObject(attrs)) + keys(attrs).forEach((key) => { + if (key.indexOf('data')) { + node[key] = attrs[key] + } else { + const k = key.replace('data', '') + + node.dataset[k] = attrs[key] + } + }) + + if (children) + children.forEach((child) => { + node.appendChild(child) + }) + + return node +} + +export const loadScript = (link: string): Promise => { + if (isDef(state[link])) return state[link] + + const loadEvent = new Promise((resolve) => { + const script = document.createElement('script') + + script.src = link + document.querySelector('body')?.appendChild(script) + + script.onload = (): void => { + resolve() + } + }) + + state[link] = loadEvent + + return loadEvent +} + +export const injectCSS = (shadowRoot: ShadowRoot, code: DemoInfo): void => { + if ( + code.css && + // Style not injected + Array.from(shadowRoot.childNodes).every( + (element) => element.nodeName !== 'STYLE', + ) + ) { + const style = h('style', { innerHTML: code.css }) + + shadowRoot.appendChild(style) + } +} + +export const injectScript = ( + id: string, + shadowRoot: ShadowRoot, + code: DemoInfo, +): void => { + const scriptText = code.getScript() + + if ( + scriptText && + // Style not injected + Array.from(shadowRoot.childNodes).every( + (element) => element.nodeName !== 'SCRIPT', + ) + ) { + const script = document.createElement('script') + + script.appendChild( + document.createTextNode( + // Here we are fixing `document` variable back to shadowDOM + `{const document=window.document.querySelector('#${id} .vp-demo-display').shadowRoot;\n${scriptText}}`, + ), + ) + shadowRoot.appendChild(script) + } +} + +export const appDivWrapper = (html: string): string => + `
\n${html}\n
` + +export const cjsWrapper = (scriptStr: string): string => + `(function(exports){var module={};module.exports=exports;${scriptStr};return module.exports.__esModule?exports.default:module.exports;})({})` diff --git a/plugins/markdown/plugin-markdown-demo/src/client/utils/vue.ts b/plugins/markdown/plugin-markdown-demo/src/client/utils/vue.ts new file mode 100644 index 0000000000..7cb1689c8d --- /dev/null +++ b/plugins/markdown/plugin-markdown-demo/src/client/utils/vue.ts @@ -0,0 +1,75 @@ +import type { DemoOptions } from '../../shared/index.js' +import { getDemoOptions } from './getDemoOptions.js' +import type { CodeInfo, DemoInfo } from './typings.js' +import { BABEL_LINK, appDivWrapper, cjsWrapper, loadScript } from './utils.js' + +declare const __DEMO_VUE_LINK__: string + +const VUE_LINK = __DEMO_VUE_LINK__ +const VUE_TEMPLATE_REG = /