Skip to content

Commit

Permalink
feat: variable binding (#1266)
Browse files Browse the repository at this point in the history
Co-authored-by: Sébastien Chopin <seb@nuxtjs.com>
  • Loading branch information
farnabaz and Atinux committed Jun 23, 2022
1 parent ea23215 commit b2d775b
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 21 deletions.
7 changes: 6 additions & 1 deletion docs/components/content/Playground.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { ref, useAsyncData, shallowRef, computed, onMounted, watch } from '#impo
import { parse } from '../../../src/runtime/markdown-parser'
import { useShiki } from '../../editor/useShiki.ts'
const INITIAL_CODE = `# MDC
const INITIAL_CODE = `---
title: MDC
---
# {{ $doc.title}}
MDC stands for _**M**ark**D**own **C**omponents_.
Expand Down Expand Up @@ -49,6 +53,7 @@ const { data: doc, refresh } = await useAsyncData('playground', async () => {
_draft: false,
_type: 'markdown',
updatedAt: new Date().toISOString(),
...parsed.meta || {},
...parsed
}
} catch (e) {
Expand Down
28 changes: 26 additions & 2 deletions playground/pages/playground.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
<script setup>
const PARSE_SERVER = 'https://mdc.nuxt.dev/api/parse'
import { ref, useAsyncData } from '#imports'
const PARSE_SERVER = 'http://localhost:3000/api/parse'
const INITIAL_CODE = `# MDC
const INITIAL_CODE = `---
title: MDC
cover: https://nuxtjs.org/design-kit/colored-logo.svg
---
:img{:src="cover"}
# {{ $doc.title }}
MDC stands for _**M**ark**D**own **C**omponents_.
Expand All @@ -10,6 +17,23 @@ This syntax supercharges regular Markdown to write documents interacting deeply
## Next steps
- [Install Nuxt Content](/get-started)
- [Explore the MDC syntax](/guide/writing/mdc)
You are visiting document: **{{ $doc._id }}**.
Current route is: **{{ $route.path }}**
::alert
---
type: success
---
This is an alert for _**{{ type }}**_
::
::alert{type="danger"}
This is an alert for _**{{ type }}**_
::
`
const content = ref(INITIAL_CODE)
Expand Down
60 changes: 44 additions & 16 deletions src/runtime/components/MarkdownRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { find, html } from 'property-information'
// eslint-disable-next-line import/no-named-as-default
import htmlTags from 'html-tags'
import type { VNode, ConcreteComponent } from 'vue'
import { useRuntimeConfig } from '#app'
import { useRuntimeConfig, useRoute } from '#app'
import type { MarkdownNode, ParsedContentMeta } from '../types'

type CreateElement = typeof h
Expand Down Expand Up @@ -106,58 +106,86 @@ export default defineComponent({
/**
* Render a markdown node
*/
function renderNode (node: MarkdownNode, h: CreateElement, documentMeta: ParsedContentMeta): ContentVNode {
const originalTag = node.tag
// `_ignoreMap` is an special prop to disables tag-mapper
const renderTag: string = (typeof node.props?.__ignoreMap === 'undefined' && documentMeta.tags[node.tag]) || node.tag

function renderNode (node: MarkdownNode, h: CreateElement, documentMeta: ParsedContentMeta, parentScope: any = {}): ContentVNode {
/**
* Render Text node
*/
if (node.type === 'text') {
return h(Text, node.value)
}

const originalTag = node.tag!
// `_ignoreMap` is an special prop to disables tag-mapper
const renderTag: string = (typeof node.props?.__ignoreMap === 'undefined' && documentMeta.tags[originalTag]) || originalTag

if (node.tag === 'binding') {
return renderBinding(node, h, documentMeta, parentScope)
}

const component = resolveVueComponent(renderTag)
if (typeof component === 'object') {
component.tag = originalTag
}

const props = propsToData(node, documentMeta)
return h(
component as any,
propsToData(node, documentMeta),
renderSlots(node, h, documentMeta)
props,
renderSlots(node, h, documentMeta, { ...parentScope, ...props })
)
}

function renderBinding (node: MarkdownNode, h: CreateElement, documentMeta: ParsedContentMeta, parentScope: any = {}): ContentVNode {
const data = {
...parentScope,
$route: () => useRoute(),
$document: documentMeta,
$doc: documentMeta
}
const splitter = /\.|\[(\d+)\]/
const keys = node.props?.value.trim().split(splitter).filter(Boolean)
const value = keys.reduce((data, key) => {
if (key in data) {
if (typeof data[key] === 'function') {
return data[key]()
} else {
return data[key]
}
}
return {}
}, data)

return h(Text, value)
}

/**
* Create slots from `node` template children.
*/
function renderSlots (node: MarkdownNode, h: CreateElement, documentMeta: ParsedContentMeta) {
function renderSlots (node: MarkdownNode, h: CreateElement, documentMeta: ParsedContentMeta, parentProps: any): ContentVNode[] {
const children: MarkdownNode[] = node.children || []

const slots: Record<string, Array<VNode | string>> = children.reduce((data, node) => {
if (!isTemplate(node)) {
data[DEFAULT_SLOT].push(renderNode(node, h, documentMeta))
data[DEFAULT_SLOT].push(renderNode(node, h, documentMeta, parentProps))
return data
}

if (isDefaultTemplate(node)) {
data[DEFAULT_SLOT].push(...node.children.map(child => renderNode(child, h, documentMeta)))
data[DEFAULT_SLOT].push(...(node.children || []).map(child => renderNode(child, h, documentMeta, parentProps)))
return data
}

const slotName = getSlotName(node)
data[slotName] = node.children.map(child => renderNode(child, h, documentMeta))
data[slotName] = (node.children || []).map(child => renderNode(child, h, documentMeta, parentProps))

return data
}, {
[DEFAULT_SLOT]: []
[DEFAULT_SLOT]: [] as any[]
})

return Object.fromEntries(
Object.entries(slots).map(([name, vDom]) => ([name, createSlotFunction(vDom)]))
)
const slotEntries = Object.entries(slots).map(([name, vDom]) => ([name, createSlotFunction(vDom)]))

return Object.fromEntries(slotEntries)
}

/**
Expand Down
18 changes: 17 additions & 1 deletion src/runtime/markdown-parser/remark-mdc/from-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const enter = {
componentContainerDataSection: enterContainerDataSection,
componentContainerAttributes: enterAttributes,
componentContainerLabel: enterContainerLabel,

bindingContent: enterBindingContent,
componentLeaf: enterLeaf,
componentLeafAttributes: enterAttributes,

Expand All @@ -24,6 +24,7 @@ const enter = {
componentTextAttributes: enterAttributes
}
const exit = {
bindingContent: exitBindingContent,
componentContainerSectionTitle: exitContainerSectionTitle,
listUnordered: conditionalExit,
listOrdered: conditionalExit,
Expand Down Expand Up @@ -63,6 +64,21 @@ const exit = {
componentTextName: exitName
}

// Bindings
function enterBindingContent (token) {
this.enter({
type: 'textComponent',
name: 'binding',
attributes: {
value: this.sliceSerialize(token).trim()
}
}, token)
}

function exitBindingContent (token) {
this.exit(token)
}

function enterContainer (token: Token) {
enterToken.call(this, 'containerComponent', token)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import tokenizeSpan from './tokenize-span'
import tokenizeAttribute from './tokenize-attribute'
import tokenizeBinding from './tokenize-binding'
import tokenizeInline from './tokenize-inline'
import tokenizeContainer from './tokenize-container'
import tokenizeContainerIndented from './tokenize-container-indented'
Expand All @@ -16,7 +17,7 @@ export default function micromarkComponentsExtension () {
text: {
[Codes.colon]: tokenizeInline,
[Codes.openingSquareBracket]: [tokenizeSpan],
[Codes.openingCurlyBracket]: tokenizeAttribute
[Codes.openingCurlyBracket]: [tokenizeBinding, tokenizeAttribute]
},
flow: {
[Codes.colon]: [tokenizeContainer]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Effects, State, Code, TokenizeContext } from 'micromark-util-types'
import { Codes } from './constants'

function attempClose (this: TokenizeContext, effects: Effects, ok: State, nok: State) {
return start

function start (code: Code) {
if (code !== Codes.closingCurlyBracket) {
return nok(code)
}
effects.exit('bindingContent')
effects.enter('bindingFence')
effects.consume(code)
return secondBracket
}

function secondBracket (code: Code) {
if (code !== Codes.closingCurlyBracket) {
return nok(code)
}
effects.consume(code)
effects.exit('bindingFence')

return ok
}
}

function tokenize (this: TokenizeContext, effects: Effects, ok: State, nok: State) {
return start

function start (code: Code): void | State {
if (code !== Codes.openingCurlyBracket) {
throw new Error('expected `{`')
}

effects.enter('bindingFence')
effects.consume(code)
return secondBracket
}

function secondBracket (code: Code): void | State {
if (code !== Codes.openingCurlyBracket) {
return nok(code)
}
effects.consume(code)
effects.exit('bindingFence')
effects.enter('bindingContent')

return content
}

function content (code: Code): void | State {
if (code === Codes.closingCurlyBracket) {
return effects.attempt({ tokenize: attempClose, partial: true }, close, (code) => {
effects.consume(code)
return content
})(code)
}

effects.consume(code)
return content
}

function close (code: Code): void | State {
return ok(code)
}
}

export default {
tokenize
}
2 changes: 2 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { testParserHooks } from './features/parser-hooks'
import { testModuleOption } from './features/module-options'
import { testContentQuery } from './features/content-query'
import { testHighlighter } from './features/highlighter'
import { testMarkdownRenderer } from './features/renderer-markdown'

const spyConsoleWarn = vi.spyOn(global.console, 'warn')

Expand Down Expand Up @@ -119,6 +120,7 @@ describe('fixtures:basic', async () => {
testNavigation()

testMarkdownParser()
testMarkdownRenderer()

testMarkdownParserExcerpt()

Expand Down
56 changes: 56 additions & 0 deletions test/features/renderer-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, test, expect } from 'vitest'
import { $fetch } from '@nuxt/test-utils'

const content = `---
title: MDC
cover: https://nuxtjs.org/design-kit/colored-logo.svg
---
:img{:src="cover"}
# {{ $doc.title }}
MDC stands for _**M**ark**D**own **C**omponents_.
This syntax supercharges regular Markdown to write documents interacting deeply with any Vue component from your \`components/content/\` directory or provided by a module.
## Next steps
- [Install Nuxt Content](/get-started)
- [Explore the MDC syntax](/guide/writing/mdc)
You are visiting document: {{ $doc._id }}.
Current route is: {{ $route.path }}
::alert
---
type: success
---
This is an alert for {{ type }}
::
::alert{type="danger"}
This is an alert for {{ type }}
::
`

export const testMarkdownRenderer = () => {
describe('renderer:markdown', () => {
test('bindings', async () => {
const rendered = await $fetch('/parse', {
params: {
content
}
})

expect(rendered).toContain('<img src="https://nuxtjs.org/design-kit/colored-logo.svg" alt>')

expect(rendered).toContain('<h1 id><!--[-->MDC<!--]--></h1>')
expect(rendered).toContain('You are visiting document: content:index.md.')
expect(rendered).toContain('Current route is: /parse')
expect(rendered).toContain('This is an alert for success')
expect(rendered).toContain('This is an alert for danger')
})
})
}
19 changes: 19 additions & 0 deletions test/fixtures/basic/pages/parse.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<div>
<MarkdownRenderer :value="data" />
</div>
</template>

<script setup>
const { content } = useRoute().query
const { data } = await useAsyncData(content, async () => {
return await $fetch('/api/parse', {
method: 'POST',
cors: true,
body: {
id: 'content:index.md',
content
}
})
})
</script>

0 comments on commit b2d775b

Please sign in to comment.