diff --git a/.changeset/wicked-bikes-raise.md b/.changeset/wicked-bikes-raise.md new file mode 100644 index 0000000..4cafc0a --- /dev/null +++ b/.changeset/wicked-bikes-raise.md @@ -0,0 +1,27 @@ +--- +"@studiocms/markdown-remark": minor +--- + +Introduce custom User-Defined component handling. + +This update includes significant enhancements to the Markdown Remark processor Astro Integration, allowing for more flexible and powerful Markdown rendering with custom components. + +### New Features: + +- Added custom components support in the Markdown Remark processor Astro Integration. +- Introduced utility functions in `utils.ts` for component proxy creation, indentation handling, dedenting strings, and merging records. +- Moved zod schema to separate `schema.ts` file. + +### Integration Updates: + +- Enhanced Astro integration to support custom components configuration via `astro.config.mjs`. +- Updated `markdown.ts` to include custom components during Markdown rendering. +- Extended `index.ts` to utilize the new schema and utilities. + +### Documentation: + +- Updated `README.md` with instructions for setting up custom components in Astro integration. + +### Dependencies: + +- Added `entities` and `ultrahtml` as new dependencies. \ No newline at end of file diff --git a/packages/markdown-remark/README.md b/packages/markdown-remark/README.md index 4005c10..04108cb 100644 --- a/packages/markdown-remark/README.md +++ b/packages/markdown-remark/README.md @@ -29,6 +29,8 @@ pnpm add @studiocms/markdown-remark ### As an Astro Integration +With the Astro integration enabled, you can either pass in custom components into your astro config, or manually for the specific render your trying to do shown in the following methods. + #### Setup the integration **`astro.config.mjs`** @@ -44,7 +46,24 @@ export default defineConfig({ * https://docs.astro.build/en/reference/configuration-reference/#markdown-options */ }, - integrations: [markdownRemark()], + integrations: [markdownRemark({ + // Used for injecting CSS for Headings and Callouts + injectCSS: true, + // User defined components that will be used when processing markdown + components: { + // Example of a custom defined component + custom: "./src/components/Custom.astro", + }, + // Custom Markdown config + markdown: { + // Configure the available callout themes + callouts: { + theme: 'obsidian' // Can also be 'github' or 'vitepress' + }, + autoLinkHeadings: true, + sanitize: {} // see https://github.com/natemoo-re/ultrahtml?tab=readme-ov-file#sanitization for full options + } + })], }); ``` @@ -55,6 +74,7 @@ export default defineConfig({ ```astro --- import { Markdown } from 'studiocms:markdown-remark'; +import Custom from '../components/Custom.astro'; --- @@ -63,7 +83,7 @@ import { Markdown } from 'studiocms:markdown-remark'; Example - + `} components={{ custom: Custom }} /> ``` @@ -73,8 +93,10 @@ OR ```astro --- import { render } from 'studiocms:markdown-remark'; +import Custom from '../components/Custom.astro'; -const { html } = render('# Hello World!') +// @ts-ignore +const { html } = render('# Hello World! ', {}, { $$result, {custom: Custom} }) --- diff --git a/packages/markdown-remark/package.json b/packages/markdown-remark/package.json index 6665e68..6cd3fda 100644 --- a/packages/markdown-remark/package.json +++ b/packages/markdown-remark/package.json @@ -21,7 +21,7 @@ "types": "./dist/processor/index.d.ts", "default": "./dist/processor/index.js" }, - "./assets/*": "./assets/*" + "./styles/*": "./dist/integration/styles/*" }, "imports": { "#import-plugin": { @@ -30,14 +30,13 @@ } }, "files": [ - "dist", - "assets" + "dist" ], "scripts": { "prepublish": "pnpm build", - "build": "run-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", - "build:ci": "run-scripts build \"src/**/*.ts\"", - "dev": "run-scripts dev \"src/**/*.ts\"", + "build": "run-scripts build \"src/**/*.{ts,css}\" && tsc -p tsconfig.json", + "build:ci": "run-scripts build \"src/**/*.{ts,css}\"", + "dev": "run-scripts dev \"src/**/*.{ts,css}\"", "test": "pnpm build && vitest run" }, "peerDependencies": { @@ -46,6 +45,7 @@ "dependencies": { "@astrojs/prism": "^3.2.0", "astro-integration-kit": "^0.18.0", + "entities": "^6.0.0", "github-slugger": "^2.0.0", "hastscript": "^9.0.0", "hast-util-from-html": "^2.0.3", @@ -64,6 +64,7 @@ "remark-rehype": "^11.1.1", "remark-smartypants": "^3.0.2", "shiki": "^1.23.1", + "ultrahtml": "^1.5.3", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", diff --git a/packages/markdown-remark/src/index.ts b/packages/markdown-remark/src/index.ts index 52f6c16..4db0445 100644 --- a/packages/markdown-remark/src/index.ts +++ b/packages/markdown-remark/src/index.ts @@ -1,2 +1,6 @@ export * from './processor/index.js'; -export { markdownRemark, markdownRemark as default } from './integration/index.js'; +export { + markdownRemark, + default, + type StudioCMSMarkdownRemarkOptions, +} from './integration/index.js'; diff --git a/packages/markdown-remark/src/integration/index.ts b/packages/markdown-remark/src/integration/index.ts index 2cc2782..ae3661f 100644 --- a/packages/markdown-remark/src/integration/index.ts +++ b/packages/markdown-remark/src/integration/index.ts @@ -1,79 +1,78 @@ import type { AstroIntegration } from 'astro'; import { addVirtualImports, createResolver } from 'astro-integration-kit'; -import { z } from 'astro/zod'; +import { + type StudioCMSMarkdownRemarkOptions, + StudioCMSMarkdownRemarkOptionsSchema, +} from './schema.js'; import { shared } from './shared.js'; -const MarkdownRemarkOptionsSchema = z - .object({ - /** - * Inject CSS for Rehype autolink headings styles. - */ - injectCSS: z.boolean().optional().default(true), - - /** - * Options for the Markdown processor. - */ - markdown: z - .object({ - /** - * Configures the callouts theme. - */ - callouts: z - .object({ - /** - * The theme to use for callouts. - */ - theme: z - .union([z.literal('github'), z.literal('obsidian'), z.literal('vitepress')]) - .optional() - .default('obsidian'), - }) - .optional() - .default({}), - }) - .optional() - .default({}), - }) - .optional() - .default({}); - -export type MarkdownRemarkOptions = typeof MarkdownRemarkOptionsSchema._input; +export type { StudioCMSMarkdownRemarkOptions } from './schema.js'; /** * Integrates the Markdown Remark processor into Astro available as `studiocms:markdown-remark`. * - * @param {MarkdownRemarkOptions} opts Options for the Markdown Remark processor. + * @param {StudioCMSMarkdownRemarkOptions} opts Options for the Markdown Remark processor. * @returns Astro integration. */ -export function markdownRemark(opts?: MarkdownRemarkOptions): AstroIntegration { - const { injectCSS, markdown } = MarkdownRemarkOptionsSchema.parse(opts); +export function markdownRemark(opts?: StudioCMSMarkdownRemarkOptions): AstroIntegration { + // Parse the options + const { injectCSS, components, markdownExtended } = + StudioCMSMarkdownRemarkOptionsSchema.parse(opts); + + // Create a resolver for the current file const { resolve } = createResolver(import.meta.url); + // Resolve the callout theme based on the user's configuration const resolvedCalloutTheme = resolve( - `../../assets/callout-themes/${markdown.callouts.theme}.css` + `./styles/callout-themes/${markdownExtended.callouts.theme}.css` ); return { name: '@studiocms/markdown-remark', hooks: { 'astro:config:setup'(params) { + // Create a resolver for the Astro project root + const { resolve: astroRootResolve } = createResolver(params.config.root.pathname); + + // Add virtual imports for the Markdown Remark processor addVirtualImports(params, { name: '@studiocms/markdown-remark', imports: { + // The main Markdown Remark processor 'studiocms:markdown-remark': `export * from '${resolve('./markdown.js')}';`, + // Styles for the Markdown Remark processor 'studiocms:markdown-remark/css': ` - import '${resolve('../../assets/headings.css')}'; - import '${resolvedCalloutTheme}'; + import '${resolve('./styles/headings.css')}'; + ${markdownExtended.callouts.enabled ? `import '${resolvedCalloutTheme}';` : ''} + `, + // User defined components for the Markdown processor + 'studiocms:markdown-remark/user-components': ` + export const componentKeys = ${JSON.stringify(Object.keys(components).map((name) => name.toLowerCase()))}; + + ${Object.entries(components) + .map( + ([name, path]) => + `export { default as ${name.toLowerCase()} } from '${astroRootResolve(path)}';` + ) + .join('\n')} `, }, }); + // Inject the CSS for the Markdown processor if enabled if (injectCSS) { params.injectScript('page-ssr', 'import "studiocms:markdown-remark/css";'); } }, 'astro:config:done'({ injectTypes, config }) { + // Create a resolver for the Astro project root + const { resolve: astroRootResolve } = createResolver(config.root.pathname); + + // Inject the Markdown configuration into the shared state shared.markdownConfig = config.markdown; + shared.studiocms = markdownExtended; + + // Inject types for the Markdown Remark processor injectTypes({ filename: 'render.d.ts', content: `// This file is generated by @studiocms/markdown-remark @@ -84,9 +83,22 @@ export function markdownRemark(opts?: MarkdownRemarkOptions): AstroIntegration { export type Props = import('${resolve('./markdown.js')}').Props; export type RenderResponse = import('${resolve('./markdown.js')}').RenderResponse; } + + declare module 'studiocms:markdown-remark/user-components' { + export const componentKeys: string[]; + + ${Object.entries(components) + .map( + ([name, path]) => + `export const ${name.toLowerCase()}: typeof import('${astroRootResolve(path)}').default;` + ) + .join('\n')} + } `, }); }, }, }; } + +export default markdownRemark; diff --git a/packages/markdown-remark/src/integration/markdown.ts b/packages/markdown-remark/src/integration/markdown.ts index 32cc780..728c372 100644 --- a/packages/markdown-remark/src/integration/markdown.ts +++ b/packages/markdown-remark/src/integration/markdown.ts @@ -1,69 +1,38 @@ +import { componentKeys } from 'studiocms:markdown-remark/user-components'; import type { SSRResult } from 'astro'; import { renderSlot } from 'astro/runtime/server/index.js'; -import type { RenderTemplateResult } from 'astro/runtime/server/render/astro/render-template.js'; -import type { ComponentSlotValue } from 'astro/runtime/server/render/slot.js'; -import type { HTMLString } from '../processor/HTMLString.js'; +import type { SanitizeOptions } from 'ultrahtml/transformers/sanitize'; +import { HTMLString } from '../processor/HTMLString.js'; import { - type MarkdownHeading, type MarkdownProcessorRenderOptions, createMarkdownProcessor, } from '../processor/index.js'; +import { TransformToProcessor } from './schema.js'; import { shared } from './shared.js'; +import type { + ComponentSlots, + MarkdownComponentAttributes, + Props, + RenderComponents, + RenderResponse, +} from './types.js'; +import { + createComponentProxy, + importComponentsKeys, + mergeRecords, + transformHTML, +} from './utils.js'; + +export type { Props, RenderResponse } from './types.js'; + +const studiocmsMarkdownExtended = TransformToProcessor.parse(shared.studiocms); const processor = await createMarkdownProcessor({ ...shared.markdownConfig, - callouts: { - theme: 'obsidian', - }, + ...studiocmsMarkdownExtended, }); -/** - * Represents the response from rendering a markdown document. - */ -export interface RenderResponse { - /** - * The rendered HTML content as a string. - */ - html: HTMLString; - - /** - * Metadata extracted from the markdown document. - */ - meta: { - /** - * An array of headings found in the markdown document. - */ - headings: MarkdownHeading[]; - - /** - * An array of image paths found in the markdown document. - */ - imagePaths: string[]; - - /** - * The frontmatter data extracted from the markdown document. - * - * @remarks - * The frontmatter is represented as a record with string keys and values of any type. - */ - - // biome-ignore lint/suspicious/noExplicitAny: - frontmatter: Record; - }; -} - -/** - * Interface representing the properties for a markdown component. - * - * @property content - The markdown content as a string. - * @property [name: string] - An index signature allowing additional properties with string keys and values of any type. - */ -export interface Props { - content: string; - - // biome-ignore lint/suspicious/noExplicitAny: - [name: string]: any; -} +const predefinedComponents = await importComponentsKeys(componentKeys); /** * Renders the given markdown content using the specified options. @@ -74,13 +43,26 @@ export interface Props { */ export async function render( content: string, - options?: MarkdownProcessorRenderOptions + options?: MarkdownProcessorRenderOptions, + componentProxy?: RenderComponents, + sanitizeOpts?: SanitizeOptions ): Promise { - const result = await processor.render(content, options); + const componentsRendered = createComponentProxy( + componentProxy?.$$result, + mergeRecords(predefinedComponents, componentProxy?.components ?? {}) + ); + + const { code, metadata } = await processor.render(content, options); + + const html = await transformHTML( + code, + componentsRendered, + sanitizeOpts ?? shared.studiocms.sanitize + ); return { - html: result.astroHTML, - meta: result.metadata, + html: new HTMLString(html), + meta: metadata, }; } @@ -113,25 +95,27 @@ export async function render( // biome-ignore lint/suspicious/noExplicitAny: export const Markdown: (props: Props) => any = Object.assign( function Markdown( - result: SSRResult, - attributes: { content: string }, - slots: { default: ComponentSlotValue | RenderTemplateResult } + $$result: SSRResult, + { content, components, sanitizeOpts }: MarkdownComponentAttributes, + { default: slotted }: ComponentSlots ) { return { get [Symbol.toStringTag]() { return 'AstroComponent'; }, async *[Symbol.asyncIterator]() { - const mdl = attributes.content; - - if (typeof mdl === 'string') { - yield ( - await render(mdl, { + if (typeof content === 'string') { + const { html } = await render( + content, + { fileURL: new URL(import.meta.url), - }) - ).html; + }, + { $$result, components }, + sanitizeOpts + ); + yield html; } else { - yield renderSlot(result, slots.default); + yield renderSlot($$result, slotted); } }, }; diff --git a/packages/markdown-remark/src/integration/schema.ts b/packages/markdown-remark/src/integration/schema.ts new file mode 100644 index 0000000..f26cef7 --- /dev/null +++ b/packages/markdown-remark/src/integration/schema.ts @@ -0,0 +1,93 @@ +import { z } from 'astro/zod'; +import type { StudioCMSConfigOptions } from '../processor/types.js'; + +export const StudioCMSSanitizeOptionsSchema = z + .object({ + /** An Array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be dropped. */ + allowElements: z.array(z.string()).optional(), + /** An Array of strings indicating elements that the sanitizer should remove, but keeping their child elements. */ + blockElements: z.array(z.string()).optional(), + /** An Array of strings indicating elements (including nested elements) that the sanitizer should remove. */ + dropElements: z.array(z.string()).optional(), + /** An Object where each key is the attribute name and the value is an Array of allowed tag names. Matching attributes will not be removed. All attributes that are not in the array will be dropped. */ + allowAttributes: z.record(z.array(z.string())).optional(), + /** An Object where each key is the attribute name and the value is an Array of dropped tag names. Matching attributes will be removed. */ + dropAttributes: z.record(z.array(z.string())).optional(), + /** A Boolean value set to false (default) to remove components and their children. If set to true, components will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). */ + allowComponents: z.boolean().optional(), + /** A Boolean value set to false (default) to remove custom elements and their children. If set to true, custom elements will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). */ + allowCustomElements: z.boolean().optional(), + /** A Boolean value set to false (default) to remove HTML comments. Set to true in order to keep comments. */ + allowComments: z.boolean().optional(), + }) + .optional(); + +export const StudioCMSMarkdownExtendedSchema = z + .union([ + z.literal(false), + z.object({ + callouts: z + .union([ + z.literal(false), + z.object({ + theme: z + .union([z.literal('github'), z.literal('obsidian'), z.literal('vitepress')]) + .optional() + .default('obsidian'), + }), + ]) + .optional() + .default({}) + .transform((value) => { + if (value === false) { + return { theme: 'obsidian' as const, enabled: false }; + } + return { ...value, enabled: true }; + }), + + autoLinkHeadings: z.boolean().optional().default(true), + + sanitize: StudioCMSSanitizeOptionsSchema, + }), + ]) + .optional() + .default({}) + .transform((value) => { + if (value === false) { + return { + callouts: { enabled: false, theme: 'obsidian' as const }, + autoLinkHeadings: false, + }; + } + return value; + }); + +export const StudioCMSMarkdownRemarkOptionsSchema = z + .object({ + /** + * Inject CSS for Rendering Markdown content. + */ + injectCSS: z.boolean().optional().default(true), + + components: z.record(z.string(), z.string()).optional().default({}), + + markdownExtended: StudioCMSMarkdownExtendedSchema, + }) + .optional() + .default({}); + +export const TransformToProcessor = StudioCMSMarkdownExtendedSchema.transform( + ({ autoLinkHeadings, callouts }) => { + return { + studiocms: { + callouts: callouts.enabled ? { theme: callouts.theme } : false, + autolink: autoLinkHeadings, + }, + } as { studiocms: StudioCMSConfigOptions }; + } +); + +export type StudioCMSMarkdownExtendedOptions = typeof StudioCMSMarkdownExtendedSchema._input; +export type StudioCMSMarkdownExtendedConfig = z.infer; +export type StudioCMSMarkdownRemarkOptions = typeof StudioCMSMarkdownRemarkOptionsSchema._input; +export type StudioCMSMarkdownRemarkConfig = z.infer; diff --git a/packages/markdown-remark/src/integration/shared.ts b/packages/markdown-remark/src/integration/shared.ts index faae39b..414113d 100644 --- a/packages/markdown-remark/src/integration/shared.ts +++ b/packages/markdown-remark/src/integration/shared.ts @@ -1,17 +1,7 @@ -import type { AstroConfig } from 'astro'; +import type { Shared } from './types.js'; export const symbol: symbol = Symbol.for('@studiocms/markdown-remark'); -/** - * Interface representing shared configuration for markdown. - * - * @interface Shared - * @property {AstroConfig['markdown']} markdownConfig - The markdown configuration from AstroConfig. - */ -export interface Shared { - markdownConfig: AstroConfig['markdown']; -} - /** * A shared object that is either retrieved from the global scope using a symbol or * initialized as a new object with a `markdownConfig` property. diff --git a/packages/markdown-remark/assets/callout-themes/github.css b/packages/markdown-remark/src/integration/styles/callout-themes/github.css similarity index 100% rename from packages/markdown-remark/assets/callout-themes/github.css rename to packages/markdown-remark/src/integration/styles/callout-themes/github.css diff --git a/packages/markdown-remark/assets/callout-themes/obsidian.css b/packages/markdown-remark/src/integration/styles/callout-themes/obsidian.css similarity index 100% rename from packages/markdown-remark/assets/callout-themes/obsidian.css rename to packages/markdown-remark/src/integration/styles/callout-themes/obsidian.css diff --git a/packages/markdown-remark/assets/callout-themes/vitepress.css b/packages/markdown-remark/src/integration/styles/callout-themes/vitepress.css similarity index 100% rename from packages/markdown-remark/assets/callout-themes/vitepress.css rename to packages/markdown-remark/src/integration/styles/callout-themes/vitepress.css diff --git a/packages/markdown-remark/assets/headings.css b/packages/markdown-remark/src/integration/styles/headings.css similarity index 100% rename from packages/markdown-remark/assets/headings.css rename to packages/markdown-remark/src/integration/styles/headings.css diff --git a/packages/markdown-remark/src/integration/types.ts b/packages/markdown-remark/src/integration/types.ts new file mode 100644 index 0000000..4b9f25c --- /dev/null +++ b/packages/markdown-remark/src/integration/types.ts @@ -0,0 +1,75 @@ +import type { AstroConfig } from 'astro'; +import type { RenderTemplateResult } from 'astro/runtime/server/render/astro/render-template.js'; +import type { ComponentSlotValue } from 'astro/runtime/server/render/slot.js'; +import type { SanitizeOptions } from 'ultrahtml/transformers/sanitize'; +import type { HTMLString } from '../processor/HTMLString.js'; +import type { MarkdownHeading } from '../processor/index.js'; +import type { StudioCMSMarkdownExtendedConfig } from './schema.js'; + +/** + * Represents the response from rendering a markdown document. + */ +export interface RenderResponse { + /** + * The rendered HTML content as a string. + */ + html: HTMLString; + + /** + * Metadata extracted from the markdown document. + */ + meta: { + /** + * An array of headings found in the markdown document. + */ + headings: MarkdownHeading[]; + + /** + * An array of image paths found in the markdown document. + */ + imagePaths: string[]; + + /** + * The frontmatter data extracted from the markdown document. + * + * @remarks + * The frontmatter is represented as a record with string keys and values of any type. + */ + + // biome-ignore lint/suspicious/noExplicitAny: + frontmatter: Record; + }; +} + +export interface RenderComponents { + // biome-ignore lint/suspicious/noExplicitAny: + $$result?: any; + // biome-ignore lint/suspicious/noExplicitAny: + components?: Record; +} + +export interface MarkdownComponentAttributes { + content: string; + // biome-ignore lint/suspicious/noExplicitAny: + components?: Record; + sanitizeOpts?: SanitizeOptions; +} + +export interface Props extends MarkdownComponentAttributes { + // biome-ignore lint/suspicious/noExplicitAny: + [name: string]: any; +} + +export interface ComponentSlots { + default: ComponentSlotValue | RenderTemplateResult; +} +/** + * Interface representing shared configuration for markdown. + * + * @interface Shared + * @property {AstroConfig['markdown']} markdownConfig - The markdown configuration from AstroConfig. + */ +export interface Shared { + markdownConfig: AstroConfig['markdown']; + studiocms: StudioCMSMarkdownExtendedConfig; +} diff --git a/packages/markdown-remark/src/integration/utils.ts b/packages/markdown-remark/src/integration/utils.ts new file mode 100644 index 0000000..2f2fe09 --- /dev/null +++ b/packages/markdown-remark/src/integration/utils.ts @@ -0,0 +1,159 @@ +import { AstroError } from 'astro/errors'; +import { jsx as h } from 'astro/jsx-runtime'; +import { renderJSX } from 'astro/runtime/server/jsx.js'; +import * as entities from 'entities'; +import { __unsafeHTML, transform } from 'ultrahtml'; +import sanitize, { type SanitizeOptions } from 'ultrahtml/transformers/sanitize'; +import swap from 'ultrahtml/transformers/swap'; + +/** + * Creates a proxy for components that can either be strings or functions. + * If the component is a string, it is directly assigned to the proxy. + * If the component is a function, it is wrapped in an async function that + * processes the props and children before rendering. + * + * @param result - The result object used for rendering JSX. + * @param _components - An optional record of components to be proxied. Defaults to an empty object. + * @returns A record of proxied components. + */ +export function createComponentProxy( + // biome-ignore lint/suspicious/noExplicitAny: + result: any, + // biome-ignore lint/suspicious/noExplicitAny: + _components: Record = {} +) { + // biome-ignore lint/suspicious/noExplicitAny: + const components: Record = {}; + for (const [key, value] of Object.entries(_components)) { + if (typeof value === 'string') { + components[key.toLowerCase()] = value; + } else { + components[key.toLowerCase()] = async ( + // biome-ignore lint/suspicious/noExplicitAny: + props: Record, + // biome-ignore lint/suspicious/noExplicitAny: + children: { value: any } + ) => { + if (key === 'codeblock' || key === 'codespan') { + props.code = entities.decode(JSON.parse(`"${props.code}"`)); + } + const output = await renderJSX(result, h(value, { ...props, 'set:html': children.value })); + return __unsafeHTML(output); + }; + } + } + return components; +} + +/** + * Determines the indentation of a given line of text. + * + * @param ln - The line of text to analyze. + * @returns The leading whitespace characters of the line, or an empty string if there is no indentation. + */ +function getIndent(ln: string): string { + if (ln.trimStart() === ln) return ''; + return ln.slice(0, ln.length - ln.trimStart().length); +} + +/** + * Removes leading indentation from a multi-line string. + * + * @param str - The string from which to remove leading indentation. + * @returns The dedented string. + */ +export function dedent(str: string): string { + const lns = str.replace(/^[\r\n]+/, '').split('\n'); + let indent = getIndent(lns[0]); + if (indent.length === 0 && lns.length > 1) { + indent = getIndent(lns[1]); + } + if (indent.length === 0) return lns.join('\n'); + return lns.map((ln) => (ln.startsWith(indent) ? ln.slice(indent.length) : ln)).join('\n'); +} + +/** + * Merges multiple records into a single record. If there are duplicate keys, the value from the last record with that key will be used. + * + * @param {...Record[]} records - The records to merge. + * @returns {Record} - The merged record. + */ +// biome-ignore lint/suspicious/noExplicitAny: +export function mergeRecords(...records: Record[]): Record { + // biome-ignore lint/suspicious/noExplicitAny: + const result: Record = {}; + for (const record of records) { + for (const [key, value] of Object.entries(record)) { + result[key.toLowerCase()] = value; + } + } + return result; +} + +export class MarkdownRemarkError extends AstroError { + name = 'StudioCMS Markdown Remark Error'; +} + +// biome-ignore lint/suspicious/noExplicitAny: +export function prefixError(err: any, prefix: string): any { + // If the error is an object with a `message` property, attempt to prefix the message + if (err?.message) { + try { + err.message = `${prefix}:\n${err.message}`; + return err; + } catch { + // Any errors here are ok, there's fallback code below + } + } + + // If that failed, create a new error with the desired message and attempt to keep the stack + const wrappedError = new Error(`${prefix}${err ? `: ${err}` : ''}`); + try { + wrappedError.stack = err.stack; + wrappedError.cause = err; + } catch { + // It's ok if we could not set the stack or cause - the message is the most important part + } + + return wrappedError; +} + +/** + * Imports components by their keys from the 'studiocms:markdown-remark/user-components' module. + * + * @param keys - An array of strings representing the keys of the components to import. + * @returns A promise that resolves to an object containing the imported components. + * @throws {MarkdownRemarkError} If any component fails to import, an error is thrown with a prefixed message. + */ +export async function importComponentsKeys(keys: string[]) { + // biome-ignore lint/suspicious/noExplicitAny: + const predefinedComponents: Record = {}; + + for (const key of keys) { + try { + predefinedComponents[key.toLowerCase()] = ( + await import('studiocms:markdown-remark/user-components') + )[key.toLowerCase()]; + } catch (e) { + if (e instanceof Error) { + const newErr = prefixError(e, `Failed to import component "${key}"`); + console.error(newErr); + throw new MarkdownRemarkError(newErr.message, newErr.stack); + } + const newErr = prefixError(new Error('Unknown error'), `Failed to import component "${key}"`); + console.error(newErr); + throw new MarkdownRemarkError(newErr.message, newErr.stack); + } + } + + return predefinedComponents; +} + +export async function transformHTML( + html: string, + // biome-ignore lint/suspicious/noExplicitAny: + components: Record, + sanitizeOpts?: SanitizeOptions +): Promise { + return await transform(dedent(html), [sanitize(sanitizeOpts), swap(components)]); +} diff --git a/packages/markdown-remark/src/processor/index.ts b/packages/markdown-remark/src/processor/index.ts index 8590a22..f7ffc6c 100644 --- a/packages/markdown-remark/src/processor/index.ts +++ b/packages/markdown-remark/src/processor/index.ts @@ -19,6 +19,7 @@ import { remarkCollectImages } from './remark-collect-images.js'; import type { MarkdownProcessor, MarkdownProcessorRenderResult, + StudioCMSCalloutOptions, StudioCMSMarkdownOptions, } from './types.js'; @@ -65,8 +66,11 @@ export const markdownConfigDefaults: Required = { remarkRehype: {}, gfm: true, smartypants: true, - callouts: { - theme: 'obsidian', + studiocms: { + callouts: { + theme: 'obsidian', + }, + autolink: true, }, }; @@ -104,9 +108,24 @@ export async function createMarkdownProcessor( remarkRehype: remarkRehypeOptions = markdownConfigDefaults.remarkRehype, gfm = markdownConfigDefaults.gfm, smartypants = markdownConfigDefaults.smartypants, - callouts = markdownConfigDefaults.callouts, + studiocms = markdownConfigDefaults.studiocms, } = opts ?? {}; + let autolink = true; + let calloutsEnabled = true; + let calloutsConfig: StudioCMSCalloutOptions = { theme: 'obsidian' }; + + if (typeof studiocms === 'boolean') { + autolink = studiocms; + calloutsEnabled = studiocms; + } else if (typeof studiocms === 'object') { + autolink = studiocms.autolink ?? autolink; + calloutsEnabled = studiocms.callouts !== false; + if (typeof studiocms.callouts === 'object') { + calloutsConfig = studiocms.callouts; + } + } + const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins)); const loadedRehypePlugins = await Promise.all(loadPlugins(rehypePlugins)); @@ -157,10 +176,14 @@ export async function createMarkdownProcessor( parser.use(rehypeHeadingIds); // Autolink headings - parser.use(rehypeAutoLink, rehypeAutolinkOptions); + if (autolink) { + parser.use(rehypeAutoLink, rehypeAutolinkOptions); + } // Callouts - parser.use(rehypeCallouts, callouts); + if (calloutsEnabled) { + parser.use(rehypeCallouts, calloutsConfig); + } // Stringify to HTML parser.use(rehypeRaw).use(rehypeStringify, { allowDangerousHtml: true }); diff --git a/packages/markdown-remark/src/processor/types.ts b/packages/markdown-remark/src/processor/types.ts index 6449155..c148075 100644 --- a/packages/markdown-remark/src/processor/types.ts +++ b/packages/markdown-remark/src/processor/types.ts @@ -55,10 +55,17 @@ export interface AstroMarkdownOptions { smartypants?: boolean; } +export interface StudioCMSCalloutOptions { + theme?: 'github' | 'obsidian' | 'vitepress'; +} + +export interface StudioCMSConfigOptions { + callouts?: StudioCMSCalloutOptions | false; + autolink?: boolean; +} + export interface StudioCMSMarkdownOptions extends AstroMarkdownOptions { - callouts?: { - theme?: 'github' | 'obsidian' | 'vitepress'; - }; + studiocms?: StudioCMSConfigOptions | false; } export interface MarkdownProcessor { diff --git a/packages/markdown-remark/tests/astro-integration.test.ts b/packages/markdown-remark/tests/astro-integration.test.ts index 38aede7..a5a10c4 100644 --- a/packages/markdown-remark/tests/astro-integration.test.ts +++ b/packages/markdown-remark/tests/astro-integration.test.ts @@ -41,6 +41,14 @@ describe('Markdown-Remark Astro Integration Tests', () => {
This is a collapsible callout

