├── .npmrc ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── tsconfig.json ├── .editorconfig ├── vite.config.mjs ├── eslint.config.js ├── LICENSE ├── tests ├── util │ └── index.ts ├── test.plugin.spec.ts └── test.fixture.spec.ts ├── package.json ├── src └── index.ts └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ipikuka] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | dist 4 | node_modules 5 | coverage 6 | archive -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | dist 4 | node_modules 5 | coverage 6 | package-lock.json 7 | archive 8 | steps.md 9 | README.md -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "bracketSpacing": true, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "semi": true, 8 | "arrowParens": "always", 9 | "endOfLine": "lf", 10 | "printWidth": 96, 11 | "objectWrap": "preserve" 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022"], 4 | "target": "ES2022", 5 | "module": "Node16", 6 | "moduleResolution": "Node16", 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "outDir": "dist/esm", 13 | "noImplicitReturns": true 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Stop the editor from looking for .editorconfig files in the parent directories 2 | root = true 3 | 4 | [*] 5 | # Non-configurable Prettier behaviors 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | # Configurable Prettier behaviors 11 | end_of_line = lf 12 | indent_style = space 13 | indent_size = 2 14 | max_line_length = 96 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | max_line_length = off -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | /// 4 | export default defineConfig({ 5 | test: { 6 | include: ["tests/**/*.spec.ts"], 7 | coverage: { 8 | provider: "v8", 9 | reporter: [ 10 | ["lcov", { projectRoot: "./src" }], 11 | ["json", { file: "coverage.json" }], 12 | "text", 13 | ], 14 | exclude: ["tests"], 15 | thresholds: { 16 | lines: 100, 17 | functions: 100, 18 | branches: 100, 19 | statements: 100, 20 | }, 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import globals from "globals"; 4 | import vitest from "@vitest/eslint-plugin"; 5 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; 6 | 7 | export default tseslint.config( 8 | { 9 | ignores: [ 10 | ".DS_Store", 11 | ".vscode/", 12 | "archive/", 13 | "coverage/", 14 | "dist/", 15 | "node_modules/", 16 | "package-lock.json", 17 | ], 18 | }, 19 | eslint.configs.recommended, 20 | tseslint.configs.recommended, 21 | { 22 | languageOptions: { 23 | globals: { 24 | ...globals.node, 25 | }, 26 | }, 27 | }, 28 | { 29 | files: ["**/*.js"], 30 | ...tseslint.configs.disableTypeChecked, 31 | }, 32 | { 33 | name: "vitest", 34 | files: ["tests/**/*.spec.ts"], 35 | ...vitest.configs.recommended, 36 | }, 37 | eslintPluginPrettierRecommended, 38 | ); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ipikuka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [20] 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Setup Node ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | cache: npm 21 | - run: npm ci 22 | - run: npm test 23 | - run: npm run format 24 | coverage: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | node-version: [20] 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | - name: Setup Node ${{ matrix.node-version }} 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: npm 39 | - run: npm ci 40 | - run: npm run test-coverage 41 | - name: Upload coverage to Codecov 42 | uses: codecov/codecov-action@v5 43 | with: 44 | directory: ./coverage/ 45 | files: ./coverage.json 46 | fail_ci_if_error: true 47 | verbose: true 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | slug: ipikuka/remark-flexible-code-titles 50 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This is a name of the workflow 2 | name: publish to npm 3 | # Controls when the workflow will run 4 | on: 5 | # Triggers the workflow on published releases 6 | release: 7 | types: [published] 8 | # A workflow run is made up of one or more jobs, which run in parallel by default 9 | jobs: 10 | # This workflow contains a single job called "build" 11 | build: 12 | # The type of runner that the job will run on 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | id-token: write 17 | strategy: 18 | matrix: 19 | node-version: [20] 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | - name: Checkout 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Node ${{ matrix.node-version }} 27 | # Setup node environment and .npmrc file to publish to npm 28 | uses: actions/setup-node@v4 29 | with: 30 | # Node version. Run "node -v" to check the latest version 31 | node-version: ${{ matrix.node-version }} 32 | registry-url: https://registry.npmjs.org/ 33 | 34 | - name: Install dependencies 35 | run: npm ci 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | - name: Publish 41 | run: npm publish --provenance --access public 42 | 43 | env: 44 | # We need this to our NPM account 45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | -------------------------------------------------------------------------------- /tests/util/index.ts: -------------------------------------------------------------------------------- 1 | import { unified } from "unified"; 2 | import remarkParse from "remark-parse"; 3 | import gfm from "remark-gfm"; 4 | import remarkRehype from "remark-rehype"; 5 | import rehypeFormat from "rehype-format"; 6 | import rehypeStringify from "rehype-stringify"; 7 | import type { VFileCompatible, Value } from "vfile"; 8 | import type { Paragraph, Code, Text } from "mdast"; 9 | import { fromMarkdown } from "mdast-util-from-markdown"; 10 | import { gfm as gfmExt } from "micromark-extension-gfm"; 11 | import { gfmFromMarkdown } from "mdast-util-gfm"; 12 | import { find } from "unist-util-find"; 13 | 14 | import plugin, { CodeTitleOptions } from "../../src"; 15 | 16 | const compilerCreator = (options?: CodeTitleOptions) => 17 | unified() 18 | .use(remarkParse) 19 | .use(gfm) 20 | .use(plugin, options) 21 | .use(remarkRehype) 22 | .use(rehypeFormat) 23 | .use(rehypeStringify); 24 | 25 | export const process = async ( 26 | content: VFileCompatible, 27 | options?: CodeTitleOptions, 28 | ): Promise => { 29 | const vFile = await compilerCreator(options).process(content); 30 | 31 | return vFile.value; 32 | }; 33 | 34 | /** 35 | * finds the AST node (Code) and the title node after processed via plugin 36 | * @param content string ; 37 | * @returns AST node (Code) 38 | */ 39 | export const processMDAST = ( 40 | content: string, 41 | options?: CodeTitleOptions, 42 | ): 43 | | { 44 | title: string | null; 45 | _lang: string | null | undefined; 46 | _meta: string | null | undefined; 47 | } 48 | | undefined => { 49 | const tree = fromMarkdown(content, { 50 | extensions: [gfmExt()], 51 | mdastExtensions: [gfmFromMarkdown()], 52 | }); 53 | 54 | const code = find(tree, { type: "code" }); 55 | 56 | if (!code) return; 57 | 58 | // @ts-expect-error An argument for 'file' was not provided 59 | plugin(options)(tree); 60 | 61 | const code_ = find(tree, { type: "code" }); 62 | 63 | if (!code_) return; 64 | 65 | const _lang = code_.lang; 66 | const _meta = code_.meta; 67 | 68 | const titleNode = find(tree, { type: "paragraph" })?.children[0]; 69 | const title = titleNode ? (titleNode as Text).value : null; 70 | 71 | return { title, _lang, _meta }; 72 | }; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remark-flexible-code-titles", 3 | "version": "1.3.2", 4 | "description": "Remark plugin to add title or/and container for code blocks with customizable properties in markdown", 5 | "type": "module", 6 | "exports": "./dist/esm/index.js", 7 | "main": "./dist/esm/index.js", 8 | "types": "./dist/esm/index.d.ts", 9 | "scripts": { 10 | "build": "rimraf dist && tsc --build && type-coverage", 11 | "format": "npm run prettier && npm run lint", 12 | "prettier": "prettier --write .", 13 | "lint": "eslint .", 14 | "test": "vitest --watch=false", 15 | "test:watch": "vitest", 16 | "test:file": "vitest test.plugin.spec.ts", 17 | "prepack": "npm run build", 18 | "prepublishOnly": "npm run test && npm run format && npm run test-coverage", 19 | "test-coverage": "vitest run --coverage" 20 | }, 21 | "files": [ 22 | "dist/", 23 | "src/", 24 | "LICENSE", 25 | "README.md" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/ipikuka/remark-flexible-code-titles.git" 30 | }, 31 | "keywords": [ 32 | "unified", 33 | "mdast", 34 | "markdown", 35 | "MDX", 36 | "remark", 37 | "plugin", 38 | "remark plugin", 39 | "code title", 40 | "code fence title", 41 | "remark code title", 42 | "remark code titles" 43 | ], 44 | "author": "ipikuka ", 45 | "license": "MIT", 46 | "homepage": "https://github.com/ipikuka/remark-flexible-code-titles#readme", 47 | "bugs": { 48 | "url": "https://github.com/ipikuka/remark-flexible-code-titles/issues" 49 | }, 50 | "devDependencies": { 51 | "@eslint/js": "^9.38.0", 52 | "@types/node": "^24.9.2", 53 | "@vitest/coverage-v8": "^4.0.5", 54 | "@vitest/eslint-plugin": "^1.3.26", 55 | "dedent": "^1.7.0", 56 | "eslint": "^9.38.0", 57 | "eslint-config-prettier": "^10.1.8", 58 | "eslint-plugin-prettier": "^5.5.4", 59 | "globals": "^16.4.0", 60 | "mdast-util-from-markdown": "^2.0.2", 61 | "mdast-util-gfm": "^3.1.0", 62 | "micromark-extension-gfm": "^3.0.0", 63 | "prettier": "^3.6.2", 64 | "rehype-format": "^5.0.1", 65 | "rehype-stringify": "^10.0.1", 66 | "remark-gfm": "^4.0.1", 67 | "remark-parse": "^11.0.0", 68 | "remark-rehype": "^11.1.2", 69 | "rimraf": "^6.0.1", 70 | "type-coverage": "^2.29.7", 71 | "typescript": "^5.9.3", 72 | "typescript-eslint": "^8.46.2", 73 | "unified": "^11.0.5", 74 | "unist-util-find": "^3.0.0", 75 | "vfile": "^6.0.3", 76 | "vitest": "^4.0.5" 77 | }, 78 | "dependencies": { 79 | "@types/mdast": "^4.0.4", 80 | "unist-util-visit": "^5.0.0" 81 | }, 82 | "peerDependencies": { 83 | "unified": "^11" 84 | }, 85 | "sideEffects": false, 86 | "typeCoverage": { 87 | "atLeast": 100, 88 | "detail": true, 89 | "ignoreAsAssertion": true, 90 | "ignoreCatch": true, 91 | "strict": true 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { visit, type Visitor } from "unist-util-visit"; 2 | import type { Plugin, Transformer } from "unified"; 3 | import type { Paragraph, Code, Root, Data, BlockContent, Parent } from "mdast"; 4 | 5 | type Prettify = { [K in keyof T]: T[K] } & {}; 6 | 7 | type PartiallyRequired = Omit & Required>; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 10 | interface ContainerData extends Data {} 11 | 12 | interface Container extends Parent { 13 | /** 14 | * Node type of mdast Mark. 15 | */ 16 | type: "container"; 17 | /** 18 | * Children of paragraph. 19 | */ 20 | children: BlockContent[]; 21 | /** 22 | * Data associated with the mdast paragraph. 23 | */ 24 | data?: ContainerData | undefined; 25 | } 26 | 27 | declare module "mdast" { 28 | interface BlockContentMap { 29 | container: Container; 30 | } 31 | 32 | interface RootContentMap { 33 | container: Container; 34 | } 35 | } 36 | 37 | type StringOrNull = string | null; 38 | 39 | type RestrictedRecord = Record & { className?: never }; 40 | type PropertyFunction = (language?: string, title?: string) => RestrictedRecord; 41 | 42 | export type CodeTitleOptions = { 43 | title?: boolean; 44 | titleTagName?: string; 45 | titleClassName?: string; 46 | titleProperties?: PropertyFunction; 47 | container?: boolean; 48 | containerTagName?: string; 49 | containerClassName?: string; 50 | containerProperties?: PropertyFunction; 51 | handleMissingLanguageAs?: string; 52 | tokenForSpaceInTitle?: string; 53 | }; 54 | 55 | const DEFAULT_SETTINGS: CodeTitleOptions = { 56 | title: true, 57 | titleTagName: "div", 58 | titleClassName: "remark-code-title", 59 | container: true, 60 | containerTagName: "div", 61 | containerClassName: "remark-code-container", 62 | }; 63 | 64 | type PartiallyRequiredCodeTitleOptions = Prettify< 65 | PartiallyRequired< 66 | CodeTitleOptions, 67 | | "title" 68 | | "titleTagName" 69 | | "titleClassName" 70 | | "container" 71 | | "containerTagName" 72 | | "containerClassName" 73 | > 74 | >; 75 | 76 | /** 77 | * 78 | * This plugin adds a title element before the code element, if the title exists in the markdown code block; 79 | * and wraps them in a container. 80 | * 81 | * for example: 82 | * ```javascript:title.js 83 | * // some js code 84 | * ``` 85 | */ 86 | export const plugin: Plugin<[CodeTitleOptions?], Root> = (options) => { 87 | const settings = Object.assign( 88 | {}, 89 | DEFAULT_SETTINGS, 90 | options, 91 | ) as PartiallyRequiredCodeTitleOptions; 92 | 93 | /** for creating mdx elements just in case (for archive) 94 | const titleNode = { 95 | type: "mdxJsxFlowElement", 96 | name: "div", 97 | attributes: [ 98 | { 99 | type: "mdxJsxAttribute", 100 | name: "className", 101 | value: "remark-code-title", 102 | }, 103 | { type: "mdxJsxAttribute", name: "data-language", value: language }, 104 | ], 105 | children: [{ type: "text", value: title }], 106 | data: { _xdmExplicitJsx: true }, 107 | }; 108 | 109 | const containerNode = { 110 | type: "mdxJsxFlowElement", 111 | name: "div", 112 | attributes: [ 113 | { 114 | type: "mdxJsxAttribute", 115 | name: "className", 116 | value: "remark-code-container", 117 | }, 118 | ], 119 | children: [titleNode, node], 120 | data: { _xdmExplicitJsx: true }, 121 | }; 122 | */ 123 | 124 | const constructTitle = (language: string, title: string): Paragraph => { 125 | const properties = settings.titleProperties?.(language, title) ?? {}; 126 | 127 | Object.entries(properties).forEach(([k, v]) => { 128 | if ( 129 | (typeof v === "string" && v === "") || 130 | (Array.isArray(v) && (v as unknown[]).length === 0) 131 | ) { 132 | properties[k] = undefined; 133 | } 134 | 135 | if (k === "className") delete properties?.["className"]; 136 | }); 137 | 138 | return { 139 | type: "paragraph", 140 | children: [{ type: "text", value: title }], 141 | data: { 142 | hName: settings.titleTagName, 143 | hProperties: { 144 | className: [settings.titleClassName], 145 | ...(properties && { ...properties }), 146 | }, 147 | }, 148 | }; 149 | }; 150 | 151 | const constructContainer = ( 152 | children: BlockContent[], 153 | language: string, 154 | title: string, 155 | ): Container => { 156 | const properties = settings.containerProperties?.(language, title) ?? {}; 157 | 158 | Object.entries(properties).forEach(([k, v]) => { 159 | if ( 160 | (typeof v === "string" && v === "") || 161 | (Array.isArray(v) && (v as unknown[]).length === 0) 162 | ) { 163 | properties[k] = undefined; 164 | } 165 | 166 | if (k === "className") delete properties?.["className"]; 167 | }); 168 | 169 | return { 170 | type: "container", 171 | children, 172 | data: { 173 | hName: settings.containerTagName, 174 | hProperties: { 175 | className: [settings.containerClassName], 176 | ...(properties && { ...properties }), 177 | }, 178 | }, 179 | }; 180 | }; 181 | 182 | const extractLanguageAndTitle = (node: Code) => { 183 | const { lang: inputLang, meta: inputMeta } = node; 184 | 185 | if (!inputLang) { 186 | return { language: null, title: null, meta: null }; 187 | } 188 | 189 | // we know that "lang" doesn't contain a space (gfm code fencing), but "meta" may consist. 190 | 191 | let title: StringOrNull = null; 192 | let language: StringOrNull = inputLang; 193 | let meta: StringOrNull = inputMeta ?? null; 194 | 195 | // move "showLineNumbers" into meta 196 | if (/showLineNumbers/.test(language)) { 197 | language = language.replace(/showLineNumbers/, ""); 198 | 199 | meta = meta?.length ? meta + " showLineNumbers" : "showLineNumbers"; 200 | } 201 | 202 | // move line range string like {1, 3-4} into meta (it may complete or nor) 203 | if (language.includes("{")) { 204 | const idxStart = language.search("{"); 205 | const idxEnd = language.search("}"); 206 | 207 | const metaPart = 208 | idxEnd >= 0 209 | ? language.substring(idxStart, idxEnd + 1) 210 | : language.slice(idxStart, language.length); 211 | 212 | language = language.replace(metaPart, ""); 213 | 214 | meta = meta?.length ? metaPart + meta : metaPart; 215 | } 216 | 217 | // move colon+title into meta 218 | if (language.includes(":")) { 219 | const idx = language.search(":"); 220 | const metaPart = language.slice(idx, language.length); 221 | 222 | language = language.slice(0, idx); 223 | 224 | meta = meta?.length ? metaPart + " " + meta : metaPart; 225 | } 226 | 227 | // another correctness for line ranges, removing all spaces within curly braces 228 | const RE = /{([\d\s,-]+)}/g; // finds {1, 2-4} like strings for line highlighting 229 | if (meta?.length && RE.test(meta)) { 230 | meta = meta.replace(RE, function (match) { 231 | return match.replace(/ /g, ""); 232 | }); 233 | } 234 | 235 | // correct if there is a non-space character before opening curly brace "{" 236 | meta = meta?.replace(/(?<=\S)\{/, " {") ?? null; 237 | 238 | // correct if there is a non-space character after closing curly brace "}" 239 | meta = meta?.replace(/\}(?=\S)/, "} ") ?? null; 240 | 241 | if (meta?.includes(":")) { 242 | const regex = /:\s*.*?(?=[\s{]|$)/; // to find :title with colon 243 | const match = meta?.match(regex); 244 | 245 | // classic V8 coverage false negative 246 | /* v8 ignore next -- @preserve */ 247 | if (match) { 248 | const matched = match[0]; 249 | title = matched.replace(/:\s*/, ""); 250 | if (/^showLineNumbers$/i.test(title)) { 251 | title = null; 252 | meta = meta.replace(/:\s*/, ""); 253 | } else { 254 | meta = meta.replace(matched, ""); 255 | } 256 | } 257 | } 258 | 259 | // handle missing language 260 | if ( 261 | !language && 262 | settings.handleMissingLanguageAs && 263 | typeof settings.handleMissingLanguageAs === "string" 264 | ) { 265 | language = settings.handleMissingLanguageAs; 266 | } 267 | 268 | // remove if there is more spaces in meta 269 | meta = meta?.replace(/\s+/g, " ").trim() ?? null; 270 | 271 | // employ the settings.tokenForSpaceInTitle 272 | if (title && settings.tokenForSpaceInTitle) 273 | title = title.replaceAll(settings.tokenForSpaceInTitle, " "); 274 | 275 | // if the title is empty, make it null 276 | if (title?.trim() === "") title = null; 277 | 278 | // if the language is empty, make it null 279 | if (language === "") language = null; 280 | 281 | // if the meta is empty, make it null 282 | if (meta === "") meta = null; 283 | 284 | return { title, language, meta }; 285 | }; 286 | 287 | const visitor: Visitor = function (node, index, parent) { 288 | /* v8 ignore next -- @preserve */ 289 | if (!parent || typeof index === "undefined") return; 290 | 291 | const { title, language, meta } = extractLanguageAndTitle(node); 292 | 293 | // mutating the parent.children may effect the next iteration causing visit the same node "code" 294 | // so, it is important to normalize the language here, otherwise may cause infinite loop 295 | node.lang = language; 296 | node.meta = meta; 297 | 298 | let titleNode: Paragraph | undefined = undefined; 299 | let containerNode: Container | undefined = undefined; 300 | 301 | if (settings.title && title) { 302 | titleNode = constructTitle(language ?? "", title); 303 | } 304 | 305 | if (settings.container) { 306 | containerNode = constructContainer( 307 | titleNode ? [titleNode, node] : [node], 308 | language ?? "", 309 | title ?? "", 310 | ); 311 | } 312 | 313 | if (containerNode) { 314 | // 1 is for replacing the "code" with the container which consists it already 315 | parent.children.splice(index, 1, containerNode); 316 | } else if (titleNode) { 317 | // 0 is for inserting the titleNode before the "code" 318 | parent.children.splice(index, 0, titleNode); 319 | } 320 | }; 321 | 322 | const transformer: Transformer = (tree) => { 323 | visit(tree, "code", visitor); 324 | }; 325 | 326 | return transformer; 327 | }; 328 | 329 | export default plugin; 330 | -------------------------------------------------------------------------------- /tests/test.plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import dedent from "dedent"; 3 | 4 | import { type CodeTitleOptions } from "../src/index"; 5 | 6 | import { process } from "./util/index"; 7 | 8 | const handleMissingLanguage: CodeTitleOptions = { 9 | handleMissingLanguageAs: "unknown", 10 | }; 11 | 12 | const noContainer: CodeTitleOptions = { container: false }; 13 | 14 | const noTitle: CodeTitleOptions = { title: false }; 15 | 16 | const options: CodeTitleOptions = { 17 | titleTagName: "span", 18 | titleClassName: "custom-code-title", 19 | titleProperties(language, title) { 20 | return { 21 | ["data-language"]: language, 22 | title, 23 | dummy: "", // shouldn't be added 24 | empty: [], // shouldn't be added 25 | className: undefined, // shouldn't be taken account 26 | }; 27 | }, 28 | containerTagName: "section", 29 | containerClassName: "custom-code-wrapper", 30 | containerProperties(language, title) { 31 | return { 32 | ["data-language"]: language, 33 | title, 34 | dummy: "", // shouldn't be added 35 | empty: [], // shouldn't be added 36 | className: undefined, // shouldn't be taken account 37 | }; 38 | }, 39 | }; 40 | 41 | describe("remark-flexible-code-title", () => { 42 | // ****************************************** 43 | it("does nothing with code in paragraph", async () => { 44 | const input = dedent` 45 | \`Hi\` 46 | `; 47 | 48 | expect(await process(input)).toMatchInlineSnapshot(` 49 | " 50 |

Hi

51 | " 52 | `); 53 | }); 54 | 55 | // ****************************************** 56 | it("considers there is no language or title, even if no coding phrase", async () => { 57 | const input = dedent` 58 | \`\`\` 59 | \`\`\` 60 | `; 61 | 62 | expect(await process(input)).toMatchInlineSnapshot(` 63 | " 64 |
65 |
66 |
67 | " 68 | `); 69 | 70 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 71 | " 72 |
73 |
74 |
75 | " 76 | `); 77 | 78 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 79 | " 80 |
81 | " 82 | `); 83 | 84 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 85 | " 86 |
87 |
88 |
89 | " 90 | `); 91 | 92 | expect(await process(input, options)).toMatchInlineSnapshot(` 93 | " 94 |
95 |
96 |
97 | " 98 | `); 99 | }); 100 | 101 | // ****************************************** 102 | it("considers there is no language or title", async () => { 103 | const input = dedent` 104 | \`\`\` 105 | const a = 1; 106 | \`\`\` 107 | `; 108 | 109 | expect(await process(input)).toMatchInlineSnapshot(` 110 | " 111 |
112 |
const a = 1;
113 |       
114 |
115 | " 116 | `); 117 | 118 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 119 | " 120 |
121 |
const a = 1;
122 |       
123 |
124 | " 125 | `); 126 | 127 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 128 | " 129 |
const a = 1;
130 |       
131 | " 132 | `); 133 | 134 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 135 | " 136 |
137 |
const a = 1;
138 |       
139 |
140 | " 141 | `); 142 | 143 | expect(await process(input, options)).toMatchInlineSnapshot(` 144 | " 145 |
146 |
const a = 1;
147 |       
148 |
149 | " 150 | `); 151 | }); 152 | 153 | // ****************************************** 154 | it("considers there is only language, no title", async () => { 155 | const input = dedent` 156 | \`\`\`javascript 157 | const a = 1; 158 | \`\`\` 159 | `; 160 | 161 | expect(await process(input)).toMatchInlineSnapshot(` 162 | " 163 |
164 |
const a = 1;
165 |       
166 |
167 | " 168 | `); 169 | 170 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 171 | " 172 |
173 |
const a = 1;
174 |       
175 |
176 | " 177 | `); 178 | 179 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 180 | " 181 |
const a = 1;
182 |       
183 | " 184 | `); 185 | 186 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 187 | " 188 |
189 |
const a = 1;
190 |       
191 |
192 | " 193 | `); 194 | 195 | expect(await process(input, options)).toMatchInlineSnapshot(` 196 | " 197 |
198 |
const a = 1;
199 |       
200 |
201 | " 202 | `); 203 | }); 204 | 205 | // ****************************************** 206 | it("considers there is no language but only title", async () => { 207 | const input = dedent` 208 | \`\`\`:title.js 209 | const a = 1; 210 | \`\`\` 211 | `; 212 | 213 | expect(await process(input)).toMatchInlineSnapshot(` 214 | " 215 |
216 |
title.js
217 |
const a = 1;
218 |       
219 |
220 | " 221 | `); 222 | 223 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 224 | " 225 |
226 |
title.js
227 |
const a = 1;
228 |       
229 |
230 | " 231 | `); 232 | 233 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 234 | " 235 |
title.js
236 |
const a = 1;
237 |       
238 | " 239 | `); 240 | 241 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 242 | " 243 |
244 |
const a = 1;
245 |       
246 |
247 | " 248 | `); 249 | 250 | expect(await process(input, options)).toMatchInlineSnapshot(` 251 | " 252 |
title.js 253 |
const a = 1;
254 |       
255 |
256 | " 257 | `); 258 | }); 259 | 260 | // ****************************************** 261 | it("considers there is a language and a title", async () => { 262 | const input = dedent` 263 | \`\`\`javascript:title.js 264 | const a = 1; 265 | \`\`\` 266 | `; 267 | 268 | expect(await process(input)).toMatchInlineSnapshot(` 269 | " 270 |
271 |
title.js
272 |
const a = 1;
273 |       
274 |
275 | " 276 | `); 277 | 278 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 279 | " 280 |
281 |
title.js
282 |
const a = 1;
283 |       
284 |
285 | " 286 | `); 287 | 288 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 289 | " 290 |
title.js
291 |
const a = 1;
292 |       
293 | " 294 | `); 295 | 296 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 297 | " 298 |
299 |
const a = 1;
300 |       
301 |
302 | " 303 | `); 304 | 305 | expect(await process(input, options)).toMatchInlineSnapshot(` 306 | " 307 |
title.js 308 |
const a = 1;
309 |       
310 |
311 | " 312 | `); 313 | }); 314 | 315 | // ****************************************** 316 | it("considers there is no language or title when there is a syntax for line numbers", async () => { 317 | const input = dedent` 318 | \`\`\`{1,3-4} showLineNumbers 319 | const a = 1; 320 | \`\`\` 321 | `; 322 | 323 | expect(await process(input)).toMatchInlineSnapshot(` 324 | " 325 |
326 |
const a = 1;
327 |       
328 |
329 | " 330 | `); 331 | 332 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 333 | " 334 |
335 |
const a = 1;
336 |       
337 |
338 | " 339 | `); 340 | 341 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 342 | " 343 |
const a = 1;
344 |       
345 | " 346 | `); 347 | 348 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 349 | " 350 |
351 |
const a = 1;
352 |       
353 |
354 | " 355 | `); 356 | 357 | expect(await process(input, options)).toMatchInlineSnapshot(` 358 | " 359 |
360 |
const a = 1;
361 |       
362 |
363 | " 364 | `); 365 | }); 366 | 367 | // ****************************************** 368 | it("considers there is only language, no title when there is a syntax for line numbers", async () => { 369 | const input = dedent` 370 | \`\`\`javascript {1,3-4} showLineNumbers 371 | const a = 1; 372 | \`\`\` 373 | `; 374 | 375 | expect(await process(input)).toMatchInlineSnapshot(` 376 | " 377 |
378 |
const a = 1;
379 |       
380 |
381 | " 382 | `); 383 | 384 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 385 | " 386 |
387 |
const a = 1;
388 |       
389 |
390 | " 391 | `); 392 | 393 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 394 | " 395 |
const a = 1;
396 |       
397 | " 398 | `); 399 | 400 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 401 | " 402 |
403 |
const a = 1;
404 |       
405 |
406 | " 407 | `); 408 | 409 | expect(await process(input, options)).toMatchInlineSnapshot(` 410 | " 411 |
412 |
const a = 1;
413 |       
414 |
415 | " 416 | `); 417 | }); 418 | 419 | // ****************************************** 420 | it("considers there is no language but only title when there is a syntax for line numbers", async () => { 421 | const input = dedent` 422 | \`\`\`:title.js {1,3-4} showLineNumbers 423 | const a = 1; 424 | \`\`\` 425 | `; 426 | 427 | expect(await process(input)).toMatchInlineSnapshot(` 428 | " 429 |
430 |
title.js
431 |
const a = 1;
432 |       
433 |
434 | " 435 | `); 436 | 437 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 438 | " 439 |
440 |
title.js
441 |
const a = 1;
442 |       
443 |
444 | " 445 | `); 446 | 447 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 448 | " 449 |
title.js
450 |
const a = 1;
451 |       
452 | " 453 | `); 454 | 455 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 456 | " 457 |
458 |
const a = 1;
459 |       
460 |
461 | " 462 | `); 463 | 464 | expect(await process(input, options)).toMatchInlineSnapshot(` 465 | " 466 |
title.js 467 |
const a = 1;
468 |       
469 |
470 | " 471 | `); 472 | }); 473 | 474 | // ****************************************** 475 | it("considers there is a language and a title when there is a syntax for line numbers", async () => { 476 | const input = dedent` 477 | \`\`\`javascript:title.js {1,3-4} showLineNumbers 478 | const a = 1; 479 | \`\`\` 480 | `; 481 | 482 | expect(await process(input)).toMatchInlineSnapshot(` 483 | " 484 |
485 |
title.js
486 |
const a = 1;
487 |       
488 |
489 | " 490 | `); 491 | 492 | expect(await process(input, handleMissingLanguage)).toMatchInlineSnapshot(` 493 | " 494 |
495 |
title.js
496 |
const a = 1;
497 |       
498 |
499 | " 500 | `); 501 | 502 | expect(await process(input, noContainer)).toMatchInlineSnapshot(` 503 | " 504 |
title.js
505 |
const a = 1;
506 |       
507 | " 508 | `); 509 | 510 | expect(await process(input, noTitle)).toMatchInlineSnapshot(` 511 | " 512 |
513 |
const a = 1;
514 |       
515 |
516 | " 517 | `); 518 | 519 | expect(await process(input, options)).toMatchInlineSnapshot(` 520 | " 521 |
title.js 522 |
const a = 1;
523 |       
524 |
525 | " 526 | `); 527 | }); 528 | }); 529 | -------------------------------------------------------------------------------- /tests/test.fixture.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from "vitest"; 2 | import dedent from "dedent"; 3 | 4 | import { processMDAST } from "./util/index"; 5 | 6 | const fixture = dedent` 7 | : 8 | 9 | : 10 | : 11 | js 12 | js 13 | js: 14 | js: 15 | js : 16 | js : 17 | js:title 18 | js :title 19 | js: title 20 | js : title 21 | js{1,2} 22 | js {1,2} 23 | js { 1, 2 } 24 | js{ 1, 2} 25 | js{1 ,2} 26 | js{1 , 2 } 27 | js:title{1,2} 28 | js:title{ 1,2} 29 | js:title{1, 2 } 30 | js:title {1,2} 31 | js:title {1, 2 } 32 | js:title { 1, 2 } 33 | js :title{1,2} 34 | js :title{1 ,2 } 35 | js :title {1,2} 36 | js :title { 1 , 2 } 37 | js: title{1,2} 38 | js: title{1 ,2 } 39 | js: title {1,2} 40 | js: title { 1 , 2 } 41 | js : title{1,2} 42 | js : title{1 ,2 } 43 | js : title {1,2} 44 | js : title { 1 , 2 } 45 | js:title{1,2}showLineNumbers 46 | js:title{1,2} showLineNumbers 47 | js:title {1,2} showLineNumbers 48 | js:title { 1, 2} showLineNumbers 49 | js:title {1, 2}showLineNumbers 50 | js:{1,2}showLineNumbers 51 | js:{1,2} showLineNumbers 52 | js: {1,2} showLineNumbers 53 | js: { 1, 2} showLineNumbers 54 | js: {1, 2}showLineNumbers 55 | js :{1,2}showLineNumbers 56 | js :{1,2} showLineNumbers 57 | js : {1,2} showLineNumbers 58 | js : { 1, 2} showLineNumbers 59 | js : {1, 2}showLineNumbers 60 | js{1,2}showLineNumbers 61 | js{1,2} showLineNumbers 62 | js {1,2} showLineNumbers 63 | js { 1, 2} showLineNumbers 64 | js {1, 2}showLineNumbers 65 | js:showLineNumbers{1,2} 66 | js: showLineNumbers{1,2} 67 | js :showLineNumbers{1,2} 68 | js : showLineNumbers{1,2} 69 | js showLineNumbers {1,2} 70 | js showLineNumbers {1 , 2 } 71 | js:title showLineNumbers {1 , 2 } 72 | js :title showLineNumbers {1 , 2 } 73 | js : title showLineNumbers {1 , 2 } 74 | js :showLineNumbers {1 , 2 } 75 | js : showLineNumbers {1 , 2 } 76 | showLineNumbers{1,2} 77 | showLineNumbers {1 , 2 } 78 | {1,2}showLineNumbers 79 | {1 , 2}showLineNumbers 80 | showLineNumbers{1,2}:title 81 | showLineNumbers{1,2} : title 82 | showLineNumbers {1 , 2 }:title 83 | showLineNumbers {1 , 2 } :title 84 | {1,2}showLineNumbers:title 85 | {1,2}showLineNumbers : title 86 | {1 , 2}showLineNumbers:title 87 | {1, 2}showLineNumbers : title 88 | js:long@title@with@space{1,2}showLineNumbers 89 | js:long@title@with@space {1,2} showLineNumbers 90 | `; 91 | 92 | it("parses the language, title and meta correctly", async () => { 93 | const result = fixture.split("\n").map((input) => ({ 94 | _____: input, 95 | ...processMDAST("```" + input + "\n```", { 96 | tokenForSpaceInTitle: "@", 97 | }), 98 | })); 99 | 100 | expect(result).toMatchInlineSnapshot(` 101 | [ 102 | { 103 | "_____": ":", 104 | "_lang": null, 105 | "_meta": null, 106 | "title": null, 107 | }, 108 | { 109 | "_____": "", 110 | "_lang": null, 111 | "_meta": null, 112 | "title": null, 113 | }, 114 | { 115 | "_____": " :", 116 | "_lang": null, 117 | "_meta": null, 118 | "title": null, 119 | }, 120 | { 121 | "_____": " :", 122 | "_lang": null, 123 | "_meta": null, 124 | "title": null, 125 | }, 126 | { 127 | "_____": "js", 128 | "_lang": "js", 129 | "_meta": null, 130 | "title": null, 131 | }, 132 | { 133 | "_____": " js", 134 | "_lang": "js", 135 | "_meta": null, 136 | "title": null, 137 | }, 138 | { 139 | "_____": "js:", 140 | "_lang": "js", 141 | "_meta": null, 142 | "title": null, 143 | }, 144 | { 145 | "_____": " js:", 146 | "_lang": "js", 147 | "_meta": null, 148 | "title": null, 149 | }, 150 | { 151 | "_____": "js :", 152 | "_lang": "js", 153 | "_meta": null, 154 | "title": null, 155 | }, 156 | { 157 | "_____": " js :", 158 | "_lang": "js", 159 | "_meta": null, 160 | "title": null, 161 | }, 162 | { 163 | "_____": "js:title", 164 | "_lang": "js", 165 | "_meta": null, 166 | "title": "title", 167 | }, 168 | { 169 | "_____": "js :title", 170 | "_lang": "js", 171 | "_meta": null, 172 | "title": "title", 173 | }, 174 | { 175 | "_____": "js: title", 176 | "_lang": "js", 177 | "_meta": null, 178 | "title": "title", 179 | }, 180 | { 181 | "_____": "js : title", 182 | "_lang": "js", 183 | "_meta": null, 184 | "title": "title", 185 | }, 186 | { 187 | "_____": "js{1,2}", 188 | "_lang": "js", 189 | "_meta": "{1,2}", 190 | "title": null, 191 | }, 192 | { 193 | "_____": "js {1,2}", 194 | "_lang": "js", 195 | "_meta": "{1,2}", 196 | "title": null, 197 | }, 198 | { 199 | "_____": "js { 1, 2 }", 200 | "_lang": "js", 201 | "_meta": "{1,2}", 202 | "title": null, 203 | }, 204 | { 205 | "_____": "js{ 1, 2}", 206 | "_lang": "js", 207 | "_meta": "{1,2}", 208 | "title": null, 209 | }, 210 | { 211 | "_____": "js{1 ,2}", 212 | "_lang": "js", 213 | "_meta": "{1,2}", 214 | "title": null, 215 | }, 216 | { 217 | "_____": "js{1 , 2 }", 218 | "_lang": "js", 219 | "_meta": "{1,2}", 220 | "title": null, 221 | }, 222 | { 223 | "_____": "js:title{1,2}", 224 | "_lang": "js", 225 | "_meta": "{1,2}", 226 | "title": "title", 227 | }, 228 | { 229 | "_____": "js:title{ 1,2}", 230 | "_lang": "js", 231 | "_meta": "{1,2}", 232 | "title": "title", 233 | }, 234 | { 235 | "_____": "js:title{1, 2 }", 236 | "_lang": "js", 237 | "_meta": "{1,2}", 238 | "title": "title", 239 | }, 240 | { 241 | "_____": "js:title {1,2}", 242 | "_lang": "js", 243 | "_meta": "{1,2}", 244 | "title": "title", 245 | }, 246 | { 247 | "_____": "js:title {1, 2 }", 248 | "_lang": "js", 249 | "_meta": "{1,2}", 250 | "title": "title", 251 | }, 252 | { 253 | "_____": "js:title { 1, 2 }", 254 | "_lang": "js", 255 | "_meta": "{1,2}", 256 | "title": "title", 257 | }, 258 | { 259 | "_____": "js :title{1,2}", 260 | "_lang": "js", 261 | "_meta": "{1,2}", 262 | "title": "title", 263 | }, 264 | { 265 | "_____": "js :title{1 ,2 }", 266 | "_lang": "js", 267 | "_meta": "{1,2}", 268 | "title": "title", 269 | }, 270 | { 271 | "_____": "js :title {1,2}", 272 | "_lang": "js", 273 | "_meta": "{1,2}", 274 | "title": "title", 275 | }, 276 | { 277 | "_____": "js :title { 1 , 2 }", 278 | "_lang": "js", 279 | "_meta": "{1,2}", 280 | "title": "title", 281 | }, 282 | { 283 | "_____": "js: title{1,2}", 284 | "_lang": "js", 285 | "_meta": "{1,2}", 286 | "title": "title", 287 | }, 288 | { 289 | "_____": "js: title{1 ,2 }", 290 | "_lang": "js", 291 | "_meta": "{1,2}", 292 | "title": "title", 293 | }, 294 | { 295 | "_____": "js: title {1,2}", 296 | "_lang": "js", 297 | "_meta": "{1,2}", 298 | "title": "title", 299 | }, 300 | { 301 | "_____": "js: title { 1 , 2 }", 302 | "_lang": "js", 303 | "_meta": "{1,2}", 304 | "title": "title", 305 | }, 306 | { 307 | "_____": "js : title{1,2}", 308 | "_lang": "js", 309 | "_meta": "{1,2}", 310 | "title": "title", 311 | }, 312 | { 313 | "_____": "js : title{1 ,2 }", 314 | "_lang": "js", 315 | "_meta": "{1,2}", 316 | "title": "title", 317 | }, 318 | { 319 | "_____": "js : title {1,2}", 320 | "_lang": "js", 321 | "_meta": "{1,2}", 322 | "title": "title", 323 | }, 324 | { 325 | "_____": "js : title { 1 , 2 }", 326 | "_lang": "js", 327 | "_meta": "{1,2}", 328 | "title": "title", 329 | }, 330 | { 331 | "_____": "js:title{1,2}showLineNumbers", 332 | "_lang": "js", 333 | "_meta": "{1,2} showLineNumbers", 334 | "title": "title", 335 | }, 336 | { 337 | "_____": "js:title{1,2} showLineNumbers", 338 | "_lang": "js", 339 | "_meta": "{1,2} showLineNumbers", 340 | "title": "title", 341 | }, 342 | { 343 | "_____": "js:title {1,2} showLineNumbers", 344 | "_lang": "js", 345 | "_meta": "{1,2} showLineNumbers", 346 | "title": "title", 347 | }, 348 | { 349 | "_____": "js:title { 1, 2} showLineNumbers", 350 | "_lang": "js", 351 | "_meta": "{1,2} showLineNumbers", 352 | "title": "title", 353 | }, 354 | { 355 | "_____": "js:title {1, 2}showLineNumbers", 356 | "_lang": "js", 357 | "_meta": "{1,2} showLineNumbers", 358 | "title": "title", 359 | }, 360 | { 361 | "_____": "js:{1,2}showLineNumbers", 362 | "_lang": "js", 363 | "_meta": "{1,2} showLineNumbers", 364 | "title": null, 365 | }, 366 | { 367 | "_____": "js:{1,2} showLineNumbers", 368 | "_lang": "js", 369 | "_meta": "{1,2} showLineNumbers", 370 | "title": null, 371 | }, 372 | { 373 | "_____": "js: {1,2} showLineNumbers", 374 | "_lang": "js", 375 | "_meta": "{1,2} showLineNumbers", 376 | "title": null, 377 | }, 378 | { 379 | "_____": "js: { 1, 2} showLineNumbers", 380 | "_lang": "js", 381 | "_meta": "{1,2} showLineNumbers", 382 | "title": null, 383 | }, 384 | { 385 | "_____": "js: {1, 2}showLineNumbers", 386 | "_lang": "js", 387 | "_meta": "{1,2} showLineNumbers", 388 | "title": null, 389 | }, 390 | { 391 | "_____": "js :{1,2}showLineNumbers", 392 | "_lang": "js", 393 | "_meta": "{1,2} showLineNumbers", 394 | "title": null, 395 | }, 396 | { 397 | "_____": "js :{1,2} showLineNumbers", 398 | "_lang": "js", 399 | "_meta": "{1,2} showLineNumbers", 400 | "title": null, 401 | }, 402 | { 403 | "_____": "js : {1,2} showLineNumbers", 404 | "_lang": "js", 405 | "_meta": "{1,2} showLineNumbers", 406 | "title": null, 407 | }, 408 | { 409 | "_____": "js : { 1, 2} showLineNumbers", 410 | "_lang": "js", 411 | "_meta": "{1,2} showLineNumbers", 412 | "title": null, 413 | }, 414 | { 415 | "_____": "js : {1, 2}showLineNumbers", 416 | "_lang": "js", 417 | "_meta": "{1,2} showLineNumbers", 418 | "title": null, 419 | }, 420 | { 421 | "_____": "js{1,2}showLineNumbers", 422 | "_lang": "js", 423 | "_meta": "{1,2} showLineNumbers", 424 | "title": null, 425 | }, 426 | { 427 | "_____": "js{1,2} showLineNumbers", 428 | "_lang": "js", 429 | "_meta": "{1,2} showLineNumbers", 430 | "title": null, 431 | }, 432 | { 433 | "_____": "js {1,2} showLineNumbers", 434 | "_lang": "js", 435 | "_meta": "{1,2} showLineNumbers", 436 | "title": null, 437 | }, 438 | { 439 | "_____": "js { 1, 2} showLineNumbers", 440 | "_lang": "js", 441 | "_meta": "{1,2} showLineNumbers", 442 | "title": null, 443 | }, 444 | { 445 | "_____": "js {1, 2}showLineNumbers", 446 | "_lang": "js", 447 | "_meta": "{1,2} showLineNumbers", 448 | "title": null, 449 | }, 450 | { 451 | "_____": "js:showLineNumbers{1,2}", 452 | "_lang": "js", 453 | "_meta": "{1,2} showLineNumbers", 454 | "title": null, 455 | }, 456 | { 457 | "_____": "js: showLineNumbers{1,2}", 458 | "_lang": "js", 459 | "_meta": "showLineNumbers {1,2}", 460 | "title": null, 461 | }, 462 | { 463 | "_____": "js :showLineNumbers{1,2}", 464 | "_lang": "js", 465 | "_meta": "showLineNumbers {1,2}", 466 | "title": null, 467 | }, 468 | { 469 | "_____": "js : showLineNumbers{1,2}", 470 | "_lang": "js", 471 | "_meta": "showLineNumbers {1,2}", 472 | "title": null, 473 | }, 474 | { 475 | "_____": "js showLineNumbers {1,2}", 476 | "_lang": "js", 477 | "_meta": "showLineNumbers {1,2}", 478 | "title": null, 479 | }, 480 | { 481 | "_____": "js showLineNumbers {1 , 2 }", 482 | "_lang": "js", 483 | "_meta": "showLineNumbers {1,2}", 484 | "title": null, 485 | }, 486 | { 487 | "_____": "js:title showLineNumbers {1 , 2 }", 488 | "_lang": "js", 489 | "_meta": "showLineNumbers {1,2}", 490 | "title": "title", 491 | }, 492 | { 493 | "_____": "js :title showLineNumbers {1 , 2 }", 494 | "_lang": "js", 495 | "_meta": "showLineNumbers {1,2}", 496 | "title": "title", 497 | }, 498 | { 499 | "_____": "js : title showLineNumbers {1 , 2 }", 500 | "_lang": "js", 501 | "_meta": "showLineNumbers {1,2}", 502 | "title": "title", 503 | }, 504 | { 505 | "_____": "js :showLineNumbers {1 , 2 }", 506 | "_lang": "js", 507 | "_meta": "showLineNumbers {1,2}", 508 | "title": null, 509 | }, 510 | { 511 | "_____": "js : showLineNumbers {1 , 2 }", 512 | "_lang": "js", 513 | "_meta": "showLineNumbers {1,2}", 514 | "title": null, 515 | }, 516 | { 517 | "_____": "showLineNumbers{1,2}", 518 | "_lang": null, 519 | "_meta": "{1,2} showLineNumbers", 520 | "title": null, 521 | }, 522 | { 523 | "_____": "showLineNumbers {1 , 2 }", 524 | "_lang": null, 525 | "_meta": "{1,2} showLineNumbers", 526 | "title": null, 527 | }, 528 | { 529 | "_____": "{1,2}showLineNumbers", 530 | "_lang": null, 531 | "_meta": "{1,2} showLineNumbers", 532 | "title": null, 533 | }, 534 | { 535 | "_____": "{1 , 2}showLineNumbers", 536 | "_lang": null, 537 | "_meta": "{1,2} showLineNumbers", 538 | "title": null, 539 | }, 540 | { 541 | "_____": "showLineNumbers{1,2}:title", 542 | "_lang": null, 543 | "_meta": "{1,2} showLineNumbers", 544 | "title": "title", 545 | }, 546 | { 547 | "_____": "showLineNumbers{1,2} : title", 548 | "_lang": null, 549 | "_meta": "{1,2} showLineNumbers", 550 | "title": "title", 551 | }, 552 | { 553 | "_____": "showLineNumbers {1 , 2 }:title", 554 | "_lang": null, 555 | "_meta": "{1,2} showLineNumbers", 556 | "title": "title", 557 | }, 558 | { 559 | "_____": "showLineNumbers {1 , 2 } :title", 560 | "_lang": null, 561 | "_meta": "{1,2} showLineNumbers", 562 | "title": "title", 563 | }, 564 | { 565 | "_____": "{1,2}showLineNumbers:title", 566 | "_lang": null, 567 | "_meta": "{1,2} showLineNumbers", 568 | "title": "title", 569 | }, 570 | { 571 | "_____": "{1,2}showLineNumbers : title", 572 | "_lang": null, 573 | "_meta": "{1,2} showLineNumbers", 574 | "title": "title", 575 | }, 576 | { 577 | "_____": "{1 , 2}showLineNumbers:title", 578 | "_lang": null, 579 | "_meta": "{1,2} showLineNumbers", 580 | "title": "title", 581 | }, 582 | { 583 | "_____": "{1, 2}showLineNumbers : title", 584 | "_lang": null, 585 | "_meta": "{1,2} showLineNumbers", 586 | "title": "title", 587 | }, 588 | { 589 | "_____": "js:long@title@with@space{1,2}showLineNumbers", 590 | "_lang": "js", 591 | "_meta": "{1,2} showLineNumbers", 592 | "title": "long title with space", 593 | }, 594 | { 595 | "_____": "js:long@title@with@space {1,2} showLineNumbers", 596 | "_lang": "js", 597 | "_meta": "{1,2} showLineNumbers", 598 | "title": "long title with space", 599 | }, 600 | ] 601 | `); 602 | }); 603 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### [Become a sponsor](https://github.com/sponsors/ipikuka) 🚀 2 | 3 | If you find **`remark-flexible-code-titles`** useful in your projects, consider supporting my work. 4 | Your sponsorship means a lot 💖 5 | 6 | Be the **first sponsor** and get featured here and on [my sponsor wall](https://github.com/sponsors/ipikuka). 7 | Thank you for supporting open source! 🙌 8 | 9 | # remark-flexible-code-titles 10 | 11 | [![npm version][badge-npm-version]][url-npm-package] 12 | [![npm downloads][badge-npm-download]][url-npm-package] 13 | [![publish to npm][badge-publish-to-npm]][url-publish-github-actions] 14 | [![code-coverage][badge-codecov]][url-codecov] 15 | [![type-coverage][badge-type-coverage]][url-github-package] 16 | [![typescript][badge-typescript]][url-typescript] 17 | [![license][badge-license]][url-license] 18 | 19 | This package is a [**unified**][unified] ([**remark**][remark]) plugin **to add title or/and container for code blocks with customizable properties in markdown.** 20 | 21 | [**unified**][unified] is a project that transforms content with abstract syntax trees (ASTs) using the new parser [**micromark**][micromark]. [**remark**][remark] adds support for markdown to unified. [**mdast**][mdast] is the Markdown Abstract Syntax Tree (AST) which is a specification for representing markdown in a syntax tree. 22 | 23 | **This plugin is a remark plugin that transforms the mdast.** 24 | 25 | ## When should I use this? 26 | 27 | **`remark-flexible-code-titles`** is useful if you want to **add title and container or any of two** for code blocks in markdown. It is able to: 28 | 29 | - add `title` node above the `code` node, providing _custom tag name, custom class name and also additional properties_. 30 | - add `container` node for the `code` node, providing _custom tag name, custom class name and also additional properties_. 31 | - correct the syntax of code highligting directives on behalf of related rehype plugins (like [rehype-prism-plus][rehypeprismplus]) 32 | - handle the titles even if there is no language provided, 33 | - handle the titles composed by more than one word (handle spaces in the title), 34 | - provide a fallback language as an option if the language is missing. 35 | 36 | ## Installation 37 | 38 | This package is suitable for ESM only. In Node.js (16.0+), install with npm: 39 | 40 | ```bash 41 | npm install remark-flexible-code-titles 42 | ``` 43 | 44 | or 45 | 46 | ```bash 47 | yarn add remark-flexible-code-titles 48 | ``` 49 | 50 | ## Usage 51 | 52 | Say we have the following file, `example.md`, which consists a code block. The code block's language is "javascript" and its title is "file.js" specified _after a colon_ **`:`** 53 | 54 | ````markdown 55 | ```javascript:file.js 56 | 57 | ``` 58 | ```` 59 | 60 | And our module, `example.js`, looks as follows: 61 | 62 | ```javascript 63 | import { read } from "to-vfile"; 64 | import remark from "remark"; 65 | import gfm from "remark-gfm"; 66 | import remarkRehype from "remark-rehype"; 67 | import rehypeStringify from "rehype-stringify"; 68 | import remarkCodeTitles from "remark-flexible-code-titles"; 69 | 70 | main(); 71 | 72 | async function main() { 73 | const file = await remark() 74 | .use(gfm) 75 | .use(remarkCodeTitles) 76 | .use(remarkRehype) 77 | .use(rehypeStringify) 78 | .process(await read("example.md")); 79 | 80 | console.log(String(file)); 81 | } 82 | ``` 83 | 84 | Now, running `node example.js` yields: 85 | 86 | ```html 87 |
88 |
file.js
89 |
 90 |     
 91 |       
 92 |      
 93 |   
94 |
95 | ``` 96 | 97 | Without **`remark-flexible-code-titles`**, you’d get: 98 | 99 | ```html 100 |
101 |   
102 |     
103 |    
104 | 
105 | ``` 106 | 107 | You can use **`remark-flexible-code-titles`** even **without a language**, _setting the title just after a colon_ **`:`** 108 | 109 | ````markdown 110 | ```:title 111 | This is a line of pseudo code. 112 | ``` 113 | ```` 114 | 115 | ## Options 116 | 117 | All options are **optional** and some of them have **default values**. 118 | 119 | ```tsx 120 | type RestrictedRecord = Record & { className?: never }; 121 | type PropertyFunction = (language?: string, title?: string) => RestrictedRecord; 122 | 123 | use(remarkCodeTitles, { 124 | title?: boolean; // default is true 125 | titleTagName?: string; // default is "div" 126 | titleClassName?: string; // default is "remark-code-title" 127 | titleProperties?: PropertyFunction; 128 | container?: boolean; // default is true 129 | containerTagName?: string; // default is "div" 130 | containerClassName?: string; // default is "remark-code-container" 131 | containerProperties?: PropertyFunction; 132 | handleMissingLanguageAs?: string; 133 | tokenForSpaceInTitle?: string; 134 | } as CodeTitleOptions); 135 | ``` 136 | 137 | #### `title` 138 | 139 | It is a **boolean** option for whether or not to add a `title` node. 140 | 141 | By default, it is `true`, meaningly adds a `title` node if a title is provided in the language part of the code block. 142 | 143 | ```javascript 144 | use(remarkCodeTitles, { 145 | title: false, 146 | }); 147 | ``` 148 | 149 | If the option is `false`, the plugin will not add any `title` node. 150 | 151 | ````markdown 152 | ```javascript:file.js 153 | console.log("Hi") 154 | ``` 155 | ```` 156 | 157 | ```html 158 |
159 | 160 |
161 |     console.log("Hi")
162 |   
163 | 
164 | ``` 165 | 166 | #### `titleTagName` 167 | 168 | It is a **string** option for providing custom HTML tag name for `title` nodes. 169 | 170 | By default, it is `div`. 171 | 172 | ```javascript 173 | use(remarkCodeTitles, { 174 | titleTagName: "span", 175 | }); 176 | ``` 177 | 178 | Now, the title element tag names will be `span`. 179 | 180 | ````markdown 181 | ```javascript:file.js 182 | console.log("Hi") 183 | ``` 184 | ```` 185 | 186 | ```html 187 |
188 | file.js 189 |
190 |     console.log("Hi")
191 |   
192 | 
193 | ``` 194 | 195 | #### `titleClassName` 196 | 197 | It is a **string** option for providing custom class name for `title` nodes. 198 | 199 | By default, it is `remark-code-title`, and all title elements' class names will contain `remark-code-title`. 200 | 201 | ```javascript 202 | use(remarkCodeTitles, { 203 | titleClassName: "custom-code-title", 204 | }); 205 | ``` 206 | 207 | Now, the title element class names will be `custom-code-title`. 208 | 209 | ````markdown 210 | ```javascript:file.js 211 | console.log("Hi") 212 | ``` 213 | ```` 214 | 215 | ```html 216 |
217 |
file.js
218 |
219 |     console.log("Hi")
220 |   
221 | 
222 | ``` 223 | 224 | #### `titleProperties` 225 | 226 | It is a **callback** `(language?: string, title?: string) => Record & { className?: never }` option to set additional properties for the `title` node. 227 | 228 | The callback function that takes the `language` and the `title` as optional arguments and returns **object** which is going to be used for adding additional properties into the `title` node. 229 | 230 | **The `className` key is forbidden and effectless in the returned object.** 231 | 232 | ```javascript 233 | use(remarkCodeTitles, { 234 | titleProperties(language, title) { 235 | return { 236 | title, 237 | ["data-language"]: language, 238 | }; 239 | }, 240 | }); 241 | ``` 242 | 243 | Now, the title elements will contain `title` and `data-color` properties. 244 | 245 | ````markdown 246 | ```javascript:file.js 247 | console.log("Hi") 248 | ``` 249 | ```` 250 | 251 | ```html 252 |
253 |
file.js
254 |
255 |     console.log("Hi")
256 |   
257 | 
258 | ``` 259 | 260 | #### `container` 261 | 262 | It is a **boolean** option for whether or not to add a `container` node. 263 | 264 | By default, it is `true`, meaningly adds a `container` node. 265 | 266 | ```javascript 267 | use(remarkCodeTitles, { 268 | container: false, 269 | }); 270 | ``` 271 | 272 | If the option is `false`, the plugin doesn't add any `container` node. 273 | 274 | ````markdown 275 | ```javascript:file.js 276 | console.log("Hi") 277 | ``` 278 | ```` 279 | 280 | ```html 281 | 282 |
file.js
283 |
284 |   console.log("Hi")
285 | 
286 | 
287 | ```
288 | 
289 | #### `containerTagName`
290 | 
291 | It is a **string** option for providing custom HTML tag name for `container` nodes.
292 | 
293 | By default, it is `div`.
294 | 
295 | ```javascript
296 | use(remarkCodeTitles, {
297 |   containerTagName: "section",
298 | });
299 | ```
300 | 
301 | Now, the container element tag names will be `section`.
302 | 
303 | ````markdown
304 | ```javascript:file.js
305 | console.log("Hi")
306 | ```
307 | ````
308 | 
309 | ```html
310 | 
311 |
file.js
312 |
313 |     console.log("Hi")
314 |   
315 | 
316 | ```
317 | 
318 | #### `containerClassName`
319 | 
320 | It is a **string** option for providing custom class name for `container` nodes.
321 | 
322 | By default, it is `remark-code-container`, and all container elements' class names will contain `remark-code-container`.
323 | 
324 | ```javascript
325 | use(remarkCodeTitles, {
326 |   containerClassName: "custom-code-container",
327 | });
328 | ```
329 | 
330 | Now, the container element class names will be `custom-code-container`.
331 | 
332 | ````markdown
333 | ```javascript:file.js
334 | console.log("Hi")
335 | ```
336 | ````
337 | 
338 | ```html
339 | 
340 |
file.js
341 |
342 |     console.log("Hi")
343 |   
344 | 
345 | ``` 346 | 347 | #### `containerProperties` 348 | 349 | It is a **callback** `(language?: string, title?: string) => Record & { className?: never }` option to set additional properties for the `container` node. 350 | 351 | The callback function that takes the `language` and the `title` as optional arguments and returns **object** which is going to be used for adding additional properties into the `container` node. 352 | 353 | **The `className` key is forbidden and effectless in the returned object.** 354 | 355 | ```javascript 356 | use(remarkCodeTitles, { 357 | titleProperties(language, title) { 358 | return { 359 | title, 360 | ["data-language"]: language, 361 | }; 362 | }, 363 | }); 364 | ``` 365 | 366 | Now, the container elements will contain `title` and `data-color` properties. 367 | 368 | ````markdown 369 | ```javascript:file.js 370 | console.log("Hi") 371 | ``` 372 | ```` 373 | 374 | ```html 375 |
376 |
file.js
377 |
378 |     console.log("Hi")
379 |   
380 | 
381 | ``` 382 | 383 | #### `handleMissingLanguageAs` 384 | 385 | It is a **string** option for providing a fallback language if the language is missing. 386 | 387 | ```javascript 388 | use(remarkCodeTitles, { 389 | handleMissingLanguageAs: "unknown", 390 | }); 391 | ``` 392 | 393 | Now, the class name of `` elements will contain `language-unknown` if the language is missing. If this option was not set, the `class` property would not be presented in the ``element. 394 | 395 | ````markdown 396 | ``` 397 | Hello from code block 398 | ``` 399 | ```` 400 | 401 | ```html 402 |
403 |
404 |     Hello from code block
405 |   
406 | 
407 | ``` 408 | 409 | #### `tokenForSpaceInTitle` 410 | 411 | It is a **string** option for composing the title with more than one word. 412 | 413 | Normally, **`remark-flexible-code-titles`** can match a code title which is the word that comes after a colon and ends in the first space it encounters. This option is provided to replace a space with a token in order to specify a code title consisting of more than one word. 414 | 415 | ```javascript 416 | use(remarkCodeTitles, { 417 | tokenForSpaceInTitle: "@", 418 | }); 419 | ``` 420 | 421 | Now, the titles that have more than one word can be set using the token `@`. 422 | 423 | ````markdown 424 | ```bash:Useful@Bash@Commands 425 | mkdir project-directory 426 | ``` 427 | ```` 428 | 429 | ```html 430 |
431 |
Useful Bash Commands
432 |
433 |     mkdir project-directory
434 |   
435 | 
436 | ``` 437 | 438 | ## Examples: 439 | 440 | #### Example for only container 441 | 442 | ````markdown 443 | ```javascript:file.js 444 | let me = "ipikuka"; 445 | ``` 446 | ```` 447 | 448 | ```javascript 449 | use(remarkCodeTitles, { 450 | title: false, 451 | containerTagName: "section", 452 | containerClassName: "custom-code-wrapper", 453 | containerProperties(language, title) { 454 | return { 455 | ["data-language"]: language, 456 | title, 457 | }; 458 | }, 459 | }); 460 | ``` 461 | 462 | is going to produce the container `section` element like below: 463 | 464 | ```html 465 |
466 |
467 |     let me = "ipikuka";
468 |   
469 |
470 | ``` 471 | 472 | #### Example for only title 473 | 474 | ````markdown 475 | ```javascript:file.js 476 | let me = "ipikuka"; 477 | ``` 478 | ```` 479 | 480 | ```javascript 481 | use(remarkCodeTitles, { 482 | container: false, 483 | titleTagName: "span", 484 | titleClassName: "custom-code-title", 485 | titleProperties: (language, title) => { 486 | ["data-language"]: language, 487 | title, 488 | }, 489 | }); 490 | ``` 491 | 492 | is going to produce the title `span` element just before the code block, like below: 493 | 494 | ```html 495 | file.js 496 |
497 |   let me = "ipikuka";
498 | 
499 | ``` 500 | 501 | #### Example for line highlighting and numbering 502 | 503 | > [!NOTE] 504 | > You need a rehype plugin like [**rehype-prism-plus**][rehypeprismplus] or [**rehype-highlight-code-lines**][rehypehighlightcodelines] for line highlighting and numbering features. 505 | 506 | ````markdown 507 | ```javascript:file.js {1,3-6} showLineNumbers 508 | let me = "ipikuka"; 509 | ``` 510 | ```` 511 | 512 | **`remark-flexible-code-titles`** takes the line highlighting and numbering syntax into consideration, and passes that information to other remark and rehype plugins. 513 | 514 | But, if you want to highlight and number the lines **without specifying language**, you will get the language of the code block as for example `language-{2}` like strings. Let me give an example: 515 | 516 | ````markdown 517 | ```{2} showLineNumbers 518 | This is a line which is going to be numbered with rehype-prism-plus. 519 | This is a line which is going to be highlighted and numbered with rehype-prism-plus. 520 | ``` 521 | ```` 522 | 523 | The above markdown, with no language provided, will lead to produce a mdast "code" node as follows: 524 | 525 | ```json 526 | { 527 | "type": "code", 528 | "lang": "{2}", 529 | "meta": "showLineNumbers" 530 | } 531 | ``` 532 | 533 | As a result, the html `code` element will have wrong language `language-{2}`: 534 | _(The class `code-highlight` in the `code` element is added by the rehype plugin `rehype-prism-plus`)_ 535 | 536 | ```html 537 | ... 538 | ``` 539 | 540 | **`remark-flexible-code-titles`** not only adds `title` and `container` elements but also **corrects the language** producing the below `mdast` which will lead the `` element has accurate language or not have language as it sould be. 541 | 542 | ```json 543 | { 544 | "type": "code", 545 | "lang": null, 546 | "meta": "{2} showLineNumbers" 547 | } 548 | ``` 549 | 550 | ```html 551 | 552 | 553 | 554 | ``` 555 | 556 | If there is no space between the parts (_title, line range string and "showLineNumbers"_), or there is extra spaces inside the _line range string_, line highlighting or numbering features by the rehype plugin will not work. **`remark-flexible-code-titles` can handles and corrects this kind of mis-typed situations**. 557 | 558 | ````markdown 559 | ```typescript:title{ 1, 3 - 6 }showLineNumbers 560 | content 561 | ``` 562 | ```` 563 | 564 | There is mis-typed syntax in above markdown example; and without **`remark-flexible-code-titles`** will cause to produce the following `mdast`; and the rehype plugin not to work properly: 565 | 566 | ```json 567 | { 568 | "type": "code", 569 | "lang": "typescript:title{", 570 | "meta": " 1, 3 - 6 }showLineNumbers" 571 | } 572 | ``` 573 | 574 | **`remark-flexible-code-titles`** will correct the syntax and ensure to produce the following `mdast` and `html`: 575 | 576 | ```json 577 | { 578 | "type": "code", 579 | "lang": "typescript", 580 | "meta": "{1,3-6} showLineNumbers" 581 | } 582 | ``` 583 | 584 | ```html 585 |
586 |
title
587 |
588 |     
589 |       
590 |     
591 |   
592 |
593 | ``` 594 | 595 | #### Example for providing a title without any language 596 | 597 | You can provide a `title` without any language just using a colon **`:`** at the beginning. 598 | 599 | ````markdown 600 | ```:title 601 | content 602 | ``` 603 | ```` 604 | 605 | ```html 606 |
607 |
title
608 |
609 |     content
610 |   
611 |
612 | ``` 613 | 614 | ### Another flexible usage 615 | 616 | You can use **`remark-flexible-code-titles**` **just for only correcting language, line highlighting and numbering syntax** on behalf of related rehype plugins. 617 | 618 | ```javascript 619 | use(remarkCodeTitles, { 620 | container: false, 621 | title: false, 622 | }); 623 | ``` 624 | 625 | Now, **`remark-flexible-code-titles`** will not add any node, but will correct language, line highlighting and numbering syntax. 626 | 627 | ## Syntax tree 628 | 629 | This plugin only modifies the mdast (markdown abstract syntax tree) as explained. 630 | 631 | ## Types 632 | 633 | This package is fully typed with [TypeScript][url-typescript]. The plugin options' type is exported as `CodeTitleOptions`. 634 | 635 | ## Compatibility 636 | 637 | This plugin works with `unified` version 6+ and `remark` version 7+. It is compatible with `mdx` version 2+. 638 | 639 | ## Security 640 | 641 | Use of **`remark-flexible-code-titles`** does not involve rehype (hast) or user content so there are no openings for cross-site scripting (XSS) attacks. 642 | 643 | ## My Plugins 644 | 645 | I like to contribute the Unified / Remark / MDX ecosystem, so I recommend you to have a look my plugins. 646 | 647 | ### My Remark Plugins 648 | 649 | - [`remark-flexible-code-titles`](https://www.npmjs.com/package/remark-flexible-code-titles) 650 | – Remark plugin to add titles or/and containers for the code blocks with customizable properties 651 | - [`remark-flexible-containers`](https://www.npmjs.com/package/remark-flexible-containers) 652 | – Remark plugin to add custom containers with customizable properties in markdown 653 | - [`remark-ins`](https://www.npmjs.com/package/remark-ins) 654 | – Remark plugin to add `ins` element in markdown 655 | - [`remark-flexible-paragraphs`](https://www.npmjs.com/package/remark-flexible-paragraphs) 656 | – Remark plugin to add custom paragraphs with customizable properties in markdown 657 | - [`remark-flexible-markers`](https://www.npmjs.com/package/remark-flexible-markers) 658 | – Remark plugin to add custom `mark` element with customizable properties in markdown 659 | - [`remark-flexible-toc`](https://www.npmjs.com/package/remark-flexible-toc) 660 | – Remark plugin to expose the table of contents via `vfile.data` or via an option reference 661 | - [`remark-mdx-remove-esm`](https://www.npmjs.com/package/remark-mdx-remove-esm) 662 | – Remark plugin to remove import and/or export statements (mdxjsEsm) 663 | 664 | ### My Rehype Plugins 665 | 666 | - [`rehype-pre-language`](https://www.npmjs.com/package/rehype-pre-language) 667 | – Rehype plugin to add language information as a property to `pre` element 668 | - [`rehype-highlight-code-lines`](https://www.npmjs.com/package/rehype-highlight-code-lines) 669 | – Rehype plugin to add line numbers to code blocks and allow highlighting of desired code lines 670 | - [`rehype-code-meta`](https://www.npmjs.com/package/rehype-code-meta) 671 | – Rehype plugin to copy `code.data.meta` to `code.properties.metastring` 672 | - [`rehype-image-toolkit`](https://www.npmjs.com/package/rehype-image-toolkit) 673 | – Rehype plugin to enhance Markdown image syntax `![]()` and Markdown/MDX media elements (``, `