Add "allowHTML" option for Markdoc with HTML parsing/processing (#7597)

* 7576 - initial support for HTML inside Markdoc.

This uses htmlparser2 to perform a pure token transform/mutation
on the markdown-it tokens, replacing the original raw HTML string
tokens with a richer set of tokens per HTML node, and in the process
Markdoc tags are interleaved in the resulting token graph at the
appropriate locations

This removes the legacy config of the @astrojs/markdoc integration
entirely (suggested by @bholmesdev) and introduces a new type for
options to be specified in the astro config, initially, with just the new
"enableHTML" option

When "enableHTML" is *not* enabled (the default), the behavior
of the entire @astrojs/markdoc integration should remain functionally
equivalent to before this change

* 7576 - fixed issues with whitespace preservation

also:

* cleaned up " to ' for astro project preferred linting
* made the html rendering test fixture use a dynamic path

* 7576 - detailed nested HTML test coverage

* 7576 -  component + HTML interleaved tests

* 7576 -  fix lint problems from previous changes

* 7576 -  some commentary

* 7576 -  file naming, refactor html under imports, package.json exports definition for html

* 7576

* move out of extensions dir, remove export
* cdata handling changes

* 7576

* inline license from third party code
* cleanup test class copy of HTML output
* remove // third party indicators for imports (clarification: not third party code, just a indicator this group of imports is third party)

* 7576 - fixed test before/after for DRY'ness

* 7576 - no need to React-ify HTML attribute case

* 7576 - rename "enableHTML" option to "allowHTML"

* Added Markdoc allowHTML feature changeset

* 7576 - updated README with allowHTML info

* 7576 - fixed changeset typo

* 7576 - minor edits based on PR feedback for docs

* 7576 - minor edits based on PR feedback for docs
This commit is contained in:
Alex Sherwin 2023-07-24 19:34:06 -04:00 committed by GitHub
parent 81c460e30b
commit 7461e82c81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1405 additions and 43 deletions

View file

@ -0,0 +1,16 @@
---
'@astrojs/markdoc': patch
---
Adds an "allowHTML" Markdoc integration option.
When enabled, all HTML in Markdoc files will be processed, including HTML elements within Markdoc tags and nodes.
Enable this feature in the `markdoc` integration configuration:
```js
// astro.config.mjs
export default defineConfig({
integrations: [markdoc({ allowHTML: true })],
});
```

View file

@ -93,7 +93,7 @@ const { Content } = await entry.render();
📚 See the [Astro Content Collection docs][astro-content-collections] for more information. 📚 See the [Astro Content Collection docs][astro-content-collections] for more information.
## Configuration ## Markdoc config
`@astrojs/markdoc` offers configuration options to use all of Markdoc's features and connect UI components to your content. `@astrojs/markdoc` offers configuration options to use all of Markdoc's features and connect UI components to your content.
@ -401,6 +401,36 @@ const { Content } = await entry.render();
This can now be accessed as `$frontmatter` in your Markdoc. This can now be accessed as `$frontmatter` in your Markdoc.
## Integration config options
The Astro Markdoc integration handles configuring Markdoc options and capabilities that are not available through the `markdoc.config.js` file.
### allowHTML
Enables writing HTML markup alongside Markdoc tags and nodes.
By default, Markdoc will not recognize HTML markup as semantic content.
To achieve a more Markdown-like experience, where HTML elements can be included alongside your content, set `allowHTML:true` as a `markdoc` integration option. This will enable HTML parsing in Markdoc markup.
> **Warning**
> When `allowHTML` is enabled, HTML markup inside Markdoc documents will be rendered as actual HTML elements (including `<script>`), making attack vectors like XSS possible.
>
> Ensure that any HTML markup comes from trusted sources.
```js {7} "allowHTML: true"
// astro.config.mjs
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
export default defineConfig({
// ...
integrations: [markdoc({ allowHTML: true })],
});
```
## Examples ## Examples
- The [Astro Markdoc starter template](https://github.com/withastro/astro/tree/latest/examples/with-markdoc) shows how to use Markdoc files in your Astro project. - The [Astro Markdoc starter template](https://github.com/withastro/astro/tree/latest/examples/with-markdoc) shows how to use Markdoc files in your Astro project.

View file

@ -69,6 +69,7 @@
"esbuild": "^0.17.19", "esbuild": "^0.17.19",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"htmlparser2": "^9.0.0",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"shiki": "^0.14.1", "shiki": "^0.14.1",
"zod": "^3.17.3" "zod": "^3.17.3"
@ -80,6 +81,7 @@
"@astrojs/markdown-remark": "^2.2.1", "@astrojs/markdown-remark": "^2.2.1",
"@types/chai": "^4.3.5", "@types/chai": "^4.3.5",
"@types/html-escaper": "^3.0.0", "@types/html-escaper": "^3.0.0",
"@types/markdown-it": "^12.2.3",
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"astro": "workspace:*", "astro": "workspace:*",
"astro-scripts": "workspace:*", "astro-scripts": "workspace:*",

View file

@ -13,13 +13,19 @@ import path from 'node:path';
import type * as rollup from 'rollup'; import type * as rollup from 'rollup';
import type { MarkdocConfigResult } from './load-config.js'; import type { MarkdocConfigResult } from './load-config.js';
import { setupConfig } from './runtime.js'; import { setupConfig } from './runtime.js';
import { getMarkdocTokenizer } from './tokenizer.js';
import type { MarkdocIntegrationOptions } from './options.js';
import { htmlTokenTransform } from './html/transform/html-token-transform.js';
export async function getContentEntryType({ export async function getContentEntryType({
markdocConfigResult, markdocConfigResult,
astroConfig, astroConfig,
options,
}: { }: {
astroConfig: AstroConfig; astroConfig: AstroConfig;
markdocConfigResult?: MarkdocConfigResult; markdocConfigResult?: MarkdocConfigResult;
options?: MarkdocIntegrationOptions,
}): Promise<ContentEntryType> { }): Promise<ContentEntryType> {
return { return {
extensions: ['.mdoc'], extensions: ['.mdoc'],
@ -27,7 +33,13 @@ export async function getContentEntryType({
handlePropagation: true, handlePropagation: true,
async getRenderModule({ contents, fileUrl, viteId }) { async getRenderModule({ contents, fileUrl, viteId }) {
const entry = getEntryInfo({ contents, fileUrl }); const entry = getEntryInfo({ contents, fileUrl });
const tokens = markdocTokenizer.tokenize(entry.body); const tokenizer = getMarkdocTokenizer(options);
let tokens = tokenizer.tokenize(entry.body);
if (options?.allowHTML) {
tokens = htmlTokenTransform(tokenizer, tokens);
}
const ast = Markdoc.parse(tokens); const ast = Markdoc.parse(tokens);
const usedTags = getUsedTags(ast); const usedTags = getUsedTags(ast);
const userMarkdocConfig = markdocConfigResult?.config ?? {}; const userMarkdocConfig = markdocConfigResult?.config ?? {};
@ -51,7 +63,7 @@ export async function getContentEntryType({
} }
const pluginContext = this; const pluginContext = this;
const markdocConfig = await setupConfig(userMarkdocConfig); const markdocConfig = await setupConfig(userMarkdocConfig, options);
const filePath = fileURLToPath(fileUrl); const filePath = fileURLToPath(fileUrl);
@ -113,15 +125,18 @@ ${getStringifiedImports(componentConfigByNodeMap, 'Node', astroConfig.root)}
const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')}; const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')};
const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')}; const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')};
const options = ${JSON.stringify(options)};
const stringifiedAst = ${JSON.stringify( const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast) /* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
)}; )};
export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig); export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options);
export const Content = createContentComponent( export const Content = createContentComponent(
Renderer, Renderer,
stringifiedAst, stringifiedAst,
markdocConfig, markdocConfig,
options,
tagComponentMap, tagComponentMap,
nodeComponentMap, nodeComponentMap,
)`; )`;
@ -134,12 +149,6 @@ export const Content = createContentComponent(
}; };
} }
const markdocTokenizer = new Markdoc.Tokenizer({
// Strip <!-- comments --> from rendered output
// Without this, they're rendered as strings!
allowComments: true,
});
function getUsedTags(markdocAst: Node) { function getUsedTags(markdocAst: Node) {
const tags = new Set<string>(); const tags = new Set<string>();
const validationErrors = Markdoc.validate(markdocAst); const validationErrors = Markdoc.validate(markdocAst);

View file

@ -0,0 +1,23 @@
import { styleToObject } from "./style-to-object.js";
export function parseInlineCSSToReactLikeObject(css: string | undefined | null): React.CSSProperties | undefined {
if (typeof css === "string") {
const cssObject: Record<string, string> = {};
styleToObject(css, (originalCssDirective: string, value: string) => {
const reactCssDirective = convertCssDirectiveNameToReactCamelCase(originalCssDirective);
cssObject[reactCssDirective] = value;
});
return cssObject;
}
return undefined;
}
function convertCssDirectiveNameToReactCamelCase(original: string): string {
// capture group 1 is the character to capitalize, the hyphen is omitted by virtue of being outside the capture group
const replaced = original.replace(/-([a-z0-9])/ig, (_match, char) => {
return char.toUpperCase();
});
return replaced;
}

View file

@ -0,0 +1,278 @@
// @ts-nocheck
// https://github.com/remarkablemark/inline-style-parser
/**
* @license MIT
*
* (The MIT License)
*
* Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
// http://www.w3.org/TR/CSS21/grammar.html
// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027
const COMMENT_REGEX = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g;
const NEWLINE_REGEX = /\n/g;
const WHITESPACE_REGEX = /^\s*/;
// declaration
const PROPERTY_REGEX = /^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/;
const COLON_REGEX = /^:\s*/;
const VALUE_REGEX = /^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/;
const SEMICOLON_REGEX = /^[;\s]*/;
// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Polyfill
const TRIM_REGEX = /^\s+|\s+$/g;
// strings
const NEWLINE = '\n';
const FORWARD_SLASH = '/';
const ASTERISK = '*';
const EMPTY_STRING = '';
// types
const TYPE_COMMENT = 'comment';
const TYPE_DECLARATION = 'declaration';
/**
* @param {String} style
* @param {Object} [options]
* @return {Object[]}
* @throws {TypeError}
* @throws {Error}
*/
export function parseInlineStyles(style, options) {
if (typeof style !== 'string') {
throw new TypeError('First argument must be a string');
}
if (!style) return [];
options = options || {};
/**
* Positional.
*/
let lineno = 1;
let column = 1;
/**
* Update lineno and column based on `str`.
*
* @param {String} str
*/
function updatePosition(str) {
let lines = str.match(NEWLINE_REGEX);
if (lines) lineno += lines.length;
let i = str.lastIndexOf(NEWLINE);
column = ~i ? str.length - i : column + str.length;
}
/**
* Mark position and patch `node.position`.
*
* @return {Function}
*/
function position() {
let start = { line: lineno, column: column };
return function (node) {
node.position = new Position(start);
whitespace();
return node;
};
}
/**
* Store position information for a node.
*
* @constructor
* @property {Object} start
* @property {Object} end
* @property {undefined|String} source
*/
function Position(start) {
this.start = start;
this.end = { line: lineno, column: column };
this.source = options.source;
}
/**
* Non-enumerable source string.
*/
Position.prototype.content = style;
const errorsList = [];
/**
* Error `msg`.
*
* @param {String} msg
* @throws {Error}
*/
function error(msg) {
const err = new Error(
options.source + ':' + lineno + ':' + column + ': ' + msg
);
err.reason = msg;
err.filename = options.source;
err.line = lineno;
err.column = column;
err.source = style;
if (options.silent) {
errorsList.push(err);
} else {
throw err;
}
}
/**
* Match `re` and return captures.
*
* @param {RegExp} re
* @return {undefined|Array}
*/
function match(re) {
const m = re.exec(style);
if (!m) return;
const str = m[0];
updatePosition(str);
style = style.slice(str.length);
return m;
}
/**
* Parse whitespace.
*/
function whitespace() {
match(WHITESPACE_REGEX);
}
/**
* Parse comments.
*
* @param {Object[]} [rules]
* @return {Object[]}
*/
function comments(rules) {
let c;
rules = rules || [];
while ((c = comment())) {
if (c !== false) {
rules.push(c);
}
}
return rules;
}
/**
* Parse comment.
*
* @return {Object}
* @throws {Error}
*/
function comment() {
const pos = position();
if (FORWARD_SLASH != style.charAt(0) || ASTERISK != style.charAt(1)) return;
let i = 2;
while (
EMPTY_STRING != style.charAt(i) &&
(ASTERISK != style.charAt(i) || FORWARD_SLASH != style.charAt(i + 1))
) {
++i;
}
i += 2;
if (EMPTY_STRING === style.charAt(i - 1)) {
return error('End of comment missing');
}
const str = style.slice(2, i - 2);
column += 2;
updatePosition(str);
style = style.slice(i);
column += 2;
return pos({
type: TYPE_COMMENT,
comment: str
});
}
/**
* Parse declaration.
*
* @return {Object}
* @throws {Error}
*/
function declaration() {
const pos = position();
// prop
const prop = match(PROPERTY_REGEX);
if (!prop) return;
comment();
// :
if (!match(COLON_REGEX)) return error("property missing ':'");
// val
const val = match(VALUE_REGEX);
const ret = pos({
type: TYPE_DECLARATION,
property: trim(prop[0].replace(COMMENT_REGEX, EMPTY_STRING)),
value: val
? trim(val[0].replace(COMMENT_REGEX, EMPTY_STRING))
: EMPTY_STRING
});
// ;
match(SEMICOLON_REGEX);
return ret;
}
/**
* Parse declarations.
*
* @return {Object[]}
*/
function declarations() {
const decls = [];
comments(decls);
// declarations
let decl;
while ((decl = declaration())) {
if (decl !== false) {
decls.push(decl);
comments(decls);
}
}
return decls;
}
whitespace();
return declarations();
};
/**
* Trim `str`.
*
* @param {String} str
* @return {String}
*/
function trim(str) {
return str ? str.replace(TRIM_REGEX, EMPTY_STRING) : EMPTY_STRING;
}

View file

@ -0,0 +1,70 @@
// @ts-nocheck
// https://github.com/remarkablemark/style-to-object
/**
* @license MIT
*
* The MIT License (MIT)
*
* Copyright (c) 2017 Menglin "Mark" Xu <mark@remarkablemark.org>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { parseInlineStyles } from "./parse-inline-styles.js";
/**
* Parses inline style to object.
*
* @example
* // returns { 'line-height': '42' }
* styleToObject('line-height: 42;');
*
* @param {String} style - The inline style.
* @param {Function} [iterator] - The iterator function.
* @return {null|Object}
*/
export function styleToObject(style, iterator) {
let output = null;
if (!style || typeof style !== 'string') {
return output;
}
let declaration;
let declarations = parseInlineStyles(style);
let hasIterator = typeof iterator === 'function';
let property;
let value;
for (let i = 0, len = declarations.length; i < len; i++) {
declaration = declarations[i];
property = declaration.property;
value = declaration.value;
if (hasIterator) {
iterator(property, value, declaration);
} else if (value) {
output || (output = {});
output[property] = value;
}
}
return output;
}

View file

@ -0,0 +1,2 @@
export { htmlTokenTransform } from "./transform/html-token-transform";
export { htmlTag } from "./tagdefs/html.tag";

View file

@ -0,0 +1,32 @@
import type { Config, Schema } from "@markdoc/markdoc";
import Markdoc from "@markdoc/markdoc";
// local
import { parseInlineCSSToReactLikeObject } from "../css/parse-inline-css-to-react.js";
// a Markdoc tag that will render a given HTML element and its attributes, as produced by the htmlTokenTransform function
export const htmlTag: Schema<Config, never> = {
attributes: {
name: { type: String, required: true },
attrs: { type: Object },
},
transform(node, config) {
const { name, attrs: unsafeAttributes } = node.attributes;
const children = node.transformChildren(config);
// pull out any "unsafe" attributes which need additional processing
const { style, ...safeAttributes } = unsafeAttributes as Record<string, unknown>;
// if the inline "style" attribute is present we need to parse the HTML into a react-like React.CSSProperties object
if (typeof style === "string") {
const styleObject = parseInlineCSSToReactLikeObject(style);
safeAttributes.style = styleObject;
}
// create a Markdoc Tag for the given HTML node with the HTML attributes and children
return new Markdoc.Tag(name, safeAttributes, children);
},
};

View file

@ -0,0 +1,256 @@
import type * as Token from 'markdown-it/lib/token';
import { Parser } from 'htmlparser2';
import { Tokenizer } from '@markdoc/markdoc';
export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token[] {
const output: Token[] = [];
// hold a lazy buffer of text and process it only when necessary
let textBuffer = '';
let inCDATA = false;
const appendText = (text: string) => {
textBuffer += text;
};
// process the current text buffer w/ Markdoc's Tokenizer for tokens
const processTextBuffer = () => {
if (textBuffer.length > 0) {
// tokenize the text buffer to look for structural markup tokens
const toks = tokenizer.tokenize(textBuffer);
// when we tokenize some raw text content, it's basically treated like Markdown, and will result in a paragraph wrapper, which we don't want
// in this scenario, we just want to generate a text token, but, we have to tokenize it in case there's other structural markup
if (toks.length === 3) {
const first = toks[0];
const second = toks[1];
const third: Token | undefined = toks.at(2);
if (first.type === 'paragraph_open' && second.type === 'inline' && (third && third.type === 'paragraph_close') && Array.isArray(second.children)) {
for (const tok of second.children as Token[]) {
// if the given token is a 'text' token and its trimmed content is the same as the pre-tokenized text buffer, use the original
// text buffer instead to preserve leading/trailing whitespace that is lost during tokenization of pure text content
if (tok.type === 'text') {
if (tok.content.trim() == textBuffer.trim()) {
tok.content = textBuffer;
}
}
output.push(tok);
}
} else {
// some other markup that happened to be 3 tokens, push tokens as-is
for (const tok of toks) {
output.push(tok);
}
}
} else {
// some other tokenized markup, push tokens as-is
for (const tok of toks) {
output.push(tok);
}
}
// reset the current lazy text buffer
textBuffer = '';
}
};
// create an incremental HTML parser that tracks HTML tag open, close and text content
const parser = new Parser({
oncdatastart() {
inCDATA = true;
},
oncdataend() {
inCDATA = false;
},
// when an HTML tag opens...
onopentag(name, attrs) {
// process any buffered text to be treated as text node before the currently opening HTML tag
processTextBuffer();
// push an 'html-tag' 'tag_open' Markdoc node instance for the currently opening HTML tag onto the resulting Token stack
output.push({
type: 'tag_open',
nesting: 1,
meta: {
tag: 'html-tag',
attributes: [
{ type: 'attribute', name: 'name', value: name },
{ type: 'attribute', name: 'attrs', value: attrs },
],
},
} as Token);
},
ontext(content: string | null | undefined) {
if (inCDATA) {
// ignore entirely while inside CDATA
return;
}
// only accumulate text into the buffer if we're not under an ignored HTML element
if (typeof content === 'string') {
appendText(content);
}
},
// when an HTML tag closes...
onclosetag(name) {
// process any buffered text to be treated as a text node inside the currently closing HTML tag
processTextBuffer();
// push an 'html-tag' 'tag_close' Markdoc node instance for the currently closing HTML tag onto the resulting Token stack
output.push({
type: 'tag_close',
nesting: -1,
meta: {
tag: 'html-tag',
attributes: [
{ type: 'attribute', name: 'name', value: name },
],
},
} as Token);
},
}, {
decodeEntities: false,
recognizeCDATA: true,
recognizeSelfClosing: true,
});
// for every detected token...
for (const token of tokens) {
// if it was an HTML token, write the HTML text into the HTML parser
if (token.type.startsWith('html')) {
// as the parser encounters opening/closing HTML tags, it will push Markdoc Tag nodes into the output stack
parser.write(token.content);
// continue loop... IMPORTANT! we're throwing away the original 'html' tokens here (raw HTML strings), since the parser is inserting new ones based on the parsed HTML
continue;
}
// process any child content for HTML
if (token.type === 'inline') {
if (token.children) {
token.children = htmlTokenTransform(tokenizer, token.children);
}
}
// not an HTML Token, preserve it at the current stack location
output.push(token);
}
// process any remaining buffered text
processTextBuffer();
//
// post-process the current levels output Token[] array to un-wind this pattern:
//
// [
// { type: tag_open, meta.tag: html-tag },
// { type: paragraph_open },
// { type: inline, children [...] },
// { type: paragraph_close },
// { type: tag_close, meta.tag: html-tag }
// ]
//
// the paragraph_open, inline, paragraph_close triplet needs to be replaced by the children of the inline node
//
// this is extra, unwanted paragraph wrapping unfortunately introduced by markdown-it during processing w/ HTML enabled
//
mutateAndCollapseExtraParagraphsUnderHtml(output);
return output;
}
function mutateAndCollapseExtraParagraphsUnderHtml(tokens: Token[]): void {
let done = false;
while (!done) {
const idx = findExtraParagraphUnderHtml(tokens);
if (typeof idx === 'number') {
// mutate
const actualChildTokens = tokens[idx + 2].children ?? [];
tokens.splice(idx, 5, ...actualChildTokens);
} else {
done = true;
}
}
}
/**
*
* @param token
* @returns
*/
function findExtraParagraphUnderHtml(tokens: Token[]): number | null {
if (tokens.length < 5) {
return null;
}
for (let i = 0; i < tokens.length; i++) {
const last = i + 4;
if (last > tokens.length - 1) {
break; // early exit, no more possible 5-long slices to search
}
const slice = tokens.slice(i, last + 1);
const isMatch = isExtraParagraphPatternMatch(slice);
if (isMatch) {
return i;
}
}
return null;
}
function isExtraParagraphPatternMatch(slice: Token[]): boolean {
const match = isHtmlTagOpen(slice[0])
&& isParagraphOpen(slice[1])
&& isInline(slice[2])
&& isParagraphClose(slice[3])
&& isHtmlTagClose(slice[4]);
return match;
}
function isHtmlTagOpen(token: Token): boolean {
return token.type === 'tag_open' && token.meta && token.meta.tag === 'html-tag';
}
function isHtmlTagClose(token: Token): boolean {
return token.type === 'tag_close' && token.meta && token.meta.tag === 'html-tag';
}
function isParagraphOpen(token: Token): boolean {
return token.type === 'paragraph_open';
}
function isParagraphClose(token: Token): boolean {
return token.type === 'paragraph_close';
}
function isInline(token: Token): boolean {
return token.type === 'inline';
}

View file

@ -1,12 +1,11 @@
/* eslint-disable no-console */
import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro'; import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
import { bold, red } from 'kleur/colors';
import { getContentEntryType } from './content-entry-type.js'; import { getContentEntryType } from './content-entry-type.js';
import { import {
SUPPORTED_MARKDOC_CONFIG_FILES, SUPPORTED_MARKDOC_CONFIG_FILES,
loadMarkdocConfig, loadMarkdocConfig,
type MarkdocConfigResult, type MarkdocConfigResult,
} from './load-config.js'; } from './load-config.js';
import type { MarkdocIntegrationOptions } from './options.js';
type SetupHookParams = HookParameters<'astro:config:setup'> & { type SetupHookParams = HookParameters<'astro:config:setup'> & {
// `contentEntryType` is not a public API // `contentEntryType` is not a public API
@ -14,15 +13,7 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
addContentEntryType: (contentEntryType: ContentEntryType) => void; addContentEntryType: (contentEntryType: ContentEntryType) => void;
}; };
export default function markdocIntegration(legacyConfig?: any): AstroIntegration { export default function markdocIntegration(options?: MarkdocIntegrationOptions): AstroIntegration {
if (legacyConfig) {
console.log(
`${red(
bold('[Markdoc]')
)} Passing Markdoc config from your \`astro.config\` is no longer supported. Configuration should be exported from a \`markdoc.config.mjs\` file. See the configuration docs for more: https://docs.astro.build/en/guides/integrations-guide/markdoc/#configuration`
);
process.exit(0);
}
let markdocConfigResult: MarkdocConfigResult | undefined; let markdocConfigResult: MarkdocConfigResult | undefined;
let astroConfig: AstroConfig; let astroConfig: AstroConfig;
return { return {
@ -34,7 +25,7 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
markdocConfigResult = await loadMarkdocConfig(astroConfig); markdocConfigResult = await loadMarkdocConfig(astroConfig);
addContentEntryType(await getContentEntryType({ markdocConfigResult, astroConfig })); addContentEntryType(await getContentEntryType({ markdocConfigResult, astroConfig, options }));
updateConfig({ updateConfig({
vite: { vite: {

View file

@ -0,0 +1,3 @@
export interface MarkdocIntegrationOptions {
allowHTML?: boolean;
}

View file

@ -10,13 +10,15 @@ import type { AstroInstance } from 'astro';
import { createComponent, renderComponent } from 'astro/runtime/server/index.js'; import { createComponent, renderComponent } from 'astro/runtime/server/index.js';
import type { AstroMarkdocConfig } from './config.js'; import type { AstroMarkdocConfig } from './config.js';
import { setupHeadingConfig } from './heading-ids.js'; import { setupHeadingConfig } from './heading-ids.js';
import type { MarkdocIntegrationOptions } from './options.js';
import { htmlTag } from './html/tagdefs/html.tag.js';
/** /**
* Merge user config with default config and set up context (ex. heading ID slugger) * Merge user config with default config and set up context (ex. heading ID slugger)
* Called on each file's individual transform. * Called on each file's individual transform.
* TODO: virtual module to merge configs per-build instead of per-file? * TODO: virtual module to merge configs per-build instead of per-file?
*/ */
export async function setupConfig(userConfig: AstroMarkdocConfig = {}): Promise<MergedConfig> { export async function setupConfig(userConfig: AstroMarkdocConfig = {}, options: MarkdocIntegrationOptions | undefined): Promise<MergedConfig> {
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig(); let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
if (userConfig.extends) { if (userConfig.extends) {
@ -29,14 +31,26 @@ export async function setupConfig(userConfig: AstroMarkdocConfig = {}): Promise<
} }
} }
return mergeConfig(defaultConfig, userConfig); let merged = mergeConfig(defaultConfig, userConfig);
if (options?.allowHTML) {
merged = mergeConfig(merged, HTML_CONFIG);
}
return merged;
} }
/** Used for synchronous `getHeadings()` function */ /** Used for synchronous `getHeadings()` function */
export function setupConfigSync(userConfig: AstroMarkdocConfig = {}): MergedConfig { export function setupConfigSync(userConfig: AstroMarkdocConfig = {}, options: MarkdocIntegrationOptions | undefined): MergedConfig {
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(); const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
return mergeConfig(defaultConfig, userConfig); let merged = mergeConfig(defaultConfig, userConfig);
if (options?.allowHTML) {
merged = mergeConfig(merged, HTML_CONFIG);
}
return merged;
} }
type MergedConfig = Required<Omit<AstroMarkdocConfig, 'extends'>>; type MergedConfig = Required<Omit<AstroMarkdocConfig, 'extends'>>;
@ -146,12 +160,12 @@ export function collectHeadings(
} }
} }
export function createGetHeadings(stringifiedAst: string, userConfig: AstroMarkdocConfig) { export function createGetHeadings(stringifiedAst: string, userConfig: AstroMarkdocConfig, options: MarkdocIntegrationOptions | undefined) {
return function getHeadings() { return function getHeadings() {
/* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables). /* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables).
TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself, TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself,
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */ instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
const config = setupConfigSync(userConfig); const config = setupConfigSync(userConfig, options);
const ast = Markdoc.Ast.fromJSON(stringifiedAst); const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast as Node, config as ConfigType); const content = Markdoc.transform(ast as Node, config as ConfigType);
let collectedHeadings: MarkdownHeading[] = []; let collectedHeadings: MarkdownHeading[] = [];
@ -164,6 +178,7 @@ export function createContentComponent(
Renderer: AstroInstance['default'], Renderer: AstroInstance['default'],
stringifiedAst: string, stringifiedAst: string,
userConfig: AstroMarkdocConfig, userConfig: AstroMarkdocConfig,
options: MarkdocIntegrationOptions | undefined,
tagComponentMap: Record<string, AstroInstance['default']>, tagComponentMap: Record<string, AstroInstance['default']>,
nodeComponentMap: Record<NodeType, AstroInstance['default']> nodeComponentMap: Record<NodeType, AstroInstance['default']>
) { ) {
@ -171,7 +186,7 @@ export function createContentComponent(
async factory(result: any, props: Record<string, any>) { async factory(result: any, props: Record<string, any>) {
const withVariables = mergeConfig(userConfig, { variables: props }); const withVariables = mergeConfig(userConfig, { variables: props });
const config = resolveComponentImports( const config = resolveComponentImports(
await setupConfig(withVariables), await setupConfig(withVariables, options),
tagComponentMap, tagComponentMap,
nodeComponentMap nodeComponentMap
); );
@ -181,3 +196,10 @@ export function createContentComponent(
propagation: 'self', propagation: 'self',
}); });
} }
// statically define a partial MarkdocConfig which registers the required "html-tag" Markdoc tag when the "allowHTML" feature is enabled
const HTML_CONFIG: AstroMarkdocConfig = {
tags: {
"html-tag": htmlTag,
},
};

View file

@ -0,0 +1,38 @@
import type { Tokenizer } from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc';
import type { MarkdocIntegrationOptions } from './options.js';
type TokenizerOptions = ConstructorParameters<typeof Tokenizer>[0];
export function getMarkdocTokenizer(options: MarkdocIntegrationOptions | undefined): Tokenizer {
const key = cacheKey(options);
if (!_cachedMarkdocTokenizers[key]) {
const tokenizerOptions: TokenizerOptions = {
// Strip <!-- comments --> from rendered output
// Without this, they're rendered as strings!
allowComments: true,
}
if (options?.allowHTML) {
// we want to allow indentation for Markdoc tags that are interleaved inside HTML block elements
tokenizerOptions.allowIndentation = true;
// enable HTML token detection in markdown-it
tokenizerOptions.html = true;
}
_cachedMarkdocTokenizers[key] = new Markdoc.Tokenizer(tokenizerOptions);
}
return _cachedMarkdocTokenizers[key];
};
// create this on-demand when needed since it relies on the runtime MarkdocIntegrationOptions and may change during
// the life of module in certain scenarios (unit tests, etc.)
let _cachedMarkdocTokenizers: Record<string, Tokenizer> = {};
function cacheKey(options: MarkdocIntegrationOptions | undefined): string {
return JSON.stringify(options);
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config
export default defineConfig({
integrations: [markdoc({ allowHTML: true })],
});

View file

@ -0,0 +1,19 @@
import { defineMarkdocConfig, component } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
tags: {
aside: {
render: component('./src/components/Aside.astro'),
attributes: {
type: { type: String },
title: { type: String },
},
},
mark: {
render: component('./src/components/Mark.astro'),
attributes: {
color: { type: String },
},
},
},
})

View file

@ -0,0 +1,9 @@
{
"name": "@test/markdoc-render-html",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,116 @@
---
// Inspired by the `Aside` component from docs.astro.build
// https://github.com/withastro/docs/blob/main/src/components/Aside.astro
export interface Props {
type?: 'note' | 'tip' | 'caution' | 'danger';
title?: string;
}
const labelByType = {
note: 'Note',
tip: 'Tip',
caution: 'Caution',
danger: 'Danger',
};
const { type = 'note' } = Astro.props as Props;
const title = Astro.props.title ?? labelByType[type] ?? '';
// SVG icon paths based on GitHub Octicons
const icons: Record<NonNullable<Props['type']>, { viewBox: string; d: string }> = {
note: {
viewBox: '0 0 18 18',
d: 'M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm1.75-.25a.25.25 0 00-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H1.75zM3.5 6.25a.75.75 0 01.75-.75h7a.75.75 0 010 1.5h-7a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5h4a.75.75 0 000-1.5h-4z',
},
tip: {
viewBox: '0 0 18 18',
d: 'M14 0a8.8 8.8 0 0 0-6 2.6l-.5.4-.9 1H3.3a1.8 1.8 0 0 0-1.5.8L.1 7.6a.8.8 0 0 0 .4 1.1l3.1 1 .2.1 2.4 2.4.1.2 1 3a.8.8 0 0 0 1 .5l2.9-1.7a1.8 1.8 0 0 0 .8-1.5V9.5l1-1 .4-.4A8.8 8.8 0 0 0 16 2v-.1A1.8 1.8 0 0 0 14.2 0h-.1zm-3.5 10.6-.3.2L8 12.3l.5 1.8 2-1.2a.3.3 0 0 0 .1-.2v-2zM3.7 8.1l1.5-2.3.2-.3h-2a.3.3 0 0 0-.3.1l-1.2 2 1.8.5zm5.2-4.5a7.3 7.3 0 0 1 5.2-2.1h.1a.3.3 0 0 1 .3.3v.1a7.3 7.3 0 0 1-2.1 5.2l-.5.4a15.2 15.2 0 0 1-2.5 2L7.1 11 5 9l1.5-2.3a15.3 15.3 0 0 1 2-2.5l.4-.5zM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-8.4 9.6a1.5 1.5 0 1 0-2.2-2.2 7 7 0 0 0-1.1 3 .2.2 0 0 0 .3.3c.6 0 2.2-.4 3-1.1z',
},
caution: {
viewBox: '-1 1 18 18',
d: 'M8.9 1.5C8.7 1.2 8.4 1 8 1s-.7.2-.9.5l-7 12a1 1 0 0 0 0 1c.2.3.6.5 1 .5H15c.4 0 .7-.2.9-.5a1 1 0 0 0 0-1l-7-12zM9 13H7v-2h2v2zm0-3H7V6h2v4z',
},
danger: {
viewBox: '0 1 14 17',
d: 'M5 .3c.9 2.2.5 3.4-.5 4.3C3.5 5.6 2 6.5 1 8c-1.5 2-1.7 6.5 3.5 7.7-2.2-1.2-2.6-4.5-.3-6.6-.6 2 .6 3.3 2 2.8 1.4-.4 2.3.6 2.2 1.7 0 .8-.3 1.4-1 1.8A5.6 5.6 0 0 0 12 10c0-2.9-2.5-3.3-1.3-5.7-1.5.2-2 1.2-1.8 2.8 0 1-1 1.8-2 1.3-.6-.4-.6-1.2 0-1.8C8.2 5.3 8.7 2.5 5 .3z',
},
};
const { viewBox, d } = icons[type];
---
<aside class={`content ${type}`} aria-label={title}>
<p class="title" aria-hidden="true">
<span class="icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox={viewBox} width={16} height={16}>
<path fill-rule="evenodd" d={d}></path>
</svg>
</span>
{title}
</p>
<section>
<slot />
</section>
</aside>
<style>
aside {
--color-base-purple: 269, 79%;
--color-base-teal: 180, 80%;
--color-base-red: 351, 100%;
--color-base-yellow: 41, 100%;
--aside-color-base: var(--color-base-purple);
--aside-color-lightness: 54%;
--aside-accent-color: hsl(var(--aside-color-base), var(--aside-color-lightness));
--aside-text-lightness: 20%;
--aside-text-accent-color: hsl(var(--aside-color-base), var(--aside-text-lightness));
border-inline-start: 4px solid var(--aside-accent-color);
padding: 1rem;
background-color: hsla(var(--aside-color-base), var(--aside-color-lightness), 0.1);
/* Indicates the aside boundaries for forced colors users, transparent is changed to a solid color */
outline: 1px solid transparent;
}
.title {
line-height: 1;
margin-bottom: 0.5rem;
font-size: 0.9rem;
letter-spacing: 0.05em;
font-weight: bold;
text-transform: uppercase;
color: var(--aside-text-accent-color);
}
.icon svg {
width: 1.5em;
height: 1.5em;
vertical-align: middle;
fill: currentcolor;
}
aside :global(a),
aside :global(a > code:not([class*='language'])) {
color: var(--aside-text-accent-color);
}
aside :global(pre) {
margin-left: 0;
margin-right: 0;
}
.tip {
--aside-color-lightness: 42%;
--aside-color-base: var(--color-base-teal);
}
.caution {
--aside-color-lightness: 59%;
--aside-color-base: var(--color-base-yellow);
}
.danger {
--aside-color-lightness: 54%;
--aside-color-base: var(--color-base-red);
}
</style>

View file

@ -0,0 +1,11 @@
---
export interface Props {
color?: 'hotpink' | 'blue';
}
const { color } = Astro.props;
---
<span style={`color: ${color}`} class="mark"><slot /></span>

View file

@ -0,0 +1,47 @@
---
title: Welcome to Markdoc 👋
---
This is a {% mark color="hotpink" %}inline mark{% /mark %} in regular Markdown markup.
<p id="p1">This is a {% mark color="hotpink" %}inline mark{% /mark %} under some HTML</p>
<div id="div1">
<p id="div1-p1">This is a {% mark color="hotpink" %}inline mark{% /mark %} under some HTML</p>
<p id="div1-p2">This is a <span id="div1-p2-span1">{% mark color="hotpink" %}inline mark{% /mark %}</span> under some HTML</p>
</div>
{% aside title="Aside One" type="tip" %}
I'm a Markdown paragraph inside an top-level aside tag
## I'm an H2 via Markdown markup
<h2 id="h-two">I'm an H2 via HTML markup</h2>
**Markdown bold** vs <strong>HTML bold</strong>
RENDERED
{% if $revealSecret %}
NOT RENDERED
{% /if %}
{% if $revealSecret %}NOT RENDERED{% /if %}
{% /aside %}
<section id="section1">
<div id="div1">
{% aside title="Nested un-indented Aside" type="tip" %}
regular Markdown markup
<p id="p4">nested {% mark color="hotpink" %}inline mark{% /mark %} content</p>
{% /aside %}
</div>
<div id="div2">
{% aside title="Nested indented Aside 💀" type="tip" %}
regular Markdown markup
<p id="p5">nested {% mark color="hotpink" %}inline<span id="inception-span"> mark</span>{% /mark %} content</p>
{% /aside %}
</div>
</section>

View file

@ -0,0 +1,18 @@
---
title: Simple post
---
<p id="p1">before <span class="inner-class" id="inner1" style="color: hotpink;">inner</span> after</p>
<p id="p2">
before
<span class="inner-class" id="inner1" style="color: hotpink;">inner</span>
after
</p>
<div id="div-l1">
<div id="div-l2-1">
<p id="p3">before <span class="inner-class" id="inner1" style="color: hotpink;">inner</span> after</p>
</div>
<div id="div-l2-2">
<p id="p4">before <span class="inner-class" id="inner1" style="color: hotpink;">inner</span> after</p>
<p id="p5">before <span class="inner-class" id="inner1" style="color: hotpink;">inner</span> after</p>
</div>
</div>

View file

@ -0,0 +1,20 @@
---
title: Simple post
---
<table>
<thead>
<tr>
<th>one</th>
<th>two</th>
<th>three</th>
</tr>
</thead>
<tbody>
<tr><td id="td1" rowspan="2" colspan="3">three wide and two tall</td></tr>
<tr><td id="td2" ROWSPAN="2" COLSPAN="3">three wide and two tall</td></tr>
<tr><td id="td3" rowSpan="2" colSpan="3">three wide and two tall</td></tr>
<tr><td id="td4" RowSpan="2" ColSpan="3">three wide and two tall</td></tr>
</tr>
</tbody>
</table>

View file

@ -0,0 +1,11 @@
---
title: Simple post
---
## Simple <span class="inside-h2" style="color: fuscia">post</span> header
This is a simple Markdoc <span class="post-class" style="color: hotpink;">post</span>.
<p>This is a paragraph!</p>
<p>This is a <span class="inside-p">span</span> inside a paragraph!</p>

View file

@ -0,0 +1,29 @@
---
import { getEntryBySlug, getCollection } from "astro:content";
const { slug } = Astro.params;
const post = await getEntryBySlug('blog', slug);
const { Content } = await post.render();
export async function getStaticPaths() {
const blogEntries = await getCollection('blog');
return blogEntries.map(entry => ({
params: { slug: entry.slug }, props: { entry },
}));
}
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content</title>
</head>
<body>
<Content />
</body>
</html>

View file

@ -0,0 +1,293 @@
import { parseHTML } from 'linkedom';
import { expect } from 'chai';
import { loadFixture } from '../../../astro/test/test-utils.js';
async function getFixture(name) {
return await loadFixture({
root: new URL(`./fixtures/${name}/`, import.meta.url),
});
}
describe('Markdoc - render html', () => {
let fixture;
before(async () => {
fixture = await getFixture('render-html');
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('renders content - simple', async () => {
const res = await fixture.fetch('/simple');
const html = await res.text();
renderSimpleChecks(html);
});
it('renders content - nested-html', async () => {
const res = await fixture.fetch('/nested-html');
const html = await res.text();
renderNestedHTMLChecks(html);
});
it('renders content - components interleaved with html', async () => {
const res = await fixture.fetch('/components');
const html = await res.text();
renderComponentsHTMLChecks(html);
});
it('renders content - randomly cased html attributes', async () => {
const res = await fixture.fetch('/randomly-cased-html-attributes');
const html = await res.text();
renderRandomlyCasedHTMLAttributesChecks(html);
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('renders content - simple', async () => {
const html = await fixture.readFile('/simple/index.html');
renderSimpleChecks(html);
});
it('renders content - nested-html', async () => {
const html = await fixture.readFile('/nested-html/index.html');
renderNestedHTMLChecks(html);
});
it('renders content - components interleaved with html', async () => {
const html = await fixture.readFile('/components/index.html');
renderComponentsHTMLChecks(html);
});
it('renders content - randomly cased html attributes', async () => {
const html = await fixture.readFile('/randomly-cased-html-attributes/index.html');
renderRandomlyCasedHTMLAttributesChecks(html);
});
});
});
/** @param {string} html */
function renderSimpleChecks(html) {
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Simple post header');
const spanInsideH2 = document.querySelector('h2 > span');
expect(spanInsideH2.textContent).to.equal('post');
expect(spanInsideH2.className).to.equal('inside-h2');
expect(spanInsideH2.style.color).to.equal('fuscia');
const p1 = document.querySelector('article > p:nth-of-type(1)');
expect(p1.children.length).to.equal(1);
expect(p1.textContent).to.equal('This is a simple Markdoc post.');
const p2 = document.querySelector('article > p:nth-of-type(2)');
expect(p2.children.length).to.equal(0);
expect(p2.textContent).to.equal('This is a paragraph!');
const p3 = document.querySelector('article > p:nth-of-type(3)');
expect(p3.children.length).to.equal(1);
expect(p3.textContent).to.equal('This is a span inside a paragraph!');
}
/** @param {string} html */
function renderNestedHTMLChecks(html) {
const { document } = parseHTML(html);
const p1 = document.querySelector('p:nth-of-type(1)');
expect(p1.id).to.equal('p1');
expect(p1.textContent).to.equal('before inner after');
expect(p1.children.length).to.equal(1);
const p1Span1 = p1.querySelector('span');
expect(p1Span1.textContent).to.equal('inner');
expect(p1Span1.id).to.equal('inner1');
expect(p1Span1.className).to.equal('inner-class');
expect(p1Span1.style.color).to.equal('hotpink');
const p2 = document.querySelector('p:nth-of-type(2)');
expect(p2.id).to.equal('p2');
expect(p2.textContent).to.equal('\n before\n inner\n after\n');
expect(p2.children.length).to.equal(1);
const divL1 = document.querySelector('div:nth-of-type(1)');
expect(divL1.id).to.equal('div-l1');
expect(divL1.children.length).to.equal(2);
const divL2_1 = divL1.querySelector('div:nth-of-type(1)');
expect(divL2_1.id).to.equal('div-l2-1');
expect(divL2_1.children.length).to.equal(1);
const p3 = divL2_1.querySelector('p:nth-of-type(1)');
expect(p3.id).to.equal('p3');
expect(p3.textContent).to.equal('before inner after');
expect(p3.children.length).to.equal(1);
const divL2_2 = divL1.querySelector('div:nth-of-type(2)');
expect(divL2_2.id).to.equal('div-l2-2');
expect(divL2_2.children.length).to.equal(2);
const p4 = divL2_2.querySelector('p:nth-of-type(1)');
expect(p4.id).to.equal('p4');
expect(p4.textContent).to.equal('before inner after');
expect(p4.children.length).to.equal(1);
const p5 = divL2_2.querySelector('p:nth-of-type(2)');
expect(p5.id).to.equal('p5');
expect(p5.textContent).to.equal('before inner after');
expect(p5.children.length).to.equal(1);
}
/**
*
* @param {string} html */
function renderRandomlyCasedHTMLAttributesChecks(html) {
const { document } = parseHTML(html);
const td1 = document.querySelector('#td1');
const td2 = document.querySelector('#td1');
const td3 = document.querySelector('#td1');
const td4 = document.querySelector('#td1');
// all four <td>'s which had randomly cased variants of colspan/rowspan should all be rendered lowercased at this point
expect(td1.getAttribute("colspan")).to.equal("3");
expect(td1.getAttribute("rowspan")).to.equal("2");
expect(td2.getAttribute("colspan")).to.equal("3");
expect(td2.getAttribute("rowspan")).to.equal("2");
expect(td3.getAttribute("colspan")).to.equal("3");
expect(td3.getAttribute("rowspan")).to.equal("2");
expect(td4.getAttribute("colspan")).to.equal("3");
expect(td4.getAttribute("rowspan")).to.equal("2");
}
/**
* Asserts that the rendered HTML tags with interleaved Markdoc tags (both block and inline) rendered in the expected nested graph of elemements
*
* @param {string} html */
function renderComponentsHTMLChecks(html) {
const { document } = parseHTML(html);
const naturalP1 = document.querySelector('article > p:nth-of-type(1)');
expect(naturalP1.textContent).to.equal('This is a inline mark in regular Markdown markup.');
expect(naturalP1.children.length).to.equal(1);
const p1 = document.querySelector('article > p:nth-of-type(2)');
expect(p1.id).to.equal('p1');
expect(p1.textContent).to.equal('This is a inline mark under some HTML');
expect(p1.children.length).to.equal(1);
assertInlineMark(p1.children[0]);
const div1p1 = document.querySelector('article > #div1 > p:nth-of-type(1)');
expect(div1p1.id).to.equal('div1-p1');
expect(div1p1.textContent).to.equal('This is a inline mark under some HTML');
expect(div1p1.children.length).to.equal(1);
assertInlineMark(div1p1.children[0]);
const div1p2 = document.querySelector('article > #div1 > p:nth-of-type(2)');
expect(div1p2.id).to.equal('div1-p2');
expect(div1p2.textContent).to.equal('This is a inline mark under some HTML');
expect(div1p2.children.length).to.equal(1);
const div1p2span1 = div1p2.querySelector('span');
expect(div1p2span1.id).to.equal('div1-p2-span1');
expect(div1p2span1.textContent).to.equal('inline mark');
expect(div1p2span1.children.length).to.equal(1);
assertInlineMark(div1p2span1.children[0]);
const aside1 = document.querySelector('article > aside:nth-of-type(1)');
const aside1Title = aside1.querySelector('p.title');
expect(aside1Title.textContent.trim()).to.equal('Aside One');
const aside1Section = aside1.querySelector('section');
const aside1SectionP1 = aside1Section.querySelector('p:nth-of-type(1)');
expect(aside1SectionP1.textContent).to.equal('I\'m a Markdown paragraph inside an top-level aside tag');
const aside1H2_1 = aside1Section.querySelector('h2:nth-of-type(1)');
expect(aside1H2_1.id).to.equal('im-an-h2-via-markdown-markup'); // automatic slug
expect(aside1H2_1.textContent).to.equal('I\'m an H2 via Markdown markup');
const aside1H2_2 = aside1Section.querySelector('h2:nth-of-type(2)');
expect(aside1H2_2.id).to.equal('h-two');
expect(aside1H2_2.textContent).to.equal('I\'m an H2 via HTML markup');
const aside1SectionP2 = aside1Section.querySelector('p:nth-of-type(2)');
expect(aside1SectionP2.textContent).to.equal('Markdown bold vs HTML bold');
expect(aside1SectionP2.children.length).to.equal(2);
const aside1SectionP2Strong1 = aside1SectionP2.querySelector('strong:nth-of-type(1)');
expect(aside1SectionP2Strong1.textContent).to.equal('Markdown bold');
const aside1SectionP2Strong2 = aside1SectionP2.querySelector('strong:nth-of-type(2)');
expect(aside1SectionP2Strong2.textContent).to.equal('HTML bold');
const article = document.querySelector('article');
expect(article.textContent).to.contain('RENDERED');
expect(article.textContent).to.not.contain('NOT RENDERED');
const section1 = document.querySelector('article > #section1');
const section1div1 = section1.querySelector('#div1');
const section1Aside1 = section1div1.querySelector('aside:nth-of-type(1)');
const section1Aside1Title = section1Aside1.querySelector('p.title');
expect(section1Aside1Title.textContent.trim()).to.equal('Nested un-indented Aside');
const section1Aside1Section = section1Aside1.querySelector('section');
const section1Aside1SectionP1 = section1Aside1Section.querySelector('p:nth-of-type(1)');
expect(section1Aside1SectionP1.textContent).to.equal('regular Markdown markup');
const section1Aside1SectionP4 = section1Aside1Section.querySelector('p:nth-of-type(2)');
expect(section1Aside1SectionP4.textContent).to.equal('nested inline mark content');
expect(section1Aside1SectionP4.children.length).to.equal(1);
assertInlineMark(section1Aside1SectionP4.children[0]);
const section1div2 = section1.querySelector('#div2');
const section1Aside2 = section1div2.querySelector('aside:nth-of-type(1)');
const section1Aside2Title = section1Aside2.querySelector('p.title');
expect(section1Aside2Title.textContent.trim()).to.equal('Nested indented Aside 💀');
const section1Aside2Section = section1Aside2.querySelector('section');
const section1Aside2SectionP1 = section1Aside2Section.querySelector('p:nth-of-type(1)');
expect(section1Aside2SectionP1.textContent).to.equal('regular Markdown markup');
const section1Aside1SectionP5 = section1Aside2Section.querySelector('p:nth-of-type(2)');
expect(section1Aside1SectionP5.id).to.equal('p5');
expect(section1Aside1SectionP5.children.length).to.equal(1);
const section1Aside1SectionP5Span1 = section1Aside1SectionP5.children[0];
expect(section1Aside1SectionP5Span1.textContent).to.equal('inline mark');
expect(section1Aside1SectionP5Span1.children.length).to.equal(1);
const section1Aside1SectionP5Span1Span1 = section1Aside1SectionP5Span1.children[0];
expect(section1Aside1SectionP5Span1Span1.textContent).to.equal(' mark');
};
/** @param {HTMLElement | null | undefined} el */
function assertInlineMark(el) {
expect(el).to.not.be.null;
expect(el).to.not.be.undefined;
expect(el.children.length).to.equal(0);
expect(el.textContent).to.equal('inline mark');
expect(el.className).to.equal('mark');
expect(el.style.color).to.equal('hotpink');
}

View file

@ -4029,6 +4029,9 @@ importers:
gray-matter: gray-matter:
specifier: ^4.0.3 specifier: ^4.0.3
version: 4.0.3 version: 4.0.3
htmlparser2:
specifier: ^9.0.0
version: 9.0.0
kleur: kleur:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.1.5 version: 4.1.5
@ -4048,6 +4051,9 @@ importers:
'@types/html-escaper': '@types/html-escaper':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
'@types/markdown-it':
specifier: ^12.2.3
version: 12.2.3
'@types/mocha': '@types/mocha':
specifier: ^9.1.1 specifier: ^9.1.1
version: 9.1.1 version: 9.1.1
@ -4121,6 +4127,15 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../../../../astro version: link:../../../../../astro
packages/integrations/markdoc/test/fixtures/render-html:
dependencies:
'@astrojs/markdoc':
specifier: workspace:*
version: link:../../..
astro:
specifier: workspace:*
version: link:../../../../../astro
packages/integrations/markdoc/test/fixtures/render-null: packages/integrations/markdoc/test/fixtures/render-null:
dependencies: dependencies:
'@astrojs/markdoc': '@astrojs/markdoc':
@ -8936,8 +8951,6 @@ packages:
/@types/linkify-it@3.0.2: /@types/linkify-it@3.0.2:
resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==} resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==}
dev: false
optional: true
/@types/markdown-it@12.2.3: /@types/markdown-it@12.2.3:
resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==}
@ -8945,8 +8958,6 @@ packages:
dependencies: dependencies:
'@types/linkify-it': 3.0.2 '@types/linkify-it': 3.0.2
'@types/mdurl': 1.0.2 '@types/mdurl': 1.0.2
dev: false
optional: true
/@types/mathjax@0.0.37: /@types/mathjax@0.0.37:
resolution: {integrity: sha512-y0WSZBtBNQwcYipTU/BhgeFu1EZNlFvUNCmkMXV9kBQZq7/o5z82dNVyH3yy2Xv5zzeNeQoHSL4Xm06+EQiH+g==} resolution: {integrity: sha512-y0WSZBtBNQwcYipTU/BhgeFu1EZNlFvUNCmkMXV9kBQZq7/o5z82dNVyH3yy2Xv5zzeNeQoHSL4Xm06+EQiH+g==}
@ -8959,8 +8970,6 @@ packages:
/@types/mdurl@1.0.2: /@types/mdurl@1.0.2:
resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==} resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==}
dev: false
optional: true
/@types/mdx@2.0.5: /@types/mdx@2.0.5:
resolution: {integrity: sha512-76CqzuD6Q7LC+AtbPqrvD9AqsN0k8bsYo2bM2J8pmNldP1aIPAbzUQ7QbobyXL4eLr1wK5x8FZFe8eF/ubRuBg==} resolution: {integrity: sha512-76CqzuD6Q7LC+AtbPqrvD9AqsN0k8bsYo2bM2J8pmNldP1aIPAbzUQ7QbobyXL4eLr1wK5x8FZFe8eF/ubRuBg==}
@ -10966,11 +10975,9 @@ packages:
domelementtype: 2.3.0 domelementtype: 2.3.0
domhandler: 5.0.3 domhandler: 5.0.3
entities: 4.5.0 entities: 4.5.0
dev: true
/domelementtype@2.3.0: /domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: true
/domexception@4.0.0: /domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
@ -10984,7 +10991,6 @@ packages:
engines: {node: '>= 4'} engines: {node: '>= 4'}
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
dev: true
/domutils@3.1.0: /domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@ -10992,7 +10998,6 @@ packages:
dom-serializer: 2.0.0 dom-serializer: 2.0.0
domelementtype: 2.3.0 domelementtype: 2.3.0
domhandler: 5.0.3 domhandler: 5.0.3
dev: true
/dotenv@10.0.0: /dotenv@10.0.0:
resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==}
@ -12733,6 +12738,15 @@ packages:
entities: 4.5.0 entities: 4.5.0
dev: true dev: true
/htmlparser2@9.0.0:
resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
dev: false
/http-cache-semantics@4.1.1: /http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
@ -18738,25 +18752,21 @@ packages:
file:packages/astro/test/fixtures/css-assets/packages/font-awesome: file:packages/astro/test/fixtures/css-assets/packages/font-awesome:
resolution: {directory: packages/astro/test/fixtures/css-assets/packages/font-awesome, type: directory} resolution: {directory: packages/astro/test/fixtures/css-assets/packages/font-awesome, type: directory}
name: '@test/astro-font-awesome-package' name: '@test/astro-font-awesome-package'
version: 0.0.1
dev: false dev: false
file:packages/astro/test/fixtures/multiple-renderers/renderers/one: file:packages/astro/test/fixtures/multiple-renderers/renderers/one:
resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/one, type: directory} resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/one, type: directory}
name: '@test/astro-renderer-one' name: '@test/astro-renderer-one'
version: 1.0.0
dev: false dev: false
file:packages/astro/test/fixtures/multiple-renderers/renderers/two: file:packages/astro/test/fixtures/multiple-renderers/renderers/two:
resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/two, type: directory} resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/two, type: directory}
name: '@test/astro-renderer-two' name: '@test/astro-renderer-two'
version: 1.0.0
dev: false dev: false
file:packages/astro/test/fixtures/solid-component/deps/solid-jsx-component: file:packages/astro/test/fixtures/solid-component/deps/solid-jsx-component:
resolution: {directory: packages/astro/test/fixtures/solid-component/deps/solid-jsx-component, type: directory} resolution: {directory: packages/astro/test/fixtures/solid-component/deps/solid-jsx-component, type: directory}
name: '@test/solid-jsx-component' name: '@test/solid-jsx-component'
version: 0.0.0
dependencies: dependencies:
solid-js: 1.7.6 solid-js: 1.7.6
dev: false dev: false