Some content shown after opening!

`); }); + test('Custom Components test', async () => { + const content = await fixture.readFile('custom-components/index.html'); + + expect(content).toContain( + '

Custom Component

This is a custom component

' + ); + }); + test('Direct Markdown Processor Tests', async () => { const content = await fixture.readFile('direct/index.html'); diff --git a/packages/markdown-remark/tests/fixture/astro/astro.config.ts b/packages/markdown-remark/tests/fixture/astro/astro.config.ts index ccebb4b..affc995 100644 --- a/packages/markdown-remark/tests/fixture/astro/astro.config.ts +++ b/packages/markdown-remark/tests/fixture/astro/astro.config.ts @@ -2,5 +2,11 @@ import markdownRemark from '@studiocms/markdown-remark'; import { defineConfig } from 'astro/config'; export default defineConfig({ - integrations: [markdownRemark()], + integrations: [ + markdownRemark({ + components: { + custom: './src/pages/custom-components/_comps/Custom.astro', + }, + }), + ], }); diff --git a/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/_comps/Custom.astro b/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/_comps/Custom.astro new file mode 100644 index 0000000..d1401d9 --- /dev/null +++ b/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/_comps/Custom.astro @@ -0,0 +1,7 @@ +--- +--- + +
+

Custom Component

+

This is a custom component

+
\ No newline at end of file diff --git a/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/_md/md.txt b/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/_md/md.txt new file mode 100644 index 0000000..b47f8f9 --- /dev/null +++ b/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/_md/md.txt @@ -0,0 +1,5 @@ +# Hello World! + + + +Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. \ No newline at end of file diff --git a/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/index.astro b/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/index.astro new file mode 100644 index 0000000..64247a5 --- /dev/null +++ b/packages/markdown-remark/tests/fixture/astro/src/pages/custom-components/index.astro @@ -0,0 +1,8 @@ +--- +import { Markdown } from 'studiocms:markdown-remark'; +import Layout from '../../layouts/layout.astro'; +import content from './_md/md.txt?raw'; +--- + + + \ No newline at end of file diff --git a/packages/markdown-remark/tests/fixture/astro/src/pages/index.astro b/packages/markdown-remark/tests/fixture/astro/src/pages/index.astro index 44124ed..c993d32 100644 --- a/packages/markdown-remark/tests/fixture/astro/src/pages/index.astro +++ b/packages/markdown-remark/tests/fixture/astro/src/pages/index.astro @@ -6,6 +6,7 @@ import Layout from '../layouts/layout.astro';
  • Basic
  • Basic Render
  • Callouts
  • +
  • Custom Components
  • Direct
  • Syntax Test
  • diff --git a/packages/markdown-remark/tests/fixture/astro/v.d.ts b/packages/markdown-remark/tests/fixture/astro/v.d.ts index e659f2b..6d1f170 100644 --- a/packages/markdown-remark/tests/fixture/astro/v.d.ts +++ b/packages/markdown-remark/tests/fixture/astro/v.d.ts @@ -2,3 +2,7 @@ declare module 'studiocms:markdown-remark' { export const Markdown: typeof import('../../../src/integration/markdown').Markdown; export const render: typeof import('../../../src/integration/markdown').render; } + +declare module 'studiocms:markdown-remark/user-components' { + export const componentMap: Record; +} diff --git a/packages/markdown-remark/tsconfig.json b/packages/markdown-remark/tsconfig.json index 8f97fc1..8881889 100644 --- a/packages/markdown-remark/tsconfig.json +++ b/packages/markdown-remark/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"], + "include": ["src", "v.d.ts"], "compilerOptions": { "outDir": "./dist", "rootDir": "./src" diff --git a/packages/markdown-remark/v.d.ts b/packages/markdown-remark/v.d.ts new file mode 100644 index 0000000..e39b685 --- /dev/null +++ b/packages/markdown-remark/v.d.ts @@ -0,0 +1,8 @@ +declare module 'studiocms:markdown-remark' { + export const Markdown: typeof import('./src/integration/markdown.js').Markdown; + export const render: typeof import('./src/integration/markdown.js').render; +} + +declare module 'studiocms:markdown-remark/user-components' { + export const componentKeys: string[]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1110e96..e98c2f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: astro-integration-kit: specifier: ^0.18.0 version: 0.18.0(astro@5.1.2(@types/node@22.10.5)(rollup@4.29.1)(typescript@5.7.2)(yaml@2.7.0)) + entities: + specifier: ^6.0.0 + version: 6.0.0 github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -107,6 +110,9 @@ importers: shiki: specifier: ^1.23.1 version: 1.25.1 + ultrahtml: + specifier: ^1.5.3 + version: 1.5.3 unified: specifier: ^11.0.5 version: 11.0.5 @@ -1427,6 +1433,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} @@ -4164,6 +4174,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.0: {} + es-module-lexer@1.6.0: {} esbuild-plugin-copy@2.1.1(esbuild@0.24.2):