[ci] format
This commit is contained in:
parent
7461e82c81
commit
25e04a2ecb
13 changed files with 711 additions and 724 deletions
|
@ -413,13 +413,11 @@ 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';
|
||||
|
|
|
@ -11,11 +11,11 @@ import { MarkdocError, isComponentConfig, isValidUrl, prependForwardSlash } from
|
|||
import { emitESMImage } from 'astro/assets/utils';
|
||||
import path from 'node:path';
|
||||
import type * as rollup from 'rollup';
|
||||
import { htmlTokenTransform } from './html/transform/html-token-transform.js';
|
||||
import type { MarkdocConfigResult } from './load-config.js';
|
||||
import type { MarkdocIntegrationOptions } from './options.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({
|
||||
markdocConfigResult,
|
||||
|
@ -24,8 +24,7 @@ export async function getContentEntryType({
|
|||
}: {
|
||||
astroConfig: AstroConfig;
|
||||
markdocConfigResult?: MarkdocConfigResult;
|
||||
options?: MarkdocIntegrationOptions,
|
||||
|
||||
options?: MarkdocIntegrationOptions;
|
||||
}): Promise<ContentEntryType> {
|
||||
return {
|
||||
extensions: ['.mdoc'],
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { styleToObject } from './style-to-object.js';
|
||||
|
||||
import { styleToObject } from "./style-to-object.js";
|
||||
|
||||
export function parseInlineCSSToReactLikeObject(css: string | undefined | null): React.CSSProperties | undefined {
|
||||
if (typeof css === "string") {
|
||||
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);
|
||||
|
@ -16,7 +17,7 @@ export function parseInlineCSSToReactLikeObject(css: string | undefined | null):
|
|||
|
||||
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) => {
|
||||
const replaced = original.replace(/-([a-z0-9])/gi, (_match, char) => {
|
||||
return char.toUpperCase();
|
||||
});
|
||||
return replaced;
|
||||
|
|
|
@ -117,9 +117,7 @@ export function parseInlineStyles(style, options) {
|
|||
* @throws {Error}
|
||||
*/
|
||||
function error(msg) {
|
||||
const err = new Error(
|
||||
options.source + ':' + lineno + ':' + column + ': ' + msg
|
||||
);
|
||||
const err = new Error(options.source + ':' + lineno + ':' + column + ': ' + msg);
|
||||
err.reason = msg;
|
||||
err.filename = options.source;
|
||||
err.line = lineno;
|
||||
|
@ -203,7 +201,7 @@ export function parseInlineStyles(style, options) {
|
|||
|
||||
return pos({
|
||||
type: TYPE_COMMENT,
|
||||
comment: str
|
||||
comment: str,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -230,9 +228,7 @@ export function parseInlineStyles(style, options) {
|
|||
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
|
||||
value: val ? trim(val[0].replace(COMMENT_REGEX, EMPTY_STRING)) : EMPTY_STRING,
|
||||
});
|
||||
|
||||
// ;
|
||||
|
@ -265,7 +261,7 @@ export function parseInlineStyles(style, options) {
|
|||
|
||||
whitespace();
|
||||
return declarations();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim `str`.
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { parseInlineStyles } from "./parse-inline-styles.js";
|
||||
import { parseInlineStyles } from './parse-inline-styles.js';
|
||||
|
||||
/**
|
||||
* Parses inline style to object.
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export { htmlTokenTransform } from "./transform/html-token-transform";
|
||||
export { htmlTag } from "./tagdefs/html.tag";
|
||||
export { htmlTag } from './tagdefs/html.tag';
|
||||
export { htmlTokenTransform } from './transform/html-token-transform';
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
import type { Config, Schema } from "@markdoc/markdoc";
|
||||
import Markdoc from "@markdoc/markdoc";
|
||||
import type { Config, Schema } from '@markdoc/markdoc';
|
||||
import Markdoc from '@markdoc/markdoc';
|
||||
|
||||
// local
|
||||
import { parseInlineCSSToReactLikeObject } from "../css/parse-inline-css-to-react.js";
|
||||
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);
|
||||
|
||||
|
@ -21,7 +19,7 @@ export const htmlTag: Schema<Config, never> = {
|
|||
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") {
|
||||
if (typeof style === 'string') {
|
||||
const styleObject = parseInlineCSSToReactLikeObject(style);
|
||||
safeAttributes.style = styleObject;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import type * as Token from 'markdown-it/lib/token';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { Tokenizer } from '@markdoc/markdoc';
|
||||
|
||||
import { Parser } from 'htmlparser2';
|
||||
import type * as Token from 'markdown-it/lib/token';
|
||||
|
||||
export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token[] {
|
||||
|
||||
const output: Token[] = [];
|
||||
|
||||
// hold a lazy buffer of text and process it only when necessary
|
||||
|
@ -18,21 +16,24 @@ export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token
|
|||
|
||||
// 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)) {
|
||||
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
|
||||
|
@ -62,8 +63,8 @@ export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token
|
|||
};
|
||||
|
||||
// create an incremental HTML parser that tracks HTML tag open, close and text content
|
||||
const parser = new Parser({
|
||||
|
||||
const parser = new Parser(
|
||||
{
|
||||
oncdatastart() {
|
||||
inCDATA = true;
|
||||
},
|
||||
|
@ -74,7 +75,6 @@ export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token
|
|||
|
||||
// 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();
|
||||
|
||||
|
@ -90,11 +90,9 @@ export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token
|
|||
],
|
||||
},
|
||||
} as Token);
|
||||
|
||||
},
|
||||
|
||||
ontext(content: string | null | undefined) {
|
||||
|
||||
if (inCDATA) {
|
||||
// ignore entirely while inside CDATA
|
||||
return;
|
||||
|
@ -108,7 +106,6 @@ export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token
|
|||
|
||||
// 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();
|
||||
|
||||
|
@ -118,26 +115,22 @@ export function htmlTokenTransform(tokenizer: Tokenizer, tokens: Token[]): Token
|
|||
nesting: -1,
|
||||
meta: {
|
||||
tag: 'html-tag',
|
||||
attributes: [
|
||||
{ type: 'attribute', name: 'name', value: name },
|
||||
],
|
||||
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);
|
||||
|
||||
|
@ -197,14 +190,12 @@ function mutateAndCollapseExtraParagraphsUnderHtml(tokens: Token[]): void {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param token
|
||||
* @returns
|
||||
*/
|
||||
function findExtraParagraphUnderHtml(tokens: Token[]): number | null {
|
||||
|
||||
if (tokens.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
@ -226,15 +217,15 @@ function findExtraParagraphUnderHtml(tokens: Token[]): number | null {
|
|||
}
|
||||
|
||||
function isExtraParagraphPatternMatch(slice: Token[]): boolean {
|
||||
const match = isHtmlTagOpen(slice[0])
|
||||
&& isParagraphOpen(slice[1])
|
||||
&& isInline(slice[2])
|
||||
&& isParagraphClose(slice[3])
|
||||
&& isHtmlTagClose(slice[4]);
|
||||
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';
|
||||
}
|
||||
|
|
|
@ -25,7 +25,9 @@ export default function markdocIntegration(options?: MarkdocIntegrationOptions):
|
|||
|
||||
markdocConfigResult = await loadMarkdocConfig(astroConfig);
|
||||
|
||||
addContentEntryType(await getContentEntryType({ markdocConfigResult, astroConfig, options }));
|
||||
addContentEntryType(
|
||||
await getContentEntryType({ markdocConfigResult, astroConfig, options })
|
||||
);
|
||||
|
||||
updateConfig({
|
||||
vite: {
|
||||
|
|
|
@ -10,15 +10,18 @@ import type { AstroInstance } from 'astro';
|
|||
import { createComponent, renderComponent } from 'astro/runtime/server/index.js';
|
||||
import type { AstroMarkdocConfig } from './config.js';
|
||||
import { setupHeadingConfig } from './heading-ids.js';
|
||||
import type { MarkdocIntegrationOptions } from './options.js';
|
||||
import { htmlTag } from './html/tagdefs/html.tag.js';
|
||||
import type { MarkdocIntegrationOptions } from './options.js';
|
||||
|
||||
/**
|
||||
* Merge user config with default config and set up context (ex. heading ID slugger)
|
||||
* Called on each file's individual transform.
|
||||
* TODO: virtual module to merge configs per-build instead of per-file?
|
||||
*/
|
||||
export async function setupConfig(userConfig: AstroMarkdocConfig = {}, options: MarkdocIntegrationOptions | undefined): Promise<MergedConfig> {
|
||||
export async function setupConfig(
|
||||
userConfig: AstroMarkdocConfig = {},
|
||||
options: MarkdocIntegrationOptions | undefined
|
||||
): Promise<MergedConfig> {
|
||||
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
|
||||
|
||||
if (userConfig.extends) {
|
||||
|
@ -41,7 +44,10 @@ export async function setupConfig(userConfig: AstroMarkdocConfig = {}, options:
|
|||
}
|
||||
|
||||
/** Used for synchronous `getHeadings()` function */
|
||||
export function setupConfigSync(userConfig: AstroMarkdocConfig = {}, options: MarkdocIntegrationOptions | undefined): MergedConfig {
|
||||
export function setupConfigSync(
|
||||
userConfig: AstroMarkdocConfig = {},
|
||||
options: MarkdocIntegrationOptions | undefined
|
||||
): MergedConfig {
|
||||
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
|
||||
|
||||
let merged = mergeConfig(defaultConfig, userConfig);
|
||||
|
@ -160,7 +166,11 @@ export function collectHeadings(
|
|||
}
|
||||
}
|
||||
|
||||
export function createGetHeadings(stringifiedAst: string, userConfig: AstroMarkdocConfig, options: MarkdocIntegrationOptions | undefined) {
|
||||
export function createGetHeadings(
|
||||
stringifiedAst: string,
|
||||
userConfig: AstroMarkdocConfig,
|
||||
options: MarkdocIntegrationOptions | undefined
|
||||
) {
|
||||
return function getHeadings() {
|
||||
/* 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,
|
||||
|
@ -200,6 +210,6 @@ export function createContentComponent(
|
|||
// 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,
|
||||
'html-tag': htmlTag,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -5,16 +5,14 @@ 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
|
||||
|
@ -27,7 +25,7 @@ export function getMarkdocTokenizer(options: MarkdocIntegrationOptions | undefin
|
|||
}
|
||||
|
||||
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.)
|
||||
|
|
|
@ -9,7 +9,6 @@ async function getFixture(name) {
|
|||
}
|
||||
|
||||
describe('Markdoc - render html', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
before(async () => {
|
||||
|
@ -17,7 +16,6 @@ describe('Markdoc - render html', () => {
|
|||
});
|
||||
|
||||
describe('dev', () => {
|
||||
|
||||
let devServer;
|
||||
|
||||
before(async () => {
|
||||
|
@ -55,16 +53,13 @@ describe('Markdoc - render html', () => {
|
|||
|
||||
renderRandomlyCasedHTMLAttributesChecks(html);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('build', () => {
|
||||
|
||||
before(async () => {
|
||||
await fixture.build();
|
||||
});
|
||||
|
||||
|
||||
it('renders content - simple', async () => {
|
||||
const html = await fixture.readFile('/simple/index.html');
|
||||
|
||||
|
@ -88,7 +83,6 @@ describe('Markdoc - render html', () => {
|
|||
|
||||
renderRandomlyCasedHTMLAttributesChecks(html);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -115,7 +109,6 @@ function renderSimpleChecks(html) {
|
|||
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 */
|
||||
|
@ -164,7 +157,6 @@ function renderNestedHTMLChecks(html) {
|
|||
expect(p5.id).to.equal('p5');
|
||||
expect(p5.textContent).to.equal('before inner after');
|
||||
expect(p5.children.length).to.equal(1);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -180,17 +172,17 @@ function renderRandomlyCasedHTMLAttributesChecks(html) {
|
|||
|
||||
// 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(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(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(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");
|
||||
expect(td4.getAttribute('colspan')).to.equal('3');
|
||||
expect(td4.getAttribute('rowspan')).to.equal('2');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -232,13 +224,15 @@ function renderComponentsHTMLChecks(html) {
|
|||
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');
|
||||
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');
|
||||
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');
|
||||
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);
|
||||
|
@ -279,7 +273,7 @@ function renderComponentsHTMLChecks(html) {
|
|||
expect(section1Aside1SectionP5Span1.children.length).to.equal(1);
|
||||
const section1Aside1SectionP5Span1Span1 = section1Aside1SectionP5Span1.children[0];
|
||||
expect(section1Aside1SectionP5Span1Span1.textContent).to.equal(' mark');
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {HTMLElement | null | undefined} el */
|
||||
|
||||
|
|
Loading…
Reference in a new issue