svelte-markdoc-preprocess
Version:
A Svelte preprocessor that allows you to use Markdoc.
362 lines (361 loc) • 12.2 kB
JavaScript
import { dirname, join } from 'path';
import { load as loadYaml } from 'js-yaml';
import { parse as svelteParse } from 'svelte/compiler';
import { render_html } from './renderer.js';
import { get_all_files, path_exists, read_file, relative_posix_path, write_to_file, } from './utils.js';
import * as default_schema from './default_schema.js';
import { LAYOUT_IMPORT, NODES_IMPORT, TAGS_IMPORT } from './constants.js';
import { log_error, log_validation_error } from './log.js';
import { walk } from 'estree-walker';
import md from '@markdoc/markdoc';
const { parse: markdocParse, transform, Tag, validate, Tokenizer } = md;
export async function transformer({ content, filename, nodes_file, tags_file, partials_dir, layouts, generate_schema, config, validation_threshold, allow_comments, highlighter, }) {
/**
* create tokenizer
*/
const tokenizer = new Tokenizer({
allowComments: allow_comments,
});
const tokens = tokenizer.tokenize(content);
/**
* create ast for markdoc
*/
const ast = markdocParse(tokens);
/**
* load frontmatter
*/
const frontmatter = (ast.attributes.frontmatter ? loadYaml(ast.attributes.frontmatter) : {});
const has_frontmatter = Object.keys(frontmatter).length > 0;
/**
* get layout from frontmatter, use default or no at all
*/
const selected_layout = layouts
? (layouts[frontmatter?.layout ?? 'default'] ?? undefined)
: undefined;
const has_layout = selected_layout !== undefined;
/**
* add used svelte components to the script tag
*/
let dependencies = new Map();
const tags = prepare_tags(tags_file);
const has_tags = Object.keys(tags).length > 0;
const nodes = prepare_nodes(nodes_file);
const has_nodes = Object.keys(nodes).length > 0;
const partials = prepare_partials(partials_dir);
/**
* add import for tags
*/
if (tags_file && has_tags) {
dependencies.set(`* as ${TAGS_IMPORT}`, relative_posix_path(filename, tags_file));
}
/**
* add import for nodes
*/
if (nodes_file && has_nodes) {
dependencies.set(`* as ${NODES_IMPORT}`, relative_posix_path(filename, nodes_file));
}
/**
* add import for layout
*/
if (selected_layout && has_layout) {
dependencies.set(LAYOUT_IMPORT, relative_posix_path(filename, selected_layout));
}
/**
* generate schema for markdoc extension
*/
if (generate_schema) {
create_schema(tags);
}
/**
* create configuration for markdoc
*/
const configuration = {
tags: {
...config?.tags,
...tags,
},
nodes: {
...config?.nodes,
...nodes,
},
partials: {
...config?.partials,
...partials,
},
variables: {
...config?.variables,
frontmatter,
},
functions: config?.functions,
validation: config?.validation,
};
/**
* validate markdoc asd and log errors, warnings & co
*/
const thresholds = new Map([
['debug', 0],
['info', 1],
['warning', 2],
['error', 3],
['critical', 4],
]);
const threshold = thresholds.get(validation_threshold);
const errors = validate(ast, configuration);
for (const error of errors) {
log_validation_error(error, filename);
const level = thresholds.get(error.error.level);
if (threshold && level && level >= threshold) {
throw new Error(error.error.message);
}
}
/**
* transform the ast with svelte components
*/
const nast = transform(ast, configuration);
/**
* render to html
*/
const code = await render_html(nast, dependencies, highlighter);
let transformed = '';
/**
* add module context if frontmatter is used
*/
if (Object.keys(frontmatter).length > 0) {
transformed += create_module_context(frontmatter);
}
/**
* add all dependencies to the document
*/
if (dependencies.size > 0) {
transformed += `<script>`;
for (const [name, path] of dependencies) {
transformed += `import ${name} from '${path}';`;
}
transformed += `</script>`;
}
/**
* wrap the content in the layout
*/
if (has_layout) {
transformed += `<${LAYOUT_IMPORT}`;
transformed += has_frontmatter ? ' {...frontmatter}>' : '>';
transformed += code;
transformed += `</${LAYOUT_IMPORT}>`;
}
else {
transformed += code;
}
return transformed;
}
export function create_module_context(frontmatter) {
return (`<script context="module">` +
`export const frontmatter = ${JSON.stringify(frontmatter)};` +
`</script>`);
}
export function get_component_vars(path, layout) {
const target = join(dirname(layout), path);
const data = read_file(target);
/**
* create an ast using typescript
*/
const ast = svelteParse(data);
const props = {};
if (!ast.instance) {
return props; // No instance script
}
walk(ast.instance.content, {
enter(node, parent, prop, index) {
// Look for variable declarations like: let { prop1, prop2 = defaultVal } = $props();
if (node.type === 'VariableDeclarator' &&
node.init?.type === 'CallExpression' &&
node.init.callee.type === 'Identifier' &&
node.init.callee.name === '$props' &&
node.id.type === 'ObjectPattern') {
// Found the $props() destructuring assignment
node.id.properties.forEach((property) => {
if (property.type === 'Property') {
let propName = undefined;
let hasDefault = false;
// Simple case: { propName }
if (property.key.type === 'Identifier' &&
property.value.type === 'Identifier' &&
property.key.name === property.value.name) {
propName = property.key.name;
}
// Case with default value: { propName = defaultValue }
else if (property.key.type === 'Identifier' &&
property.value.type === 'AssignmentPattern') {
if (property.value.left.type === 'Identifier') {
propName = property.value.left.name;
hasDefault = true;
}
}
if (propName !== undefined) {
if (propName === 'children')
return;
props[propName] = {
required: !hasDefault,
};
}
}
});
}
},
});
return props;
}
const uc_map = {
'{': '{',
'}': '}',
};
const uc_regular_expression = new RegExp(Object.keys(uc_map).join('|'), 'gi');
export function sanitize_for_svelte(content) {
return content.replace(uc_regular_expression, (matched) => uc_map[matched.toLowerCase()]);
}
function get_node_defaults(node_type) {
switch (node_type) {
case 'blockquote':
return default_schema.blockquote;
case 'em':
return default_schema.em;
case 'heading':
return default_schema.heading;
case 'hr':
return default_schema.hr;
case 'image':
return default_schema.image;
case 'inline':
return default_schema.inline;
case 'item':
return default_schema.item;
case 'link':
return default_schema.link;
case 'list':
return default_schema.list;
case 'paragraph':
return default_schema.paragraph;
case 'strong':
return default_schema.strong;
case 'table':
return default_schema.table;
case 'code':
return default_schema.code;
case 'comment':
return default_schema.comment;
case 'document':
return default_schema.document;
case 'error':
return default_schema.error;
case 'fence':
return default_schema.fence;
case 'hardbreak':
return default_schema.hardbreak;
case 'node':
return default_schema.node;
case 's':
return default_schema.s;
case 'softbreak':
return default_schema.softbreak;
case 'tbody':
return default_schema.tbody;
case 'td':
return default_schema.td;
case 'text':
return default_schema.text;
case 'th':
return default_schema.th;
case 'thead':
return default_schema.thead;
case 'tr':
return default_schema.tr;
default:
throw new Error(`Unknown node type: ${node_type}`);
}
}
function prepare_nodes(nodes_file) {
const nodes = {};
if (nodes_file) {
for (const [name] of each_exported_var(nodes_file)) {
const type = name.toLowerCase();
nodes[name.toLowerCase()] = {
...get_node_defaults(type),
transform(node, config) {
return new Tag(`${NODES_IMPORT}.${name}`, node.transformAttributes(config), node.transformChildren(config));
},
};
}
}
return nodes;
}
function prepare_tags(tags_file) {
const tags = {};
if (tags_file) {
for (const [name, value] of each_exported_var(tags_file)) {
/**
* extract all exported variables from the components
*/
const attributes = get_component_vars(String(value), tags_file);
tags[name.toLowerCase()] = {
render: `${TAGS_IMPORT}.${name}`,
attributes,
};
}
}
return tags;
}
function prepare_partials(folder) {
if (!folder) {
return {};
}
return get_all_files(folder).reduce((carry, file) => {
carry[file] = markdocParse(read_file(folder, file));
return carry;
}, {});
}
function each_exported_var(filepath) {
const data = read_file(filepath);
const ast = svelteParse(data);
const tup = [];
//@ts-ignore weird types here from svelte
walk(ast, {
enter(node, parent) {
if (node.type === 'ExportSpecifier') {
if (parent?.type === 'ExportNamedDeclaration' &&
parent?.source &&
node.exported.type === 'Identifier') {
tup.push([node.exported.name, String(parent.source.value)]);
}
}
},
});
return tup;
}
function create_schema(tags) {
// Create schema from the record
// use regex to get the type from the ouput of interface `toString` method`
// and then remove the double quotes from the json
const object = JSON.stringify(tags, (key, value) => key === 'type' && [Number, String, Boolean].includes(value)
? ((value + '').match(/.*([A-Z].*)\(\).*/)?.pop() ?? value)
: value).replaceAll(/"(Number|String|Boolean)"/g, '$1');
const content = `export default { tags: ${object} };`;
const target_directory = join(process.cwd(), '.svelte-kit');
const target_file = join(target_directory, 'markdoc_schema.js');
if (path_exists(target_directory)) {
try {
if (path_exists(target_file)) {
if (content === read_file(target_file)) {
return;
}
}
write_to_file(target_file, content);
}
catch (err) {
if (err instanceof Error) {
log_error(err.message);
}
else {
console.error(err);
}
}
}
}