334 lines
10 KiB
Vue
334 lines
10 KiB
Vue
|
|
<script>
|
||
|
|
import { h, resolveComponent, reactive, watch, Text, Comment, defineAsyncComponent, defineComponent, toRaw, computed, getCurrentInstance } from "vue";
|
||
|
|
import destr from "destr";
|
||
|
|
import { kebabCase, pascalCase } from "scule";
|
||
|
|
import { find, html } from "property-information";
|
||
|
|
import htmlTags from "../parser/utils/html-tags-list";
|
||
|
|
import { flatUnwrap } from "../utils/node";
|
||
|
|
const DEFAULT_SLOT = "default";
|
||
|
|
const rxOn = /^@|^v-on:/;
|
||
|
|
const rxBind = /^:|^v-bind:/;
|
||
|
|
const rxModel = /^v-model/;
|
||
|
|
const nativeInputs = ["select", "textarea", "input"];
|
||
|
|
const proseComponentMap = Object.fromEntries(["p", "a", "blockquote", "code", "pre", "code", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "ul", "ol", "li", "strong", "table", "thead", "tbody", "td", "th", "tr", "script"].map((t) => [t, `prose-${t}`]));
|
||
|
|
export default defineComponent({
|
||
|
|
name: "MDCRenderer",
|
||
|
|
props: {
|
||
|
|
/**
|
||
|
|
* Content to render
|
||
|
|
*/
|
||
|
|
body: {
|
||
|
|
type: Object,
|
||
|
|
required: true
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* Document meta data
|
||
|
|
*/
|
||
|
|
data: {
|
||
|
|
type: Object,
|
||
|
|
default: () => ({})
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* Class(es) to bind to the component
|
||
|
|
*/
|
||
|
|
class: {
|
||
|
|
type: [String, Object],
|
||
|
|
default: void 0
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* Root tag to use for rendering
|
||
|
|
*/
|
||
|
|
tag: {
|
||
|
|
type: [String, Boolean],
|
||
|
|
default: void 0
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* Whether or not to render Prose components instead of HTML tags
|
||
|
|
*/
|
||
|
|
prose: {
|
||
|
|
type: Boolean,
|
||
|
|
default: void 0
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* The map of custom components to use for rendering.
|
||
|
|
*/
|
||
|
|
components: {
|
||
|
|
type: Object,
|
||
|
|
default: () => ({})
|
||
|
|
},
|
||
|
|
/**
|
||
|
|
* Tags to unwrap separated by spaces
|
||
|
|
* Example: 'ul li'
|
||
|
|
*/
|
||
|
|
unwrap: {
|
||
|
|
type: [Boolean, String],
|
||
|
|
default: false
|
||
|
|
}
|
||
|
|
},
|
||
|
|
async setup(props) {
|
||
|
|
const $nuxt = getCurrentInstance()?.appContext?.app?.$nuxt;
|
||
|
|
const route = $nuxt?.$route || $nuxt?._route;
|
||
|
|
const { mdc } = $nuxt?.$config?.public || {};
|
||
|
|
const tags = {
|
||
|
|
...mdc?.components?.prose && props.prose !== false ? proseComponentMap : {},
|
||
|
|
...mdc?.components?.map || {},
|
||
|
|
...toRaw(props.data?.mdc?.components || {}),
|
||
|
|
...props.components
|
||
|
|
};
|
||
|
|
const contentKey = computed(() => {
|
||
|
|
const components = (props.body?.children || []).map((n) => n.tag || n.type).filter((t) => !htmlTags.includes(t));
|
||
|
|
return Array.from(new Set(components)).sort().join(".");
|
||
|
|
});
|
||
|
|
const runtimeData = reactive({
|
||
|
|
...props.data
|
||
|
|
});
|
||
|
|
watch(() => props.data, (newData) => {
|
||
|
|
Object.assign(runtimeData, newData);
|
||
|
|
});
|
||
|
|
await resolveContentComponents(props.body, { tags });
|
||
|
|
function updateRuntimeData(code, value) {
|
||
|
|
const lastIndex = code.split(".").length - 1;
|
||
|
|
return code.split(".").reduce((o, k, i) => {
|
||
|
|
if (i == lastIndex && o) {
|
||
|
|
o[k] = value;
|
||
|
|
return o[k];
|
||
|
|
}
|
||
|
|
return typeof o === "object" ? o[k] : void 0;
|
||
|
|
}, runtimeData);
|
||
|
|
}
|
||
|
|
return { tags, contentKey, route, runtimeData, updateRuntimeData };
|
||
|
|
},
|
||
|
|
render(ctx) {
|
||
|
|
const { tags, tag, body, data, contentKey, route, unwrap, runtimeData, updateRuntimeData } = ctx;
|
||
|
|
if (!body) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const meta = { ...data, tags, $route: route, runtimeData, updateRuntimeData };
|
||
|
|
const component = tag !== false ? resolveVueComponent(tag || meta.component?.name || meta.component || "div") : void 0;
|
||
|
|
return component ? h(component, { ...meta.component?.props, class: ctx.class, ...this.$attrs, key: contentKey }, { default: defaultSlotRenderer }) : defaultSlotRenderer?.();
|
||
|
|
function defaultSlotRenderer() {
|
||
|
|
if (unwrap) {
|
||
|
|
return flatUnwrap(
|
||
|
|
renderSlots(body, h, meta, meta).default(),
|
||
|
|
typeof unwrap === "string" ? unwrap.split(" ") : ["*"]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return renderSlots(body, h, meta, meta).default();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
function renderNode(node, h2, documentMeta, parentScope = {}) {
|
||
|
|
if (node.type === "text") {
|
||
|
|
return h2(Text, node.value);
|
||
|
|
}
|
||
|
|
if (node.type === "comment") {
|
||
|
|
return h2(Comment, null, node.value);
|
||
|
|
}
|
||
|
|
const originalTag = node.tag;
|
||
|
|
const renderTag = findMappedTag(node, documentMeta.tags);
|
||
|
|
if (node.tag === "binding") {
|
||
|
|
return renderBinding(node, h2, documentMeta, parentScope);
|
||
|
|
}
|
||
|
|
const component = resolveVueComponent(renderTag);
|
||
|
|
if (typeof component === "object") {
|
||
|
|
component.tag = originalTag;
|
||
|
|
}
|
||
|
|
const props = propsToData(node, documentMeta);
|
||
|
|
return h2(
|
||
|
|
component,
|
||
|
|
props,
|
||
|
|
renderSlots(node, h2, documentMeta, { ...parentScope, ...props })
|
||
|
|
);
|
||
|
|
}
|
||
|
|
function renderBinding(node, h2, documentMeta, parentScope = {}) {
|
||
|
|
const data = {
|
||
|
|
...documentMeta.runtimeData,
|
||
|
|
...parentScope,
|
||
|
|
$document: documentMeta,
|
||
|
|
$doc: documentMeta
|
||
|
|
};
|
||
|
|
const splitter = /\.|\[(\d+)\]/;
|
||
|
|
const keys = node.props?.value.trim().split(splitter).filter(Boolean);
|
||
|
|
const value = keys.reduce((data2, key) => {
|
||
|
|
if (data2 && key in data2) {
|
||
|
|
if (typeof data2[key] === "function") {
|
||
|
|
return data2[key]();
|
||
|
|
} else {
|
||
|
|
return data2[key];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return void 0;
|
||
|
|
}, data);
|
||
|
|
const defaultValue = node.props?.defaultValue;
|
||
|
|
return h2(Text, value ?? defaultValue ?? "");
|
||
|
|
}
|
||
|
|
function renderSlots(node, h2, documentMeta, parentProps) {
|
||
|
|
const children = node.children || [];
|
||
|
|
const slotNodes = children.reduce((data, node2) => {
|
||
|
|
if (!isTemplate(node2)) {
|
||
|
|
data[DEFAULT_SLOT].push(node2);
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
const slotName = getSlotName(node2);
|
||
|
|
data[slotName] = data[slotName] || [];
|
||
|
|
if (node2.type === "element") {
|
||
|
|
data[slotName].push(...node2.children || []);
|
||
|
|
}
|
||
|
|
return data;
|
||
|
|
}, {
|
||
|
|
[DEFAULT_SLOT]: []
|
||
|
|
});
|
||
|
|
const slots = Object.entries(slotNodes).reduce((slots2, [name, children2]) => {
|
||
|
|
if (!children2.length) {
|
||
|
|
return slots2;
|
||
|
|
}
|
||
|
|
slots2[name] = () => {
|
||
|
|
const vNodes = children2.map((child) => renderNode(child, h2, documentMeta, parentProps));
|
||
|
|
return mergeTextNodes(vNodes);
|
||
|
|
};
|
||
|
|
return slots2;
|
||
|
|
}, {});
|
||
|
|
return slots;
|
||
|
|
}
|
||
|
|
function propsToData(node, documentMeta) {
|
||
|
|
const { tag = "", props = {} } = node;
|
||
|
|
return Object.keys(props).reduce(function(data, key) {
|
||
|
|
if (key === "__ignoreMap") {
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
const value = props[key];
|
||
|
|
if (rxModel.test(key)) {
|
||
|
|
return propsToDataRxModel(key, value, data, documentMeta, { native: nativeInputs.includes(tag) });
|
||
|
|
}
|
||
|
|
if (key === "v-bind") {
|
||
|
|
return propsToDataVBind(key, value, data, documentMeta);
|
||
|
|
}
|
||
|
|
if (rxOn.test(key)) {
|
||
|
|
return propsToDataRxOn(key, value, data, documentMeta);
|
||
|
|
}
|
||
|
|
if (rxBind.test(key)) {
|
||
|
|
return propsToDataRxBind(key, value, data, documentMeta);
|
||
|
|
}
|
||
|
|
const { attribute } = find(html, key);
|
||
|
|
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
||
|
|
data[attribute] = value.join(" ");
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
data[attribute] = value;
|
||
|
|
return data;
|
||
|
|
}, {});
|
||
|
|
}
|
||
|
|
function propsToDataRxModel(key, value, data, documentMeta, { native }) {
|
||
|
|
const propName = key.match(/^v-model:([^=]+)/)?.[1] || "modelValue";
|
||
|
|
const field = native ? "value" : propName;
|
||
|
|
const event = native ? "onInput" : `onUpdate:${propName}`;
|
||
|
|
data[field] = evalInContext(value, documentMeta.runtimeData);
|
||
|
|
data[event] = (e) => {
|
||
|
|
documentMeta.updateRuntimeData(value, native ? e.target?.value : e);
|
||
|
|
};
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
function propsToDataVBind(_key, value, data, documentMeta) {
|
||
|
|
const val = evalInContext(value, documentMeta);
|
||
|
|
data = Object.assign(data, val);
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
function propsToDataRxOn(key, value, data, documentMeta) {
|
||
|
|
key = key.replace(rxOn, "");
|
||
|
|
data.on = data.on || {};
|
||
|
|
data.on[key] = () => evalInContext(value, documentMeta);
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
function propsToDataRxBind(key, value, data, documentMeta) {
|
||
|
|
key = key.replace(rxBind, "");
|
||
|
|
data[key] = evalInContext(value, documentMeta);
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
const resolveVueComponent = (component) => {
|
||
|
|
if (typeof component === "string") {
|
||
|
|
if (htmlTags.includes(component)) {
|
||
|
|
return component;
|
||
|
|
}
|
||
|
|
const _component = resolveComponent(pascalCase(component), false);
|
||
|
|
if (!component || _component?.name === "AsyncComponentWrapper") {
|
||
|
|
return _component;
|
||
|
|
}
|
||
|
|
if (typeof _component === "string") {
|
||
|
|
return _component;
|
||
|
|
}
|
||
|
|
if ("setup" in _component) {
|
||
|
|
return defineAsyncComponent(() => new Promise((resolve) => resolve(_component)));
|
||
|
|
}
|
||
|
|
return _component;
|
||
|
|
}
|
||
|
|
return component;
|
||
|
|
};
|
||
|
|
function evalInContext(code, context) {
|
||
|
|
const result = code.split(".").reduce((o, k) => typeof o === "object" ? o[k] : void 0, context);
|
||
|
|
return typeof result === "undefined" ? destr(code) : result;
|
||
|
|
}
|
||
|
|
function getSlotName(node) {
|
||
|
|
let name = "";
|
||
|
|
for (const propName of Object.keys(node.props || {})) {
|
||
|
|
if (!propName.startsWith("#") && !propName.startsWith("v-slot:")) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
name = propName.split(/[:#]/, 2)[1];
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
return name || DEFAULT_SLOT;
|
||
|
|
}
|
||
|
|
function isTemplate(node) {
|
||
|
|
return node.tag === "template";
|
||
|
|
}
|
||
|
|
function mergeTextNodes(nodes) {
|
||
|
|
const mergedNodes = [];
|
||
|
|
for (const node of nodes) {
|
||
|
|
const previousNode = mergedNodes[mergedNodes.length - 1];
|
||
|
|
if (node.type === Text && previousNode?.type === Text) {
|
||
|
|
previousNode.children = previousNode.children + node.children;
|
||
|
|
} else {
|
||
|
|
mergedNodes.push(node);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return mergedNodes;
|
||
|
|
}
|
||
|
|
async function resolveContentComponents(body, meta) {
|
||
|
|
if (!body) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const components = Array.from(new Set(loadComponents(body, meta)));
|
||
|
|
await Promise.all(components.map(async (c) => {
|
||
|
|
if (c?.render || c?.ssrRender || c?.__ssrInlineRender) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const resolvedComponent = resolveVueComponent(c);
|
||
|
|
if (resolvedComponent?.__asyncLoader && !resolvedComponent.__asyncResolved) {
|
||
|
|
await resolvedComponent.__asyncLoader();
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
function loadComponents(node, documentMeta) {
|
||
|
|
const tag = node.tag;
|
||
|
|
if (node.type === "text" || tag === "binding" || node.type === "comment") {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const renderTag = findMappedTag(node, documentMeta.tags);
|
||
|
|
const components2 = [];
|
||
|
|
if (node.type !== "root" && !htmlTags.includes(renderTag)) {
|
||
|
|
components2.push(renderTag);
|
||
|
|
}
|
||
|
|
for (const child of node.children || []) {
|
||
|
|
components2.push(...loadComponents(child, documentMeta));
|
||
|
|
}
|
||
|
|
return components2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function findMappedTag(node, tags) {
|
||
|
|
const tag = node.tag;
|
||
|
|
if (!tag || typeof node.props?.__ignoreMap !== "undefined") {
|
||
|
|
return tag;
|
||
|
|
}
|
||
|
|
return tags[tag] || tags[pascalCase(tag)] || tags[kebabCase(node.tag)] || tag;
|
||
|
|
}
|
||
|
|
</script>
|