334 lines
10 KiB
Vue
Raw Permalink Normal View History

2025-09-05 14:59:21 +08:00
<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>