diff options
author | 2025-03-12 14:58:59 +0100 | |
---|---|---|
committer | 2025-03-12 13:58:59 +0000 | |
commit | cb886dcde6c28acca286a66be46228a4d4cc52e7 (patch) | |
tree | d00898192e6a7149698c2261c7194000d1e44a3d | |
parent | a3327ffbe6373228339824684eaa6f340a20a32e (diff) | |
download | astro-cb886dcde6c28acca286a66be46228a4d4cc52e7.tar.gz astro-cb886dcde6c28acca286a66be46228a4d4cc52e7.tar.zst astro-cb886dcde6c28acca286a66be46228a4d4cc52e7.zip |
Add `experimental.headingIdCompat` flag (#13352)
* Add `experimental.headingIdCompat` option schema & types
* Markdown and MDX support
* Markdoc support
* Add changeset
* Fix missing argument in Markdoc integration
* Improve JSDoc comment
Co-authored-by: Matt Kane <m@mk.gg>
* Refactor to avoid global context object in Markdoc
* Minor changeset tweak
* Make `rehypeHeadingIds()` argument optional for backwards compatibility
* Add doc comment to `rehypeHeadingIds()`
* Document rehype plugin usage in changeset
---------
Co-authored-by: Matt Kane <m@mk.gg>
17 files changed, 225 insertions, 17 deletions
diff --git a/.changeset/good-toys-refuse.md b/.changeset/good-toys-refuse.md new file mode 100644 index 000000000..2705bcfa8 --- /dev/null +++ b/.changeset/good-toys-refuse.md @@ -0,0 +1,45 @@ +--- +'@astrojs/markdoc': minor +'@astrojs/mdx': minor +'@astrojs/markdown-remark': minor +'astro': minor +--- + +Adds support for a new `experimental.headingIdCompat` flag + +By default, Astro removes a trailing `-` from the end of IDs it generates for headings ending with +special characters. This differs from the behavior of common Markdown processors. + +You can now disable this behavior with a new configuration flag: + +```js +// astro.config.mjs +import { defineConfig } from "astro/config"; + +export default defineConfig({ + experimental: { + headingIdCompat: true, + }, +}); +``` + +This can be useful when heading IDs and anchor links need to behave consistently across your site +and other platforms such as GitHub and npm. + +If you are [using the `rehypeHeadingIds` plugin directly](https://docs.astro.build/en/guides/markdown-content/#heading-ids-and-plugins), you can also pass this new option: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { rehypeHeadingIds } from '@astrojs/markdown-remark'; +import { otherPluginThatReliesOnHeadingIDs } from 'some/plugin/source'; + +export default defineConfig({ + markdown: { + rehypePlugins: [ + [rehypeHeadingIds, { experimentalHeadingIdCompat: true }], + otherPluginThatReliesOnHeadingIDs, + ], + }, +}); +``` diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index a24713ba3..15729f399 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -101,6 +101,7 @@ export const ASTRO_CONFIG_DEFAULTS = { svg: false, serializeConfig: false, session: false, + headingIdCompat: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -616,6 +617,10 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.serializeConfig), + headingIdCompat: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.headingIdCompat), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 4ff49cc1c..178beb370 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2166,6 +2166,20 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * These two virtual modules contain a serializable subset of the Astro configuration. */ serializeConfig?: boolean; + + /** + * @name experimental.headingIdCompat + * @type {boolean} + * @default `false` + * @version 5.5.x + * @description + * + * Enables full compatibility of Markdown headings IDs with common platforms such as GitHub and npm. + * + * When enabled, IDs for headings ending with non-alphanumeric characters, e.g. `<Picture />`, will + * include a trailing `-`, matching standard behavior in other Markdown tooling. + */ + headingIdCompat?: boolean; }; } diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 9cd080a03..753da7ded 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -62,6 +62,7 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug if (!processor) { processor = createMarkdownProcessor({ image: settings.config.image, + experimentalHeadingIdCompat: settings.config.experimental.headingIdCompat, ...settings.config.markdown, }); } diff --git a/packages/integrations/markdoc/src/content-entry-type.ts b/packages/integrations/markdoc/src/content-entry-type.ts index 998d8fbb5..a41670a7b 100644 --- a/packages/integrations/markdoc/src/content-entry-type.ts +++ b/packages/integrations/markdoc/src/content-entry-type.ts @@ -49,7 +49,11 @@ export async function getContentEntryType({ const userMarkdocConfig = markdocConfigResult?.config ?? {}; const markdocConfigUrl = markdocConfigResult?.fileUrl; const pluginContext = this; - const markdocConfig = await setupConfig(userMarkdocConfig, options); + const markdocConfig = await setupConfig( + userMarkdocConfig, + options, + astroConfig.experimental.headingIdCompat, + ); const filePath = fileURLToPath(fileUrl); raiseValidationErrors({ ast, @@ -116,6 +120,7 @@ markdocConfig.nodes = { ...assetsConfig.nodes, ...markdocConfig.nodes }; ${getStringifiedImports(componentConfigByTagMap, 'Tag', astroConfig.root)} ${getStringifiedImports(componentConfigByNodeMap, 'Node', astroConfig.root)} +const experimentalHeadingIdCompat = ${JSON.stringify(astroConfig.experimental.headingIdCompat || false)} const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')}; const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')}; @@ -126,7 +131,7 @@ const stringifiedAst = ${JSON.stringify( /* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast), )}; -export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options); +export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options, experimentalHeadingIdCompat); export const Content = createContentComponent( Renderer, stringifiedAst, @@ -134,6 +139,7 @@ export const Content = createContentComponent( options, tagComponentMap, nodeComponentMap, + experimentalHeadingIdCompat, )`; return { code: res }; }, diff --git a/packages/integrations/markdoc/src/heading-ids.ts b/packages/integrations/markdoc/src/heading-ids.ts index 9290a3db6..7242e0e16 100644 --- a/packages/integrations/markdoc/src/heading-ids.ts +++ b/packages/integrations/markdoc/src/heading-ids.ts @@ -11,6 +11,7 @@ function getSlug( attributes: Record<string, any>, children: RenderableTreeNode[], headingSlugger: Slugger, + experimentalHeadingIdCompat: boolean, ): string { if (attributes.id && typeof attributes.id === 'string') { return attributes.id; @@ -18,12 +19,14 @@ function getSlug( const textContent = attributes.content ?? getTextContent(children); let slug = headingSlugger.slug(textContent); - if (slug.endsWith('-')) slug = slug.slice(0, -1); + if (!experimentalHeadingIdCompat) { + if (slug.endsWith('-')) slug = slug.slice(0, -1); + } return slug; } type HeadingIdConfig = MarkdocConfig & { - ctx: { headingSlugger: Slugger }; + ctx: { headingSlugger: Slugger; experimentalHeadingIdCompat: boolean }; }; /* @@ -47,7 +50,12 @@ export const heading: Schema = { 'Unexpected problem adding heading IDs to Markdoc file. Did you modify the `ctx.headingSlugger` property in your Markdoc config?', }); } - const slug = getSlug(attributes, children, config.ctx.headingSlugger); + const slug = getSlug( + attributes, + children, + config.ctx.headingSlugger, + config.ctx.experimentalHeadingIdCompat, + ); const render = config.nodes?.heading?.render ?? `h${level}`; @@ -64,11 +72,12 @@ export const heading: Schema = { }; // Called internally to ensure `ctx` is generated per-file, instead of per-build. -export function setupHeadingConfig(): HeadingIdConfig { +export function setupHeadingConfig(experimentalHeadingIdCompat: boolean): HeadingIdConfig { const headingSlugger = new Slugger(); return { ctx: { headingSlugger, + experimentalHeadingIdCompat, }, nodes: { heading, diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts index f62bcec1a..44c232b79 100644 --- a/packages/integrations/markdoc/src/runtime.ts +++ b/packages/integrations/markdoc/src/runtime.ts @@ -19,8 +19,9 @@ import type { MarkdocIntegrationOptions } from './options.js'; export async function setupConfig( userConfig: AstroMarkdocConfig = {}, options: MarkdocIntegrationOptions | undefined, + experimentalHeadingIdCompat: boolean, ): Promise<MergedConfig> { - let defaultConfig: AstroMarkdocConfig = setupHeadingConfig(); + let defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat); if (userConfig.extends) { for (let extension of userConfig.extends) { @@ -45,8 +46,9 @@ export async function setupConfig( export function setupConfigSync( userConfig: AstroMarkdocConfig = {}, options: MarkdocIntegrationOptions | undefined, + experimentalHeadingIdCompat: boolean, ): MergedConfig { - const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(); + const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat); let merged = mergeConfig(defaultConfig, userConfig); @@ -168,12 +170,13 @@ export function createGetHeadings( stringifiedAst: string, userConfig: AstroMarkdocConfig, options: MarkdocIntegrationOptions | undefined, + experimentalHeadingIdCompat: boolean, ) { 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, instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */ - const config = setupConfigSync(userConfig, options); + const config = setupConfigSync(userConfig, options, experimentalHeadingIdCompat); const ast = Markdoc.Ast.fromJSON(stringifiedAst); const content = Markdoc.transform(ast as Node, config as ConfigType); let collectedHeadings: MarkdownHeading[] = []; @@ -189,12 +192,13 @@ export function createContentComponent( options: MarkdocIntegrationOptions | undefined, tagComponentMap: Record<string, AstroInstance['default']>, nodeComponentMap: Record<NodeType, AstroInstance['default']>, + experimentalHeadingIdCompat: boolean, ) { return createComponent({ async factory(result: any, props: Record<string, any>) { const withVariables = mergeConfig(userConfig, { variables: props }); const config = resolveComponentImports( - await setupConfig(withVariables, options), + await setupConfig(withVariables, options, experimentalHeadingIdCompat), tagComponentMap, nodeComponentMap, ); diff --git a/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc new file mode 100644 index 000000000..2d1801014 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc @@ -0,0 +1,3 @@ +## `<Picture />` + +### « Sacrebleu ! » diff --git a/packages/integrations/markdoc/test/headings.test.js b/packages/integrations/markdoc/test/headings.test.js index 330d3356b..1a6061aa6 100644 --- a/packages/integrations/markdoc/test/headings.test.js +++ b/packages/integrations/markdoc/test/headings.test.js @@ -9,6 +9,38 @@ async function getFixture(name) { }); } +describe('experimental.headingIdCompat', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL(`./fixtures/headings/`, import.meta.url), + experimental: { headingIdCompat: true }, + }); + }); + + describe('dev', () => { + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('applies IDs to headings containing special characters', async () => { + const res = await fixture.fetch('/headings-with-special-characters'); + const html = await res.text(); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('h2')?.id, 'picture-'); + assert.equal(document.querySelector('h3')?.id, '-sacrebleu--'); + }); + }); +}); + describe('Markdoc - Headings', () => { let fixture; @@ -35,6 +67,15 @@ describe('Markdoc - Headings', () => { idTest(document); }); + it('applies IDs to headings containing special characters', async () => { + const res = await fixture.fetch('/headings-with-special-characters'); + const html = await res.text(); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('h2')?.id, 'picture'); + assert.equal(document.querySelector('h3')?.id, '-sacrebleu-'); + }); + it('generates the same IDs for other documents with the same headings', async () => { const res = await fixture.fetch('/headings-stale-cache-check'); const html = await res.text(); diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index fd2fab8c8..fe2cbde0b 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -101,6 +101,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI Object.assign(vitePluginMdxOptions, { mdxOptions: resolvedMdxOptions, srcDir: config.srcDir, + experimentalHeadingIdCompat: config.experimental.headingIdCompat, }); // @ts-expect-error After we assign, we don't need to reference `mdxOptions` in this context anymore. // Re-assign it so that the garbage can be collected later. diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index e1640238f..c44c8fffd 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -23,12 +23,13 @@ const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); interface MdxProcessorExtraOptions { sourcemap: boolean; + experimentalHeadingIdCompat: boolean; } export function createMdxProcessor(mdxOptions: MdxOptions, extraOptions: MdxProcessorExtraOptions) { return createProcessor({ remarkPlugins: getRemarkPlugins(mdxOptions), - rehypePlugins: getRehypePlugins(mdxOptions), + rehypePlugins: getRehypePlugins(mdxOptions, extraOptions), recmaPlugins: mdxOptions.recmaPlugins, remarkRehypeOptions: mdxOptions.remarkRehype, jsxImportSource: 'astro', @@ -57,7 +58,10 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList { return remarkPlugins; } -function getRehypePlugins(mdxOptions: MdxOptions): PluggableList { +function getRehypePlugins( + mdxOptions: MdxOptions, + { experimentalHeadingIdCompat }: MdxProcessorExtraOptions, +): PluggableList { let rehypePlugins: PluggableList = [ // ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters rehypeMetaString, @@ -84,7 +88,10 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList { if (!isPerformanceBenchmark) { // getHeadings() is guaranteed by TS, so this must be included. // We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins. - rehypePlugins.push(rehypeHeadingIds, rehypeInjectHeadingsExport); + rehypePlugins.push( + [rehypeHeadingIds, { experimentalHeadingIdCompat }], + rehypeInjectHeadingsExport, + ); } rehypePlugins.push( diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts index 869c65d26..7dda9d714 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts @@ -9,6 +9,7 @@ import { safeParseFrontmatter } from './utils.js'; export interface VitePluginMdxOptions { mdxOptions: MdxOptions; srcDir: URL; + experimentalHeadingIdCompat: boolean; } // NOTE: Do not destructure `opts` as we're assigning a reference that will be mutated later @@ -61,7 +62,10 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin { // Lazily initialize the MDX processor if (!processor) { - processor = createMdxProcessor(opts.mdxOptions, { sourcemap: sourcemapEnabled }); + processor = createMdxProcessor(opts.mdxOptions, { + sourcemap: sourcemapEnabled, + experimentalHeadingIdCompat: opts.experimentalHeadingIdCompat, + }); } try { diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx b/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx index 2bf3677cf..97f1dd37e 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx +++ b/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx @@ -7,3 +7,7 @@ ### Subsection 2 ## Section 2 + +## `<Picture />` + +### « Sacrebleu ! » diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.js b/packages/integrations/mdx/test/mdx-get-headings.test.js index 4de8b0a6e..10ce965e1 100644 --- a/packages/integrations/mdx/test/mdx-get-headings.test.js +++ b/packages/integrations/mdx/test/mdx-get-headings.test.js @@ -43,6 +43,8 @@ describe('MDX getHeadings', () => { { depth: 3, slug: 'subsection-1', text: 'Subsection 1' }, { depth: 3, slug: 'subsection-2', text: 'Subsection 2' }, { depth: 2, slug: 'section-2', text: 'Section 2' }, + { depth: 2, slug: 'picture', text: '<Picture />' }, + { depth: 3, slug: '-sacrebleu-', text: '« Sacrebleu ! »' }, ]), ); }); @@ -114,6 +116,8 @@ describe('MDX heading IDs can be customized by user plugins', () => { { depth: 3, slug: '2', text: 'Subsection 1' }, { depth: 3, slug: '3', text: 'Subsection 2' }, { depth: 2, slug: '4', text: 'Section 2' }, + { depth: 2, slug: '5', text: '<Picture />' }, + { depth: 3, slug: '6', text: '« Sacrebleu ! »' }, ]), ); }); @@ -198,3 +202,50 @@ describe('MDX headings with frontmatter', () => { ); }); }); + +describe('experimental.headingIdCompat', () => { + describe('MDX getHeadings', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-get-headings/', import.meta.url), + integrations: [mdx()], + experimental: { headingIdCompat: true }, + }); + + await fixture.build(); + }); + + it('adds anchor IDs to headings', async () => { + const html = await fixture.readFile('/test/index.html'); + const { document } = parseHTML(html); + + const h2Ids = document.querySelectorAll('h2').map((el) => el?.id); + const h3Ids = document.querySelectorAll('h3').map((el) => el?.id); + assert.equal(document.querySelector('h1').id, 'heading-test'); + assert.equal(h2Ids.includes('section-1'), true); + assert.equal(h2Ids.includes('section-2'), true); + assert.equal(h2Ids.includes('picture-'), true); + assert.equal(h3Ids.includes('subsection-1'), true); + assert.equal(h3Ids.includes('subsection-2'), true); + assert.equal(h3Ids.includes('-sacrebleu--'), true); + }); + + it('generates correct getHeadings() export', async () => { + const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); + assert.equal( + JSON.stringify(headingsByPage['./test.mdx']), + JSON.stringify([ + { depth: 1, slug: 'heading-test', text: 'Heading test' }, + { depth: 2, slug: 'section-1', text: 'Section 1' }, + { depth: 3, slug: 'subsection-1', text: 'Subsection 1' }, + { depth: 3, slug: 'subsection-2', text: 'Subsection 2' }, + { depth: 2, slug: 'section-2', text: 'Section 2' }, + { depth: 2, slug: 'picture-', text: '<Picture />' }, + { depth: 3, slug: '-sacrebleu--', text: '« Sacrebleu ! »' }, + ]), + ); + }); + }); +}); diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 6d3261496..1aa713956 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -79,6 +79,7 @@ export async function createMarkdownProcessor( remarkRehype: remarkRehypeOptions = markdownConfigDefaults.remarkRehype, gfm = markdownConfigDefaults.gfm, smartypants = markdownConfigDefaults.smartypants, + experimentalHeadingIdCompat = false, } = opts ?? {}; const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins)); @@ -136,7 +137,7 @@ export async function createMarkdownProcessor( // Headings if (!isPerformanceBenchmark) { - parser.use(rehypeHeadingIds); + parser.use(rehypeHeadingIds, { experimentalHeadingIdCompat }); } // Stringify to HTML diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts index ab2113f49..24823afc3 100644 --- a/packages/markdown/remark/src/rehype-collect-headings.ts +++ b/packages/markdown/remark/src/rehype-collect-headings.ts @@ -9,7 +9,16 @@ import type { MarkdownHeading, RehypePlugin } from './types.js'; const rawNodeTypes = new Set(['text', 'raw', 'mdxTextExpression']); const codeTagNames = new Set(['code', 'pre']); -export function rehypeHeadingIds(): ReturnType<RehypePlugin> { +/** + * Rehype plugin that adds `id` attributes to headings based on their text content. + * + * @param options Optional configuration object for the plugin. + * + * @see https://docs.astro.build/en/guides/markdown-content/#heading-ids-and-plugins + */ +export function rehypeHeadingIds({ + experimentalHeadingIdCompat, +}: { experimentalHeadingIdCompat?: boolean } = {}): ReturnType<RehypePlugin> { return function (tree, file) { const headings: MarkdownHeading[] = []; const frontmatter = file.data.astro?.frontmatter; @@ -59,7 +68,9 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> { if (typeof node.properties.id !== 'string') { let slug = slugger.slug(text); - if (slug.endsWith('-')) slug = slug.slice(0, -1); + if (!experimentalHeadingIdCompat) { + if (slug.endsWith('-')) slug = slug.slice(0, -1); + } node.properties.id = slug; } diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index a3efae62d..57d7ca77d 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -69,6 +69,7 @@ export interface AstroMarkdownProcessorOptions extends AstroMarkdownOptions { domains?: string[]; remotePatterns?: RemotePattern[]; }; + experimentalHeadingIdCompat?: boolean; } export interface MarkdownProcessor { |