diff --git a/.changeset/mean-humans-mix.md b/.changeset/mean-humans-mix.md new file mode 100644 index 0000000000..f49e45d925 --- /dev/null +++ b/.changeset/mean-humans-mix.md @@ -0,0 +1,5 @@ +--- +"@opral/markdown-wc": minor +--- + +Refactor: Remove dependency on TailwindCSS to increase interoperability. diff --git a/.changeset/weak-zebras-smell.md b/.changeset/weak-zebras-smell.md new file mode 100644 index 0000000000..02c4b3db77 --- /dev/null +++ b/.changeset/weak-zebras-smell.md @@ -0,0 +1,15 @@ +--- +"@opral/markdown-wc": minor +--- + +Ensure interoperability and portability by letting documents import components via frontmatter. + +```diff +title: "Hello World" ++import: ++ - "https://example.com/doc-card.js" ++ - "https://example.com/doc-button.js" +--- + +# Hello World +``` \ No newline at end of file diff --git a/inlang/packages/website/src/pages/m/+onBeforeRender.ts b/inlang/packages/website/src/pages/m/+onBeforeRender.ts index 4cfaa80b16..9c2a06bd3a 100644 --- a/inlang/packages/website/src/pages/m/+onBeforeRender.ts +++ b/inlang/packages/website/src/pages/m/+onBeforeRender.ts @@ -120,8 +120,8 @@ export default async function onBeforeRender(pageContext: PageContext) { const markdown = await parse(content); renderedMarkdown = markdown.html; - if (markdown.data?.frontmatter) { - pageData = markdown.data?.frontmatter; + if (markdown?.frontmatter) { + pageData = markdown?.frontmatter; } } catch (error) { // pages do not getting prerendered because they are link @@ -139,8 +139,8 @@ export default async function onBeforeRender(pageContext: PageContext) { const readmeMarkdown = await parse(await getContentString(readme()!)); renderedMarkdown = readmeMarkdown.html; - if (readmeMarkdown.data?.frontmatter) { - pageData = readmeMarkdown.data?.frontmatter; + if (readmeMarkdown?.frontmatter) { + pageData = readmeMarkdown?.frontmatter; } } catch (error) { console.error("Error while accessing the readme file"); diff --git a/packages/markdown-wc/README.md b/packages/markdown-wc/README.md index 85f5bb8da0..fb2832d427 100644 --- a/packages/markdown-wc/README.md +++ b/packages/markdown-wc/README.md @@ -1,3 +1,10 @@ +import: + - "https://example.com/doc-card.js" + - "https://example.com/doc-button.js" + + +--- + # Markdown with Web Components Enables writing documentation with components in markdown as backwards compatible superset. @@ -5,16 +12,18 @@ Enables writing documentation with components in markdown as backwards compatibl ## Why - Enables writing documentation with components in markdown -- Uses only HTML and Frontmatter to be backwards compatible with markdown +- Interoperable with existing markdown parsers - Doesn't depend on a framework like [React MDX](https://mdxjs.com/) or [Svelte MDsveX](https://github.com/pngwn/MDsveX) +- Doesn't introduce custom syntax like [Markdoc](https://markdoc.dev/) ## Comparison | Feature | Markdown | @opral/markdown-wc | React MDX | Svelte MDsveX | Markdoc | |--------------------------------|----------|--------------------|-----------|---------------|---------| -| Components in markdown | ❌ | ✅ | ✅ | ✅ | ✅ | -| Interoperable | ✅ | ✅ | ❌ | ❌ | ✅ | -| Doesn't depend on a framework | ✅ | ✅ | ❌ | ❌ | ✅ | +| Components in markdown | ❌ | ✅ | ✅ | ✅ | ✅ | +| Interoperable | ✅ | ✅ | ❌ | ❌ | ✅ | +| Portable | ✅ | ✅ | ❌ | ❌ | ✅ | +| No custom syntax | ✅ | ✅ | ❌ | ❌ | ❌ | ## Usage @@ -33,7 +42,17 @@ const markdown = ` `; -const html = parse(markdown); +// Parse markdown +const parsed = parse(markdown); + +// Register web components +for (const name in parsed.imports) { + // optionally sanitize the components here + const component = await import(parsed.imports[name]) + customElements.define(name, component); +} + +// render HTML +render(parsed.html); +``` -console.log(html); -``` \ No newline at end of file diff --git a/packages/markdown-wc/package.json b/packages/markdown-wc/package.json index 81ae31c179..d61eef3399 100644 --- a/packages/markdown-wc/package.json +++ b/packages/markdown-wc/package.json @@ -19,8 +19,6 @@ "clean": "rm -rf ./dist ./node_modules" }, "dependencies": { - "@sinclair/typebox": "^0.32.20", - "cheerio": "1.0.0-rc.12", "iconify-icon": "1.0.8", "lit": "2.8.0", "rehype-accessible-emojis": "0.3.2", @@ -32,7 +30,6 @@ "rehype-sanitize": "6.0.0", "rehype-slug": "6.0.0", "rehype-stringify": "10.0.0", - "remark-frontmatter": "^5.0.0", "remark-gfm": "3.0.1", "remark-parse": "10.0.2", "remark-rehype": "10.1.0", diff --git a/packages/markdown-wc/src/index.ts b/packages/markdown-wc/src/index.ts index 37d1ee5cbb..4814a34b40 100644 --- a/packages/markdown-wc/src/index.ts +++ b/packages/markdown-wc/src/index.ts @@ -1,2 +1 @@ export { parse } from "./parse.js" -export { generateTableOfContents } from "./tableOfContents.js" diff --git a/packages/markdown-wc/src/parse.test.ts b/packages/markdown-wc/src/parse.test.ts index f9460f46a0..ab1d545888 100644 --- a/packages/markdown-wc/src/parse.test.ts +++ b/packages/markdown-wc/src/parse.test.ts @@ -33,36 +33,27 @@ C -->|Two| E[Result two] expect(html).toContain(" { +test("leaves imported custom elements as is", async () => { const markdown = ` +--- +imports: + doc-figure: "https://cdn.skypack.dev/@doc-elements/figure" +--- # Hello World ` - const html = (await parse(markdown)).html - expect(html).toContain(" { - const markdown = ` - - ` - const html = (await parse(markdown)).html - expect(html).toContain("") -}) - -test("should be able to display a comment", async () => { - const markdown = ` - - ` - const html = (await parse(markdown)).html - expect(html).toContain(" { +test("additional frontmatter properties", async () => { const markdown = `--- -title: test -description: test +title: test_title +description: test_description --- # Hello World @@ -70,20 +61,15 @@ This is markdown ` const result = await parse(markdown) expect(result.html).toContain(" { - const markdown = `--- -title: test ---- - +test("no frontmatter defined", async () => { + const markdown = ` # Hello World This is markdown ` - try { - await parse(markdown) - } catch (e: any) { - expect(e.message).toContain("Frontmatter is not valid") - } + const parsed = await parse(markdown) + expect(parsed.html).toContain(" = () => (node, file) => { - const Frontmatter = Type.Object({ - title: Type.String(), - description: Type.String(), - }) - - // TODO: make this more robust - const frontmatterString = - (node as any).children.find((child: any) => child.type === "yaml")?.value || "" - - const frontmatter = yaml.parse(frontmatterString) - if (frontmatter !== null) { - file.data.frontmatter = frontmatter +/* Converts the markdown with remark and the html with rehype to be suitable for being rendered */ +export async function parse( + markdown: string, + options?: { + /** + * Inline styles to be applied to the HTML elements + * + * @example + * const inlineStyles = { + * h1: { + * font-weight: "600", + * line-height: "1.625", + * } + * } + */ + inlineStyles?: Record> + } +): Promise<{ + frontmatter: Record & { imports: Record[] } + html: string +}> { + const withDefaults = { + inlineStyles: defaultInlineStyles, + ...options, } - //check only if frontmatter exists - if (file.data.frontmatter) { - // check if type Frontmatter - if (Value.Check(Frontmatter, frontmatter)) { - // type is valid - } else { - throw new Error("Frontmatter is not valid") - } + // Extract frontmatter manually + const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n/m) + let frontmatter: Record & { imports: Record[] } = { imports: [] } + + if (frontmatterMatch) { + frontmatter = yaml.parse(frontmatterMatch[1]!) || { imports: [] } + // Remove the frontmatter from the markdown string + markdown = markdown.slice(frontmatterMatch[0].length).trimStart() } -} -/* Converts the markdown with remark and the html with rehype to be suitable for being rendered */ -export async function parse(markdown: string): Promise<{ data: any; html: string }> { const content = await unified() /* @ts-ignore */ .use(remarkParse) - .use(remarkFrontmatter, ["yaml"]) - .use(customFrontmatterValidation) /* @ts-ignore */ .use(remarkGfm) /* @ts-ignore */ @@ -58,84 +60,24 @@ export async function parse(markdown: string): Promise<{ data: any; html: string .use(rehypeRaw) /* @ts-ignore */ .use(rehypeSanitize, { + ...defaultSchema, + tagNames: [ - "doc-figure", - "doc-link", - "doc-copy", - "doc-links", - "doc-icon", - "doc-slider", - "doc-comment", - "doc-comments", - "doc-proof", - "doc-feature", - "doc-features", - "inlang-badge-generator", - "doc-accordion", - "doc-header", - "doc-image", - "doc-pricing", - "doc-important", - "doc-video", - "doc-hero", + // allow the custom elements + ...Object.keys(frontmatter.imports ?? []), ...(defaultSchema.tagNames ?? []), ], attributes: { - "doc-figure": ["src", "alt", "caption"], - "doc-link": ["href", "description", "title", "icon"], - "doc-comment": ["author", "text", "icon"], - "doc-feature": ["name", "icon", "image", "color", "text-color"], - "doc-proof": ["organisations"], - "doc-slider": ["items"], - "doc-icon": ["icon", "size"], - "doc-accordion": ["heading", "text"], - "doc-header": ["title", "description", "button", "link"], - "doc-image": ["src", "alt"], - "doc-important": ["title", "description"], - "doc-pricing": ["heading", "content"], - "doc-video": ["src", "type"], - "doc-hero": [ - "title", - "description", - "primary-text", - "primary-link", - "secondary-text", - "secondary-link", - "tag", - "companies", - ], - ...defaultSchema.attributes, + // allow any attributes on custom elements + ...Object.keys(frontmatter.imports ?? {}).map((customElement) => ({ + [customElement]: ["*"], + })), + ...(defaultSchema.attributes ?? {}), }, }) .use(rehypeHighlight) .use(rehypeSlug) - /* @ts-ignore */ - .use(addClasses, { - "h1,h2,h3,h4,h5,h6": - "doc-font-semibold doc-leading-relaxed doc-relative doc-my-6 doc-cursor-pointer doc-group/heading doc-no-underline", - h1: "doc-text-3xl doc-pb-3 doc-mb-2 doc-mt-12", - h2: "doc-text-2xl doc-pb-3 doc-mb-1 doc-mt-10", - h3: "doc-text-xl doc-mt-10 doc-pb-0 doc-mb-0", - h4: "doc-text-lg", - h5: "doc-text-lg", - h6: "doc-text-base", - p: "doc-text-base text-surface-600 doc-my-4 doc-leading-relaxed", - a: "text-primary doc-font-medium hover:text-hover-primary doc-no-underline doc-inline-block", - code: "doc-px-1 doc-py-0.5 doc-bg-surface-100 doc-rounded-lg bg-surface-200 doc-my-6 doc-text-sm doc-font-mono text-surface-900", - pre: "doc-relative", - ul: "doc-list-disc doc-list-inside doc-space-y-3 pl-6", - ol: "doc-list-decimal doc-list-inside doc-space-y-3 pl-6", - li: "doc-list-outside", - table: - "doc-table-auto doc-w-full doc-my-6 doc-rounded-xl doc-text-left doc-max-w-[100%] doc-overflow-x-scroll", - thead: "doc-font-medium pb-2 doc-border-b doc-border-surface-2 doc-text-left", - th: "doc-py-2 doc-font-medium doc-border-b doc-border-surface-2 doc-truncate", - tr: "doc-py-2 doc-border-b border-surface-2", - td: "doc-py-2 doc-leading-7", - hr: "doc-my-6 doc-border-b border-surface-200", - img: "doc-mx-auto doc-my-4 doc-rounded", - strong: "doc-font-bold", - }) + .use(rehypeInlineStyles(withDefaults.inlineStyles)) /* @ts-ignore */ .use(rehypeAutolinkHeadings, { behavior: "wrap", @@ -144,108 +86,13 @@ export async function parse(markdown: string): Promise<{ data: any; html: string }, }) /* @ts-ignore */ - .use(rehypeRewrite, { - rewrite: (node: any) => { - if ( - node.tagName === "h1" || - node.tagName === "h2" || - node.tagName === "h3" || - node.tagName === "h4" || - node.tagName === "h5" || - node.tagName === "h6" - ) { - if (node.type === "element") { - node.children = [ - { - type: "element", - tagName: "span", - properties: { - className: - "doc-font-medium doc-hidden md:doc-block doc-mr-2 text-primary doc-opacity-0 group-hover/heading:doc-opacity-100 transition-opacity doc-absolute " + - (node.tagName === "h1" - ? "-doc-left-6" - : node.tagName === "h2" - ? "-doc-left-5" - : node.tagName === "h3" - ? "-doc-left-4" - : "-doc-left-3"), - }, - children: [{ type: "text", value: "#" }], - }, - ...node.children, - ] - } - } else if ( - node.tagName === "pre" && - !node.children[0].properties.className.includes("language-mermaid") - ) { - node.children = [ - { - type: "element", - tagName: "doc-copy", - properties: { - className: "doc-absolute doc-right-3 doc-top-3 doc-font-sans text-sm", - }, - }, - ...node.children, - ] - } else if ( - node.tagName === "pre" && - node.children[0].properties.className.includes("language-mermaid") - ) { - node.tagName = "div" - node.children = [] - } else if ( - // external link with arrow icon - node.tagName === "a" && - node.properties.href && - node.properties.href.startsWith("http") && - !node.properties.href.includes("inlang.com") && - node.children[0].tagName !== "img" - ) { - ;(node.children = [ - ...node.children, - { - type: "element", - tagName: "doc-icon", - properties: { - className: "relative doc-ml-1 doc-top-[3px]", - icon: "material-symbols:arrow-outward", - size: "1.2em", - }, - }, - ]), - (node.properties.target = "_blank") - } else if ( - // external link to inlang.com (we need that to have working links in github) - node.tagName === "a" && - node.properties.href && - node.properties.href.startsWith("http") && - node.properties.href.includes("inlang.com") && - node.children[0].tagName !== "img" - ) { - node.properties.href = node.properties.href - .replace("https://inlang.com", "") - .replace("http://inlang.com", "") - } else if ( - // external link with image (no arrow icon) - node.tagName === "a" && - node.properties.href && - node.properties.href.startsWith("http") && - node.children[0].tagName === "img" - ) { - node.properties.target = "_blank" - } - }, - }) - /* @ts-ignore */ .use(rehypeAccessibleEmojis) /* @ts-ignore */ .use(rehypeStringify) .process(preprocess(markdown)) return { - data: content.data || {}, + frontmatter, html: String(` ${content}`), } diff --git a/packages/markdown-wc/src/tableOfContents.ts b/packages/markdown-wc/src/tableOfContents.ts deleted file mode 100644 index cacc21b264..0000000000 --- a/packages/markdown-wc/src/tableOfContents.ts +++ /dev/null @@ -1,31 +0,0 @@ -import cheerio from "cheerio" - -export const generateTableOfContents = async (markdown: any) => { - const table = {} - - if ( - markdown && - markdown.match(/(.*?)<\/h[1-3]>/g) && - markdown.match(/(.*?)<\/h[1]>/g) - ) { - const headings = markdown.match(/(.*?)<\/h[1-3]>/g) - - for (const heading of headings) { - const $ = cheerio.load(heading) - const text = $("h1, h2, h3").text() - - if (text) { - if ($("h1").length > 0) { - // @ts-ignore - table[text.replace(/(<([^>]+)>)/gi, "").replace("#", "")] = [] - } else if (Object.keys(table).length > 0) { - const lastH1Key = Object.keys(table).pop() - // @ts-ignore - table[lastH1Key].push(text.replace(/(<([^>]+)>)/gi, "").replace("#", "")) - } - } - } - } - - return table -} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a3cd1bf14e..201d16ea58 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'inlang/packages/**/*' + - "!**/*/dist" - 'packages/*' - 'packages/*/example' - 'packages/*/examples/*'