svelte-markdoc-preprocess
Version:
A Svelte preprocessor that allows you to use Markdoc.
481 lines (440 loc) • 14.1 kB
text/typescript
import type {
Schema,
SchemaAttribute,
NodeType,
ConfigType,
} from '@markdoc/markdoc';
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 type { Config } from './config.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;
type Var = {
name: string;
type: StringConstructor | NumberConstructor | BooleanConstructor;
};
export async function transformer({
content,
filename,
nodes_file,
tags_file,
partials_dir,
layouts,
generate_schema,
config,
validation_threshold,
allow_comments,
highlighter,
}: {
content: string;
filename: string;
nodes_file: Config['nodes'];
tags_file: Config['tags'];
partials_dir: Config['partials'];
layouts: Config['layouts'];
generate_schema: Config['generateSchema'];
config: Config['config'];
validation_threshold: Config['validationThreshold'];
allow_comments: Config['allowComments'];
highlighter: Config['highlighter'];
}): Promise<string> {
/**
* 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) : {}
) as Record<string, string>;
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<string, string>();
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: ConfigType = {
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<Config['validationThreshold'], number>([
['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: Record<string, string>,
): string {
return (
`<script context="module">` +
`export const frontmatter = ${JSON.stringify(frontmatter)};` +
`</script>`
);
}
export function get_component_vars(
path: string,
layout: string,
): Record<string, SchemaAttribute> {
const target = join(dirname(layout), path);
const data = read_file(target);
/**
* create an ast using typescript
*/
const ast = svelteParse(data);
const props: ReturnType<typeof get_component_vars> = {};
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: string | undefined = undefined;
let hasDefault: boolean = 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: Record<string, string> = {
'{': '{',
'}': '}',
};
const uc_regular_expression = new RegExp(Object.keys(uc_map).join('|'), 'gi');
export function sanitize_for_svelte(content: string): string {
return content.replace(
uc_regular_expression,
(matched) => uc_map[matched.toLowerCase()],
);
}
function get_node_defaults(node_type: NodeType): Partial<Schema> {
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: Config['nodes'],
): Partial<Record<NodeType, Schema>> {
const nodes: Record<string, Schema> = {};
if (nodes_file) {
for (const [name] of each_exported_var(nodes_file)) {
const type = name.toLowerCase() as NodeType;
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: Config['tags']): Record<string, Schema> {
const tags: Record<string, Schema> = {};
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: Config['partials'],
): Record<string, ReturnType<typeof markdocParse>> {
if (!folder) {
return {};
}
return get_all_files(folder).reduce<ReturnType<typeof prepare_partials>>(
(carry, file) => {
carry[file] = markdocParse(read_file(folder, file));
return carry;
},
{},
);
}
function each_exported_var(filepath: string): Array<[string, string]> {
const data = read_file(filepath);
const ast = svelteParse(data);
const tup: Array<[string, string]> = [];
//@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: Record<string, Schema>): void {
// 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);
}
}
}
}