├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── index.d.ts ├── index.js ├── lib └── index.js ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: unifiedjs/beep-boop-beta@main 6 | with: 7 | repo-token: ${{secrets.GITHUB_TOKEN}} 8 | name: bb 9 | on: 10 | issues: 11 | types: [closed, edited, labeled, opened, reopened, unlabeled] 12 | pull_request_target: 13 | types: [closed, edited, labeled, opened, reopened, unlabeled] 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | name: ${{matrix.node}} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v5 13 | strategy: 14 | matrix: 15 | node: 16 | - lts/hydrogen 17 | - node 18 | name: main 19 | on: 20 | - pull_request 21 | - push 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | *.log 3 | *.map 4 | *.tsbuildinfo 5 | .DS_Store 6 | coverage/ 7 | node_modules/ 8 | yarn.lock 9 | !/index.d.ts 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BlockContent, 3 | Data, 4 | DefinitionContent, 5 | Parent, 6 | PhrasingContent 7 | } from 'mdast' 8 | 9 | export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' 10 | 11 | /** 12 | * Configuration. 13 | */ 14 | export interface ToMarkdownOptions { 15 | /** 16 | * Collapse empty attributes: get `title` instead of `title=""` 17 | * (default: `true`). 18 | */ 19 | collapseEmptyAttributes?: boolean | null | undefined 20 | /** 21 | * Prefer `#` and `.` shortcuts for `id` and `class` 22 | * (default: `true`). 23 | */ 24 | preferShortcut?: boolean | null | undefined 25 | /** 26 | * Leave attributes unquoted if that results in less bytes 27 | * (default: `false`). 28 | */ 29 | preferUnquoted?: boolean | null | undefined 30 | /** 31 | * Use the other quote if that results in less bytes 32 | * (default: `false`). 33 | */ 34 | quoteSmart?: boolean | null | undefined 35 | /** 36 | * Preferred quote to use around attribute values 37 | * (default: the `quote` used by `mdast-util-to-markdown` for titles). 38 | */ 39 | quote?: '"' | "'" | null | undefined 40 | } 41 | 42 | /** 43 | * Fields shared by directives. 44 | */ 45 | interface DirectiveFields { 46 | /** 47 | * Directive attributes. 48 | */ 49 | attributes?: Record | null | undefined 50 | 51 | /** 52 | * Directive name. 53 | */ 54 | name: string 55 | } 56 | 57 | /** 58 | * Markdown directive (container form). 59 | */ 60 | export interface ContainerDirective extends DirectiveFields, Parent { 61 | /** 62 | * Node type of container directive. 63 | */ 64 | type: 'containerDirective' 65 | 66 | /** 67 | * Children of container directive. 68 | */ 69 | children: Array 70 | 71 | /** 72 | * Data associated with the mdast container directive. 73 | */ 74 | data?: ContainerDirectiveData | undefined 75 | } 76 | 77 | /** 78 | * Info associated with mdast container directive nodes by the ecosystem. 79 | */ 80 | export interface ContainerDirectiveData extends Data {} 81 | 82 | /** 83 | * Markdown directive (leaf form). 84 | */ 85 | export interface LeafDirective extends Parent, DirectiveFields { 86 | /** 87 | * Node type of leaf directive. 88 | */ 89 | type: 'leafDirective' 90 | 91 | /** 92 | * Children of leaf directive. 93 | */ 94 | children: Array 95 | 96 | /** 97 | * Data associated with the mdast leaf directive. 98 | */ 99 | data?: LeafDirectiveData | undefined 100 | } 101 | 102 | /** 103 | * Info associated with mdast leaf directive nodes by the ecosystem. 104 | */ 105 | export interface LeafDirectiveData extends Data {} 106 | 107 | /** 108 | * Markdown directive (text form). 109 | */ 110 | export interface TextDirective extends DirectiveFields, Parent { 111 | /** 112 | * Node type of text directive. 113 | */ 114 | type: 'textDirective' 115 | 116 | /** 117 | * Children of text directive. 118 | */ 119 | children: Array 120 | 121 | /** 122 | * Data associated with the text leaf directive. 123 | */ 124 | data?: TextDirectiveData | undefined 125 | } 126 | 127 | /** 128 | * Info associated with mdast text directive nodes by the ecosystem. 129 | */ 130 | export interface TextDirectiveData extends Data {} 131 | 132 | /** 133 | * Union of registered mdast directive nodes. 134 | * 135 | * It is not possible to register custom mdast directive node types. 136 | */ 137 | export type Directives = ContainerDirective | LeafDirective | TextDirective 138 | 139 | // Add custom data tracked to turn markdown into a tree. 140 | declare module 'mdast-util-from-markdown' { 141 | interface CompileData { 142 | /** 143 | * Attributes for current directive. 144 | */ 145 | directiveAttributes?: Array<[string, string]> | undefined 146 | } 147 | } 148 | 149 | // Add custom data tracked to turn a syntax tree into markdown. 150 | declare module 'mdast-util-to-markdown' { 151 | interface ConstructNameMap { 152 | /** 153 | * Whole container directive. 154 | * 155 | * ```markdown 156 | * > | :::a 157 | * ^^^^ 158 | * > | ::: 159 | * ^^^ 160 | * ``` 161 | */ 162 | containerDirective: 'containerDirective' 163 | 164 | /** 165 | * Label of a container directive. 166 | * 167 | * ```markdown 168 | * > | :::a[b] 169 | * ^^^ 170 | * | ::: 171 | * ``` 172 | */ 173 | containerDirectiveLabel: 'containerDirectiveLabel' 174 | 175 | /** 176 | * Whole leaf directive. 177 | * 178 | * ```markdown 179 | * > | ::a 180 | * ^^^ 181 | * ``` 182 | */ 183 | leafDirective: 'leafDirective' 184 | 185 | /** 186 | * Label of a leaf directive. 187 | * 188 | * ```markdown 189 | * > | ::a[b] 190 | * ^^^ 191 | * ``` 192 | */ 193 | leafDirectiveLabel: 'leafDirectiveLabel' 194 | 195 | /** 196 | * Whole text directive. 197 | * 198 | * ```markdown 199 | * > | :a 200 | * ^^ 201 | * ``` 202 | */ 203 | textDirective: 'textDirective' 204 | 205 | /** 206 | * Label of a text directive. 207 | * 208 | * ```markdown 209 | * > | :a[b] 210 | * ^^^ 211 | * ``` 212 | */ 213 | textDirectiveLabel: 'textDirectiveLabel' 214 | } 215 | } 216 | 217 | // Add nodes to content, register `data` on paragraph. 218 | declare module 'mdast' { 219 | interface BlockContentMap { 220 | /** 221 | * Directive in flow content (such as in the root document, or block 222 | * quotes), which contains further flow content. 223 | */ 224 | containerDirective: ContainerDirective 225 | 226 | /** 227 | * Directive in flow content (such as in the root document, or block 228 | * quotes), which contains nothing. 229 | */ 230 | leafDirective: LeafDirective 231 | } 232 | 233 | interface ParagraphData { 234 | /** 235 | * Field set on the first paragraph which is a child of a container 236 | * directive. 237 | * When this is `true`, that means the paragraph represents the *label*: 238 | * 239 | * ```markdown 240 | * :::a[This is the label] 241 | * This is further things. 242 | * ::: 243 | * ``` 244 | */ 245 | directiveLabel?: boolean | null | undefined 246 | } 247 | 248 | interface PhrasingContentMap { 249 | /** 250 | * Directive in phrasing content (such as in paragraphs, headings). 251 | */ 252 | textDirective: TextDirective 253 | } 254 | 255 | interface RootContentMap { 256 | /** 257 | * Directive in flow content (such as in the root document, or block 258 | * quotes), which contains further flow content. 259 | */ 260 | containerDirective: ContainerDirective 261 | 262 | /** 263 | * Directive in flow content (such as in the root document, or block 264 | * quotes), which contains nothing. 265 | */ 266 | leafDirective: LeafDirective 267 | 268 | /** 269 | * Directive in phrasing content (such as in paragraphs, headings). 270 | */ 271 | textDirective: TextDirective 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Note: types exposed from `index.d.ts`. 2 | export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' 3 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Directives, LeafDirective, TextDirective, ToMarkdownOptions} from 'mdast-util-directive' 3 | * @import { 4 | * CompileContext, 5 | * Extension as FromMarkdownExtension, 6 | * Handle as FromMarkdownHandle, 7 | * Token 8 | * } from 'mdast-util-from-markdown' 9 | * @import { 10 | * ConstructName, 11 | * Handle as ToMarkdownHandle, 12 | * Options as ToMarkdownExtension, 13 | * State 14 | * } from 'mdast-util-to-markdown' 15 | * @import {Nodes, Paragraph} from 'mdast' 16 | */ 17 | 18 | import {ccount} from 'ccount' 19 | import {ok as assert} from 'devlop' 20 | import {parseEntities} from 'parse-entities' 21 | import {stringifyEntitiesLight} from 'stringify-entities' 22 | import {visitParents} from 'unist-util-visit-parents' 23 | 24 | const own = {}.hasOwnProperty 25 | 26 | /** @type {Readonly} */ 27 | const emptyOptions = {} 28 | 29 | const shortcut = /^[^\t\n\r "#'.<=>`}]+$/ 30 | const unquoted = /^[^\t\n\r "'<=>`}]+$/ 31 | 32 | /** 33 | * Create an extension for `mdast-util-from-markdown` to enable directives in 34 | * markdown. 35 | * 36 | * @returns {FromMarkdownExtension} 37 | * Extension for `mdast-util-from-markdown` to enable directives. 38 | */ 39 | export function directiveFromMarkdown() { 40 | return { 41 | canContainEols: ['textDirective'], 42 | enter: { 43 | directiveContainer: enterContainer, 44 | directiveContainerAttributes: enterAttributes, 45 | directiveContainerLabel: enterContainerLabel, 46 | 47 | directiveLeaf: enterLeaf, 48 | directiveLeafAttributes: enterAttributes, 49 | 50 | directiveText: enterText, 51 | directiveTextAttributes: enterAttributes 52 | }, 53 | exit: { 54 | directiveContainer: exit, 55 | directiveContainerAttributeClassValue: exitAttributeClassValue, 56 | directiveContainerAttributeIdValue: exitAttributeIdValue, 57 | directiveContainerAttributeName: exitAttributeName, 58 | directiveContainerAttributeValue: exitAttributeValue, 59 | directiveContainerAttributes: exitAttributes, 60 | directiveContainerLabel: exitContainerLabel, 61 | directiveContainerName: exitName, 62 | 63 | directiveLeaf: exit, 64 | directiveLeafAttributeClassValue: exitAttributeClassValue, 65 | directiveLeafAttributeIdValue: exitAttributeIdValue, 66 | directiveLeafAttributeName: exitAttributeName, 67 | directiveLeafAttributeValue: exitAttributeValue, 68 | directiveLeafAttributes: exitAttributes, 69 | directiveLeafName: exitName, 70 | 71 | directiveText: exit, 72 | directiveTextAttributeClassValue: exitAttributeClassValue, 73 | directiveTextAttributeIdValue: exitAttributeIdValue, 74 | directiveTextAttributeName: exitAttributeName, 75 | directiveTextAttributeValue: exitAttributeValue, 76 | directiveTextAttributes: exitAttributes, 77 | directiveTextName: exitName 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Create an extension for `mdast-util-to-markdown` to enable directives in 84 | * markdown. 85 | * 86 | * @param {Readonly | null | undefined} [options] 87 | * Configuration (optional). 88 | * @returns {ToMarkdownExtension} 89 | * Extension for `mdast-util-to-markdown` to enable directives. 90 | */ 91 | export function directiveToMarkdown(options) { 92 | const settings = options || emptyOptions 93 | 94 | if ( 95 | settings.quote !== '"' && 96 | settings.quote !== "'" && 97 | settings.quote !== null && 98 | settings.quote !== undefined 99 | ) { 100 | throw new Error( 101 | 'Invalid quote `' + settings.quote + '`, expected `\'` or `"`' 102 | ) 103 | } 104 | 105 | handleDirective.peek = peekDirective 106 | 107 | return { 108 | handlers: { 109 | containerDirective: handleDirective, 110 | leafDirective: handleDirective, 111 | textDirective: handleDirective 112 | }, 113 | unsafe: [ 114 | { 115 | character: '\r', 116 | inConstruct: ['leafDirectiveLabel', 'containerDirectiveLabel'] 117 | }, 118 | { 119 | character: '\n', 120 | inConstruct: ['leafDirectiveLabel', 'containerDirectiveLabel'] 121 | }, 122 | { 123 | before: '[^:]', 124 | character: ':', 125 | after: '[A-Za-z]', 126 | inConstruct: ['phrasing'] 127 | }, 128 | {atBreak: true, character: ':', after: ':'} 129 | ] 130 | } 131 | 132 | /** 133 | * @type {ToMarkdownHandle} 134 | * @param {Directives} node 135 | */ 136 | function handleDirective(node, _, state, info) { 137 | const tracker = state.createTracker(info) 138 | const sequence = fence(node) 139 | const exit = state.enter(node.type) 140 | let value = tracker.move(sequence + (node.name || '')) 141 | /** @type {LeafDirective | Paragraph | TextDirective | undefined} */ 142 | let label 143 | 144 | if (node.type === 'containerDirective') { 145 | const head = (node.children || [])[0] 146 | label = inlineDirectiveLabel(head) ? head : undefined 147 | } else { 148 | label = node 149 | } 150 | 151 | if (label && label.children && label.children.length > 0) { 152 | const exit = state.enter('label') 153 | /** @type {ConstructName} */ 154 | const labelType = `${node.type}Label` 155 | const subexit = state.enter(labelType) 156 | value += tracker.move('[') 157 | value += tracker.move( 158 | state.containerPhrasing(label, { 159 | ...tracker.current(), 160 | before: value, 161 | after: ']' 162 | }) 163 | ) 164 | value += tracker.move(']') 165 | subexit() 166 | exit() 167 | } 168 | 169 | value += tracker.move(attributes(node, state)) 170 | 171 | if (node.type === 'containerDirective') { 172 | const head = (node.children || [])[0] 173 | let shallow = node 174 | 175 | if (inlineDirectiveLabel(head)) { 176 | shallow = Object.assign({}, node, {children: node.children.slice(1)}) 177 | } 178 | 179 | if (shallow && shallow.children && shallow.children.length > 0) { 180 | value += tracker.move('\n') 181 | value += tracker.move(state.containerFlow(shallow, tracker.current())) 182 | } 183 | 184 | value += tracker.move('\n' + sequence) 185 | } 186 | 187 | exit() 188 | return value 189 | } 190 | 191 | /** 192 | * @param {Directives} node 193 | * @param {State} state 194 | * @returns {string} 195 | */ 196 | function attributes(node, state) { 197 | const attributes = node.attributes || {} 198 | /** @type {Array} */ 199 | const values = [] 200 | /** @type {string | undefined} */ 201 | let classesFull 202 | /** @type {string | undefined} */ 203 | let classes 204 | /** @type {string | undefined} */ 205 | let id 206 | /** @type {string} */ 207 | let key 208 | 209 | for (key in attributes) { 210 | if ( 211 | own.call(attributes, key) && 212 | attributes[key] !== undefined && 213 | attributes[key] !== null 214 | ) { 215 | const value = String(attributes[key]) 216 | 217 | // To do: next major: 218 | // Do not reorder `id` and `class` attributes when they do not turn into 219 | // shortcuts. 220 | // Additionally, join shortcuts: `#a .b.c d="e"` -> `#a.b.c d="e"` 221 | if (key === 'id') { 222 | id = 223 | settings.preferShortcut !== false && shortcut.test(value) 224 | ? '#' + value 225 | : quoted('id', value, node, state) 226 | } else if (key === 'class') { 227 | const list = value.split(/[\t\n\r ]+/g) 228 | /** @type {Array} */ 229 | const classesFullList = [] 230 | /** @type {Array} */ 231 | const classesList = [] 232 | let index = -1 233 | 234 | while (++index < list.length) { 235 | ;(settings.preferShortcut !== false && shortcut.test(list[index]) 236 | ? classesList 237 | : classesFullList 238 | ).push(list[index]) 239 | } 240 | 241 | classesFull = 242 | classesFullList.length > 0 243 | ? quoted('class', classesFullList.join(' '), node, state) 244 | : '' 245 | classes = classesList.length > 0 ? '.' + classesList.join('.') : '' 246 | } else { 247 | values.push(quoted(key, value, node, state)) 248 | } 249 | } 250 | } 251 | 252 | if (classesFull) { 253 | values.unshift(classesFull) 254 | } 255 | 256 | if (classes) { 257 | values.unshift(classes) 258 | } 259 | 260 | if (id) { 261 | values.unshift(id) 262 | } 263 | 264 | return values.length > 0 ? '{' + values.join(' ') + '}' : '' 265 | } 266 | 267 | /** 268 | * @param {string} key 269 | * @param {string} value 270 | * @param {Directives} node 271 | * @param {State} state 272 | * @returns {string} 273 | */ 274 | function quoted(key, value, node, state) { 275 | if (settings.collapseEmptyAttributes !== false && !value) return key 276 | 277 | if (settings.preferUnquoted && unquoted.test(value)) { 278 | return key + '=' + value 279 | } 280 | 281 | // If the alternative is less common than `quote`, switch. 282 | const preferred = settings.quote || state.options.quote || '"' 283 | const alternative = preferred === '"' ? "'" : '"' 284 | // If the alternative is less common than `quote`, switch. 285 | const appliedQuote = 286 | settings.quoteSmart && 287 | ccount(value, preferred) > ccount(value, alternative) 288 | ? alternative 289 | : preferred 290 | const subset = 291 | node.type === 'textDirective' 292 | ? [appliedQuote] 293 | : [appliedQuote, '\n', '\r'] 294 | 295 | return ( 296 | key + 297 | '=' + 298 | appliedQuote + 299 | stringifyEntitiesLight(value, {subset}) + 300 | appliedQuote 301 | ) 302 | } 303 | } 304 | 305 | /** 306 | * @this {CompileContext} 307 | * @type {FromMarkdownHandle} 308 | */ 309 | function enterContainer(token) { 310 | enter.call(this, 'containerDirective', token) 311 | } 312 | 313 | /** 314 | * @this {CompileContext} 315 | * @type {FromMarkdownHandle} 316 | */ 317 | function enterLeaf(token) { 318 | enter.call(this, 'leafDirective', token) 319 | } 320 | 321 | /** 322 | * @this {CompileContext} 323 | * @type {FromMarkdownHandle} 324 | */ 325 | function enterText(token) { 326 | enter.call(this, 'textDirective', token) 327 | } 328 | 329 | /** 330 | * @this {CompileContext} 331 | * @param {Directives['type']} type 332 | * @param {Token} token 333 | */ 334 | function enter(type, token) { 335 | this.enter({type, name: '', attributes: {}, children: []}, token) 336 | } 337 | 338 | /** 339 | * @this {CompileContext} 340 | * @param {Token} token 341 | */ 342 | function exitName(token) { 343 | const node = this.stack[this.stack.length - 1] 344 | assert( 345 | node.type === 'containerDirective' || 346 | node.type === 'leafDirective' || 347 | node.type === 'textDirective' 348 | ) 349 | node.name = this.sliceSerialize(token) 350 | } 351 | 352 | /** 353 | * @this {CompileContext} 354 | * @type {FromMarkdownHandle} 355 | */ 356 | function enterContainerLabel(token) { 357 | this.enter( 358 | {type: 'paragraph', data: {directiveLabel: true}, children: []}, 359 | token 360 | ) 361 | } 362 | 363 | /** 364 | * @this {CompileContext} 365 | * @type {FromMarkdownHandle} 366 | */ 367 | function exitContainerLabel(token) { 368 | this.exit(token) 369 | } 370 | 371 | /** 372 | * @this {CompileContext} 373 | * @type {FromMarkdownHandle} 374 | */ 375 | function enterAttributes() { 376 | this.data.directiveAttributes = [] 377 | this.buffer() // Capture EOLs 378 | } 379 | 380 | /** 381 | * @this {CompileContext} 382 | * @type {FromMarkdownHandle} 383 | */ 384 | function exitAttributeIdValue(token) { 385 | const list = this.data.directiveAttributes 386 | assert(list, 'expected `directiveAttributes`') 387 | list.push([ 388 | 'id', 389 | parseEntities(this.sliceSerialize(token), {attribute: true}) 390 | ]) 391 | } 392 | 393 | /** 394 | * @this {CompileContext} 395 | * @type {FromMarkdownHandle} 396 | */ 397 | function exitAttributeClassValue(token) { 398 | const list = this.data.directiveAttributes 399 | assert(list, 'expected `directiveAttributes`') 400 | list.push([ 401 | 'class', 402 | parseEntities(this.sliceSerialize(token), {attribute: true}) 403 | ]) 404 | } 405 | 406 | /** 407 | * @this {CompileContext} 408 | * @type {FromMarkdownHandle} 409 | */ 410 | function exitAttributeValue(token) { 411 | const list = this.data.directiveAttributes 412 | assert(list, 'expected `directiveAttributes`') 413 | list[list.length - 1][1] = parseEntities(this.sliceSerialize(token), { 414 | attribute: true 415 | }) 416 | } 417 | 418 | /** 419 | * @this {CompileContext} 420 | * @type {FromMarkdownHandle} 421 | */ 422 | function exitAttributeName(token) { 423 | const list = this.data.directiveAttributes 424 | assert(list, 'expected `directiveAttributes`') 425 | 426 | // Attribute names in CommonMark are significantly limited, so character 427 | // references can’t exist. 428 | list.push([this.sliceSerialize(token), '']) 429 | } 430 | 431 | /** 432 | * @this {CompileContext} 433 | * @type {FromMarkdownHandle} 434 | */ 435 | function exitAttributes() { 436 | const list = this.data.directiveAttributes 437 | assert(list, 'expected `directiveAttributes`') 438 | /** @type {Record} */ 439 | const cleaned = {} 440 | let index = -1 441 | 442 | while (++index < list.length) { 443 | const attribute = list[index] 444 | 445 | if (attribute[0] === 'class' && cleaned.class) { 446 | cleaned.class += ' ' + attribute[1] 447 | } else { 448 | cleaned[attribute[0]] = attribute[1] 449 | } 450 | } 451 | 452 | this.data.directiveAttributes = undefined 453 | this.resume() // Drop EOLs 454 | const node = this.stack[this.stack.length - 1] 455 | assert( 456 | node.type === 'containerDirective' || 457 | node.type === 'leafDirective' || 458 | node.type === 'textDirective' 459 | ) 460 | node.attributes = cleaned 461 | } 462 | 463 | /** 464 | * @this {CompileContext} 465 | * @type {FromMarkdownHandle} 466 | */ 467 | function exit(token) { 468 | this.exit(token) 469 | } 470 | 471 | /** @type {ToMarkdownHandle} */ 472 | function peekDirective() { 473 | return ':' 474 | } 475 | 476 | /** 477 | * @param {Nodes} node 478 | * @returns {node is Paragraph & {data: {directiveLabel: true}}} 479 | */ 480 | function inlineDirectiveLabel(node) { 481 | return Boolean( 482 | node && node.type === 'paragraph' && node.data && node.data.directiveLabel 483 | ) 484 | } 485 | 486 | /** 487 | * @param {Directives} node 488 | * @returns {string} 489 | */ 490 | function fence(node) { 491 | let size = 0 492 | 493 | if (node.type === 'containerDirective') { 494 | visitParents(node, function (node, parents) { 495 | if (node.type === 'containerDirective') { 496 | let index = parents.length 497 | let nesting = 0 498 | 499 | while (index--) { 500 | if (parents[index].type === 'containerDirective') { 501 | nesting++ 502 | } 503 | } 504 | 505 | if (nesting > size) size = nesting 506 | } 507 | }) 508 | size += 3 509 | } else if (node.type === 'leafDirective') { 510 | size = 2 511 | } else { 512 | size = 1 513 | } 514 | 515 | return ':'.repeat(size) 516 | } 517 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Titus Wormer (https://wooorm.com)", 3 | "bugs": "https://github.com/syntax-tree/mdast-util-directive/issues", 4 | "contributors": [ 5 | "Titus Wormer (https://wooorm.com)" 6 | ], 7 | "dependencies": { 8 | "@types/mdast": "^4.0.0", 9 | "@types/unist": "^3.0.0", 10 | "ccount": "^2.0.0", 11 | "devlop": "^1.0.0", 12 | "mdast-util-from-markdown": "^2.0.0", 13 | "mdast-util-to-markdown": "^2.0.0", 14 | "parse-entities": "^4.0.0", 15 | "stringify-entities": "^4.0.0", 16 | "unist-util-visit-parents": "^6.0.0" 17 | }, 18 | "description": "mdast extension to parse and serialize generic directives (`:cite[smith04]`)", 19 | "devDependencies": { 20 | "@types/node": "^22.0.0", 21 | "c8": "^10.0.0", 22 | "micromark-extension-directive": "^4.0.0", 23 | "prettier": "^3.0.0", 24 | "remark-cli": "^12.0.0", 25 | "remark-preset-wooorm": "^11.0.0", 26 | "type-coverage": "^2.0.0", 27 | "typescript": "^5.0.0", 28 | "unist-util-remove-position": "^5.0.0", 29 | "xo": "^0.60.0" 30 | }, 31 | "exports": "./index.js", 32 | "files": [ 33 | "index.d.ts", 34 | "index.js", 35 | "lib/" 36 | ], 37 | "funding": { 38 | "type": "opencollective", 39 | "url": "https://opencollective.com/unified" 40 | }, 41 | "keywords": [ 42 | "container", 43 | "directive", 44 | "extension", 45 | "generic", 46 | "markdown", 47 | "markup", 48 | "mdast-util", 49 | "mdast", 50 | "unist", 51 | "utility", 52 | "util" 53 | ], 54 | "license": "MIT", 55 | "name": "mdast-util-directive", 56 | "prettier": { 57 | "bracketSpacing": false, 58 | "semi": false, 59 | "singleQuote": true, 60 | "tabWidth": 2, 61 | "trailingComma": "none", 62 | "useTabs": false 63 | }, 64 | "remarkConfig": { 65 | "plugins": [ 66 | "remark-preset-wooorm" 67 | ] 68 | }, 69 | "repository": "syntax-tree/mdast-util-directive", 70 | "scripts": { 71 | "build": "tsc --build --clean && tsc --build && type-coverage", 72 | "format": "remark --frail --output --quiet -- . && prettier --log-level warn --write -- . && xo --fix", 73 | "test-api-dev": "node --conditions development test.js", 74 | "test-api-prod": "node --conditions production test.js", 75 | "test-api": "npm run test-api-dev && npm run test-api-prod", 76 | "test-coverage": "c8 --100 --reporter lcov -- npm run test-api", 77 | "test": "npm run build && npm run format && npm run test-coverage" 78 | }, 79 | "sideEffects": false, 80 | "typeCoverage": { 81 | "atLeast": 100, 82 | "strict": true 83 | }, 84 | "type": "module", 85 | "version": "3.1.0", 86 | "xo": { 87 | "overrides": [ 88 | { 89 | "files": [ 90 | "**/*.d.ts" 91 | ], 92 | "rules": { 93 | "@typescript-eslint/array-type": [ 94 | "error", 95 | { 96 | "default": "generic" 97 | } 98 | ], 99 | "@typescript-eslint/ban-types": [ 100 | "error", 101 | { 102 | "extendDefaults": true 103 | } 104 | ], 105 | "@typescript-eslint/consistent-type-definitions": [ 106 | "error", 107 | "interface" 108 | ] 109 | } 110 | } 111 | ], 112 | "prettier": true, 113 | "rules": { 114 | "unicorn/prefer-at": "off" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # mdast-util-directive 2 | 3 | [![Build][badge-build-image]][badge-build-url] 4 | [![Coverage][badge-coverage-image]][badge-coverage-url] 5 | [![Downloads][badge-downloads-image]][badge-downloads-url] 6 | [![Size][badge-size-image]][badge-size-url] 7 | 8 | [mdast][github-mdast] extensions to parse and serialize 9 | [generic directives proposal][commonmark-directive-proposal] 10 | (`:cite[smith04]`, 11 | `::youtube[Video of a cat in a box]{v=01ab2cd3efg}`, 12 | and such). 13 | 14 | ## Contents 15 | 16 | * [What is this?](#what-is-this) 17 | * [When to use this](#when-to-use-this) 18 | * [Install](#install) 19 | * [Use](#use) 20 | * [API](#api) 21 | * [`directiveFromMarkdown()`](#directivefrommarkdown) 22 | * [`directiveToMarkdown(options?)`](#directivetomarkdownoptions) 23 | * [`ContainerDirective`](#containerdirective) 24 | * [`Directives`](#directives) 25 | * [`LeafDirective`](#leafdirective) 26 | * [`TextDirective`](#textdirective) 27 | * [`ToMarkdownOptions`](#tomarkdownoptions) 28 | * [HTML](#html) 29 | * [Syntax](#syntax) 30 | * [Syntax tree](#syntax-tree) 31 | * [Nodes](#nodes) 32 | * [Mixin](#mixin) 33 | * [Types](#types) 34 | * [Compatibility](#compatibility) 35 | * [Related](#related) 36 | * [Contribute](#contribute) 37 | * [License](#license) 38 | 39 | ## What is this? 40 | 41 | This package contains two extensions that add support for directive syntax in 42 | markdown to [mdast][github-mdast]. 43 | These extensions plug into 44 | [`mdast-util-from-markdown`][github-mdast-util-from-markdown] 45 | (to support parsing directives in markdown into a syntax tree) 46 | and 47 | [`mdast-util-to-markdown`][github-mdast-util-to-markdown] 48 | (to support serializing directives in syntax trees to markdown). 49 | 50 | ## When to use this 51 | 52 | Directives are one of the four ways to extend markdown: 53 | an arbitrary extension syntax 54 | (see [Extending markdown][github-micromark-extending] in micromark’s docs for 55 | the alternatives and more info). 56 | This mechanism works well when you control the content: 57 | who authors it, 58 | what tools handle it, 59 | and where it’s displayed. 60 | When authors can read a guide on how to embed a tweet but are not expected to 61 | know the ins and outs of HTML or JavaScript. 62 | Directives don’t work well if you don’t know who authors content, 63 | what tools handle it, 64 | and where it ends up. 65 | Example use cases are a docs website for a project or product, 66 | or blogging tools and static site generators. 67 | 68 | You can use these extensions when you are working with 69 | `mdast-util-from-markdown` and `mdast-util-to-markdown` already. 70 | 71 | When working with `mdast-util-from-markdown`, 72 | you must combine this package with 73 | [`micromark-extension-directive`][github-micromark-extension-directive]. 74 | 75 | When you don’t need a syntax tree, 76 | you can use [`micromark`][github-micromark] directly with 77 | `micromark-extension-directive`. 78 | 79 | All these packages are used [`remark-directive`][github-remark-directive], 80 | which focusses on making it easier to transform content by abstracting these 81 | internals away. 82 | 83 | This package only handles the syntax tree. 84 | For example, 85 | it does not handle how markdown is turned to HTML. 86 | You can use this with some more code to match your specific needs, 87 | to allow for anything from callouts, 88 | citations, 89 | styled blocks, 90 | forms, 91 | embeds, 92 | spoilers, 93 | etc. 94 | [Traverse the tree][unifiedjs-tree-traversal] to change directives to whatever 95 | you please. 96 | 97 | ## Install 98 | 99 | This package is [ESM only][github-gist-esm]. 100 | In Node.js (version 16+), 101 | install with [npm][npmjs-install]: 102 | 103 | ```sh 104 | npm install mdast-util-directive 105 | ``` 106 | 107 | In Deno with [`esm.sh`][esmsh]: 108 | 109 | ```js 110 | import {directiveFromMarkdown, directiveToMarkdown} from 'https://esm.sh/mdast-util-directive@3' 111 | ``` 112 | 113 | In browsers with [`esm.sh`][esmsh]: 114 | 115 | ```html 116 | 119 | ``` 120 | 121 | ## Use 122 | 123 | Say our document `example.md` contains: 124 | 125 | ```markdown 126 | A lovely language know as :abbr[HTML]{title="HyperText Markup Language"}. 127 | ``` 128 | 129 | …and our module `example.js` looks as follows: 130 | 131 | ```js 132 | import fs from 'node:fs/promises' 133 | import {fromMarkdown} from 'mdast-util-from-markdown' 134 | import {toMarkdown} from 'mdast-util-to-markdown' 135 | import {directive} from 'micromark-extension-directive' 136 | import {directiveFromMarkdown, directiveToMarkdown} from 'mdast-util-directive' 137 | 138 | const doc = await fs.readFile('example.md') 139 | 140 | const tree = fromMarkdown(doc, { 141 | extensions: [directive()], 142 | mdastExtensions: [directiveFromMarkdown()] 143 | }) 144 | 145 | console.log(tree) 146 | 147 | const out = toMarkdown(tree, {extensions: [directiveToMarkdown()]}) 148 | 149 | console.log(out) 150 | ``` 151 | 152 | …now running `node example.js` yields (positional info removed for brevity): 153 | 154 | ```js 155 | { 156 | type: 'root', 157 | children: [ 158 | { 159 | type: 'paragraph', 160 | children: [ 161 | {type: 'text', value: 'A lovely language know as '}, 162 | { 163 | type: 'textDirective', 164 | name: 'abbr', 165 | attributes: {title: 'HyperText Markup Language'}, 166 | children: [{type: 'text', value: 'HTML'}] 167 | }, 168 | {type: 'text', value: '.'} 169 | ] 170 | } 171 | ] 172 | } 173 | ``` 174 | 175 | ```markdown 176 | A lovely language know as :abbr[HTML]{title="HyperText Markup Language"}. 177 | ``` 178 | 179 | ## API 180 | 181 | This package exports the identifiers 182 | [`directiveFromMarkdown`][api-directive-from-markdown] and 183 | [`directiveToMarkdown`][api-directive-to-markdown]. 184 | There is no default export. 185 | 186 | ### `directiveFromMarkdown()` 187 | 188 | Create an extension for 189 | [`mdast-util-from-markdown`][github-mdast-util-from-markdown] 190 | to enable directives in markdown. 191 | 192 | ###### Returns 193 | 194 | Extension for `mdast-util-from-markdown` to enable directives 195 | ([`FromMarkdownExtension`][github-mdast-from-markdown-extension]). 196 | 197 | ### `directiveToMarkdown(options?)` 198 | 199 | Create an extension for 200 | [`mdast-util-to-markdown`][github-mdast-util-to-markdown] 201 | to enable directives in markdown. 202 | 203 | ###### Parameters 204 | 205 | * `options` 206 | ([`ToMarkdownOptions`][api-to-markdown-options], optional) 207 | — configuration 208 | 209 | ###### Returns 210 | 211 | Extension for `mdast-util-to-markdown` to enable directives 212 | ([`ToMarkdownExtension`][github-mdast-to-markdown-extension]). 213 | 214 | ### `ContainerDirective` 215 | 216 | Directive in flow content 217 | (such as in the root document or block quotes), 218 | which contains further flow content 219 | (TypeScript type). 220 | 221 | ###### Type 222 | 223 | ```ts 224 | import type {BlockContent, DefinitionContent, Parent} from 'mdast' 225 | 226 | interface ContainerDirective extends Parent { 227 | type: 'containerDirective' 228 | name: string 229 | attributes?: Record | null | undefined 230 | children: Array 231 | } 232 | ``` 233 | 234 | ### `Directives` 235 | 236 | The different directive nodes 237 | (TypeScript type). 238 | 239 | ###### Type 240 | 241 | ```ts 242 | type Directives = ContainerDirective | LeafDirective | TextDirective 243 | ``` 244 | 245 | ### `LeafDirective` 246 | 247 | Directive in flow content 248 | (such as in the root document or block quotes), 249 | which contains nothing 250 | (TypeScript type). 251 | 252 | ###### Type 253 | 254 | ```ts 255 | import type {PhrasingContent, Parent} from 'mdast' 256 | 257 | interface LeafDirective extends Parent { 258 | type: 'leafDirective' 259 | name: string 260 | attributes?: Record | null | undefined 261 | children: Array 262 | } 263 | ``` 264 | 265 | ### `TextDirective` 266 | 267 | Directive in phrasing content 268 | (such as in paragraphs and headings) 269 | (TypeScript type). 270 | 271 | ###### Type 272 | 273 | ```ts 274 | import type {PhrasingContent, Parent} from 'mdast' 275 | 276 | interface TextDirective extends Parent { 277 | type: 'textDirective' 278 | name: string 279 | attributes?: Record | null | undefined 280 | children: Array 281 | } 282 | ``` 283 | 284 | ### `ToMarkdownOptions` 285 | 286 | Configuration. 287 | 288 | ###### Parameters 289 | 290 | * `collapseEmptyAttributes` 291 | (`boolean`, default: `true`) 292 | — collapse empty attributes: get `title` instead of `title=""` 293 | * `preferShortcut` 294 | (`boolean`, default: `true`) 295 | — prefer `#` and `.` shortcuts for `id` and `class` 296 | * `preferUnquoted` 297 | (`boolean`, default: `false`) 298 | — leave attributes unquoted if that results in less bytes 299 | * `quoteSmart` 300 | (`boolean`, default: `false`) 301 | — use the other quote if that results in less bytes 302 | * `quote` 303 | (`'"'` or `"'"`, 304 | default: the [`quote`][github-mdast-util-to-markdown-quote] 305 | used by `mdast-util-to-markdown` for titles) 306 | — preferred quote to use around attribute values 307 | 308 | ## HTML 309 | 310 | This utility does not handle how markdown is turned to HTML. 311 | You can use this with some more code to match your specific needs, 312 | to allow for anything from callouts, 313 | citations, 314 | styled blocks, 315 | forms, 316 | embeds, 317 | spoilers, 318 | etc. 319 | [Traverse the tree][unifiedjs-tree-traversal] to change directives to whatever 320 | you please. 321 | 322 | ## Syntax 323 | 324 | See [Syntax in 325 | `micromark-extension-directive`][github-micromark-extension-directive-syntax]. 326 | 327 | ## Syntax tree 328 | 329 | The following interfaces are added to **[mdast][github-mdast]** by this utility. 330 | 331 | ### Nodes 332 | 333 | #### `TextDirective` 334 | 335 | ```idl 336 | interface TextDirective <: Parent { 337 | type: 'textDirective' 338 | children: [PhrasingContent] 339 | } 340 | 341 | TextDirective includes Directive 342 | ``` 343 | 344 | **TextDirective** (**[Parent][github-mdast-parent]**) is a directive. 345 | It can be used where **[phrasing][github-mdast-phrasing-content]** content is 346 | expected. 347 | Its content model is also **[phrasing][github-mdast-phrasing-content]** 348 | content. 349 | It includes the mixin **[Directive][syntax-tree-mixin-directive]**. 350 | 351 | For example, 352 | the following Markdown: 353 | 354 | ```markdown 355 | :name[Label]{#x.y.z key=value} 356 | ``` 357 | 358 | Yields: 359 | 360 | ```js 361 | { 362 | type: 'textDirective', 363 | name: 'name', 364 | attributes: {id: 'x', class: 'y z', key: 'value'}, 365 | children: [{type: 'text', value: 'Label'}] 366 | } 367 | ``` 368 | 369 | #### `LeafDirective` 370 | 371 | ```idl 372 | interface LeafDirective <: Parent { 373 | type: 'leafDirective' 374 | children: [PhrasingContent] 375 | } 376 | 377 | LeafDirective includes Directive 378 | ``` 379 | 380 | **LeafDirective** (**[Parent][github-mdast-parent]**) is a directive. 381 | It can be used where **[flow][github-mdast-flow-content]** content is expected. 382 | Its content model is **[phrasing][github-mdast-phrasing-content]** content. 383 | It includes the mixin **[Directive][syntax-tree-mixin-directive]**. 384 | 385 | For example, 386 | the following Markdown: 387 | 388 | ```markdown 389 | ::youtube[Label]{v=123} 390 | ``` 391 | 392 | Yields: 393 | 394 | ```js 395 | { 396 | type: 'leafDirective', 397 | name: 'youtube', 398 | attributes: {v: '123'}, 399 | children: [{type: 'text', value: 'Label'}] 400 | } 401 | ``` 402 | 403 | #### `ContainerDirective` 404 | 405 | ```idl 406 | interface ContainerDirective <: Parent { 407 | type: 'containerDirective' 408 | children: [FlowContent] 409 | } 410 | 411 | ContainerDirective includes Directive 412 | ``` 413 | 414 | **ContainerDirective** (**[Parent][github-mdast-parent]**) is a directive. 415 | It can be used where **[flow][github-mdast-flow-content]** content is expected. 416 | Its content model is also **[flow][github-mdast-flow-content]** content. 417 | It includes the mixin **[Directive][syntax-tree-mixin-directive]**. 418 | 419 | The phrasing in the label is, 420 | when available, 421 | added as a paragraph with a `directiveLabel: true` field, 422 | as the head of its content. 423 | 424 | For example, 425 | the following Markdown: 426 | 427 | ```markdown 428 | :::spoiler[Open at your own peril] 429 | He dies. 430 | ::: 431 | ``` 432 | 433 | Yields: 434 | 435 | ```js 436 | { 437 | type: 'containerDirective', 438 | name: 'spoiler', 439 | attributes: {}, 440 | children: [ 441 | { 442 | type: 'paragraph', 443 | data: {directiveLabel: true}, 444 | children: [{type: 'text', value: 'Open at your own peril'}] 445 | }, 446 | { 447 | type: 'paragraph', 448 | children: [{type: 'text', value: 'He dies.'}] 449 | } 450 | ] 451 | } 452 | ``` 453 | 454 | ### Mixin 455 | 456 | #### `Directive` 457 | 458 | ```idl 459 | interface mixin Directive { 460 | name: string 461 | attributes: Attributes? 462 | } 463 | 464 | interface Attributes {} 465 | typedef string AttributeName 466 | typedef string AttributeValue 467 | ``` 468 | 469 | **Directive** represents something defined by an extension. 470 | 471 | The `name` field must be present and represents an identifier of an extension. 472 | 473 | The `attributes` field represents information associated with the node. 474 | The value of the `attributes` field implements the **Attributes** interface. 475 | 476 | In the **Attributes** interface, 477 | every field must be an `AttributeName` and every value an `AttributeValue`. 478 | The fields and values can be anything: 479 | there are no semantics (such as by HTML or hast). 480 | 481 | > In JSON, 482 | > the value `null` must be treated as if the attribute was not included. 483 | > In JavaScript, 484 | > both `null` and `undefined` must be similarly ignored. 485 | 486 | ## Types 487 | 488 | This package is fully typed with [TypeScript][]. 489 | It exports the additional types [`ContainerDirective`][api-container-directive], 490 | [`Directives`][api-directives], 491 | [`LeafDirective`][api-leaf-directive], 492 | and 493 | [`TextDirective`][api-text-directive]. 494 | 495 | It also registers the node types with `@types/mdast`. 496 | If you’re working with the syntax tree, 497 | make sure to import this utility somewhere in your types, 498 | as that registers the new node types in the tree. 499 | 500 | ```js 501 | /** 502 | * @import {} from 'mdast-util-directive' 503 | * @import {Root} from 'mdast' 504 | */ 505 | 506 | import {visit} from 'unist-util-visit' 507 | 508 | /** @type {Root} */ 509 | const tree = getMdastNodeSomeHow() 510 | 511 | visit(tree, function (node) { 512 | // `node` can now be one of the nodes for directives. 513 | }) 514 | ``` 515 | 516 | ## Compatibility 517 | 518 | Projects maintained by the unified collective are compatible with maintained 519 | versions of Node.js. 520 | 521 | When we cut a new major release, 522 | we drop support for unmaintained versions of Node. 523 | This means we try to keep the current release line, 524 | `mdast-util-directive@3`, 525 | compatible with Node.js 16. 526 | 527 | This utility works with `mdast-util-from-markdown` version 2+ and 528 | `mdast-util-to-markdown` version 2+. 529 | 530 | ## Related 531 | 532 | * [`remark-directive`][github-remark-directive] 533 | — remark plugin to support generic directives 534 | * [`micromark-extension-directive`][github-micromark-extension-directive] 535 | — micromark extension to parse directives 536 | 537 | ## Contribute 538 | 539 | See [`contributing.md`][health-contributing] 540 | in 541 | [`syntax-tree/.github`][health] 542 | for ways to get started. 543 | See [`support.md`][health-support] for ways to get help. 544 | 545 | This project has a [code of conduct][health-coc]. 546 | By interacting with this repository, 547 | organization, 548 | or community you agree to abide by its terms. 549 | 550 | ## License 551 | 552 | [MIT][file-license] © [Titus Wormer][wooorm] 553 | 554 | 555 | 556 | [api-container-directive]: #containerdirective 557 | 558 | [api-directive-from-markdown]: #directivefrommarkdown 559 | 560 | [api-directive-to-markdown]: #directivetomarkdownoptions 561 | 562 | [api-directives]: #directives 563 | 564 | [api-leaf-directive]: #leafdirective 565 | 566 | [api-text-directive]: #textdirective 567 | 568 | [api-to-markdown-options]: #tomarkdownoptions 569 | 570 | [badge-build-image]: https://github.com/syntax-tree/mdast-util-directive/workflows/main/badge.svg 571 | 572 | [badge-build-url]: https://github.com/syntax-tree/mdast-util-directive/actions 573 | 574 | [badge-coverage-image]: https://img.shields.io/codecov/c/github/syntax-tree/mdast-util-directive.svg 575 | 576 | [badge-coverage-url]: https://codecov.io/github/syntax-tree/mdast-util-directive 577 | 578 | [badge-downloads-image]: https://img.shields.io/npm/dm/mdast-util-directive.svg 579 | 580 | [badge-downloads-url]: https://www.npmjs.com/package/mdast-util-directive 581 | 582 | [badge-size-image]: https://img.shields.io/bundlejs/size/mdast-util-directive 583 | 584 | [badge-size-url]: https://bundlejs.com/?q=mdast-util-directive 585 | 586 | [commonmark-directive-proposal]: https://talk.commonmark.org/t/generic-directives-plugins-syntax/444 587 | 588 | [esmsh]: https://esm.sh 589 | 590 | [file-license]: license 591 | 592 | [github-gist-esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 593 | 594 | [github-mdast]: https://github.com/syntax-tree/mdast 595 | 596 | [github-mdast-flow-content]: https://github.com/syntax-tree/mdast#flowcontent 597 | 598 | [github-mdast-from-markdown-extension]: https://github.com/syntax-tree/mdast-util-from-markdown#extension 599 | 600 | [github-mdast-parent]: https://github.com/syntax-tree/mdast#parent 601 | 602 | [github-mdast-phrasing-content]: https://github.com/syntax-tree/mdast#phrasingcontent 603 | 604 | [github-mdast-to-markdown-extension]: https://github.com/syntax-tree/mdast-util-to-markdown#options 605 | 606 | [github-mdast-util-from-markdown]: https://github.com/syntax-tree/mdast-util-from-markdown 607 | 608 | [github-mdast-util-to-markdown]: https://github.com/syntax-tree/mdast-util-to-markdown 609 | 610 | [github-mdast-util-to-markdown-quote]: https://github.com/syntax-tree/mdast-util-to-markdown#optionsquote 611 | 612 | [github-micromark]: https://github.com/micromark/micromark 613 | 614 | [github-micromark-extending]: https://github.com/micromark/micromark#extending-markdown 615 | 616 | [github-micromark-extension-directive]: https://github.com/micromark/micromark-extension-directive 617 | 618 | [github-micromark-extension-directive-syntax]: https://github.com/micromark/micromark-extension-directive#syntax 619 | 620 | [github-remark-directive]: https://github.com/remarkjs/remark-directive 621 | 622 | [health]: https://github.com/syntax-tree/.github 623 | 624 | [health-coc]: https://github.com/syntax-tree/.github/blob/main/code-of-conduct.md 625 | 626 | [health-contributing]: https://github.com/syntax-tree/.github/blob/main/contributing.md 627 | 628 | [health-support]: https://github.com/syntax-tree/.github/blob/main/support.md 629 | 630 | [npmjs-install]: https://docs.npmjs.com/cli/install 631 | 632 | [syntax-tree-mixin-directive]: #directive 633 | 634 | [typescript]: https://www.typescriptlang.org 635 | 636 | [unifiedjs-tree-traversal]: https://unifiedjs.com/learn/recipe/tree-traversal/ 637 | 638 | [wooorm]: https://wooorm.com 639 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | import {directive} from 'micromark-extension-directive' 4 | import {directiveFromMarkdown, directiveToMarkdown} from 'mdast-util-directive' 5 | import {fromMarkdown} from 'mdast-util-from-markdown' 6 | import {toMarkdown} from 'mdast-util-to-markdown' 7 | import {removePosition} from 'unist-util-remove-position' 8 | 9 | test('core', async function (t) { 10 | await t.test('should expose the public api', async function () { 11 | assert.deepEqual(Object.keys(await import('mdast-util-directive')).sort(), [ 12 | 'directiveFromMarkdown', 13 | 'directiveToMarkdown' 14 | ]) 15 | }) 16 | }) 17 | 18 | test('directiveFromMarkdown()', async function (t) { 19 | await t.test('should support directives (text)', async function () { 20 | assert.deepEqual( 21 | fromMarkdown('a :b[c]{d} e.', { 22 | extensions: [directive()], 23 | mdastExtensions: [directiveFromMarkdown()] 24 | }).children[0], 25 | { 26 | type: 'paragraph', 27 | children: [ 28 | { 29 | type: 'text', 30 | value: 'a ', 31 | position: { 32 | start: {line: 1, column: 1, offset: 0}, 33 | end: {line: 1, column: 3, offset: 2} 34 | } 35 | }, 36 | { 37 | type: 'textDirective', 38 | name: 'b', 39 | attributes: {d: ''}, 40 | children: [ 41 | { 42 | type: 'text', 43 | value: 'c', 44 | position: { 45 | start: {line: 1, column: 6, offset: 5}, 46 | end: {line: 1, column: 7, offset: 6} 47 | } 48 | } 49 | ], 50 | position: { 51 | start: {line: 1, column: 3, offset: 2}, 52 | end: {line: 1, column: 11, offset: 10} 53 | } 54 | }, 55 | { 56 | type: 'text', 57 | value: ' e.', 58 | position: { 59 | start: {line: 1, column: 11, offset: 10}, 60 | end: {line: 1, column: 14, offset: 13} 61 | } 62 | } 63 | ], 64 | position: { 65 | start: {line: 1, column: 1, offset: 0}, 66 | end: {line: 1, column: 14, offset: 13} 67 | } 68 | } 69 | ) 70 | }) 71 | 72 | await t.test('should support directives (leaf)', async function () { 73 | assert.deepEqual( 74 | fromMarkdown('::a[b]{c}', { 75 | extensions: [directive()], 76 | mdastExtensions: [directiveFromMarkdown()] 77 | }).children[0], 78 | { 79 | type: 'leafDirective', 80 | name: 'a', 81 | attributes: {c: ''}, 82 | children: [ 83 | { 84 | type: 'text', 85 | value: 'b', 86 | position: { 87 | start: {line: 1, column: 5, offset: 4}, 88 | end: {line: 1, column: 6, offset: 5} 89 | } 90 | } 91 | ], 92 | position: { 93 | start: {line: 1, column: 1, offset: 0}, 94 | end: {line: 1, column: 10, offset: 9} 95 | } 96 | } 97 | ) 98 | }) 99 | 100 | await t.test('should support directives (container)', async function () { 101 | assert.deepEqual( 102 | fromMarkdown(':::a[b]{c}\nd', { 103 | extensions: [directive()], 104 | mdastExtensions: [directiveFromMarkdown()] 105 | }).children[0], 106 | { 107 | type: 'containerDirective', 108 | name: 'a', 109 | attributes: {c: ''}, 110 | children: [ 111 | { 112 | type: 'paragraph', 113 | data: {directiveLabel: true}, 114 | children: [ 115 | { 116 | type: 'text', 117 | value: 'b', 118 | position: { 119 | start: {line: 1, column: 6, offset: 5}, 120 | end: {line: 1, column: 7, offset: 6} 121 | } 122 | } 123 | ], 124 | position: { 125 | start: {line: 1, column: 5, offset: 4}, 126 | end: {line: 1, column: 8, offset: 7} 127 | } 128 | }, 129 | { 130 | type: 'paragraph', 131 | children: [ 132 | { 133 | type: 'text', 134 | value: 'd', 135 | position: { 136 | start: {line: 2, column: 1, offset: 11}, 137 | end: {line: 2, column: 2, offset: 12} 138 | } 139 | } 140 | ], 141 | position: { 142 | start: {line: 2, column: 1, offset: 11}, 143 | end: {line: 2, column: 2, offset: 12} 144 | } 145 | } 146 | ], 147 | position: { 148 | start: {line: 1, column: 1, offset: 0}, 149 | end: {line: 2, column: 2, offset: 12} 150 | } 151 | } 152 | ) 153 | }) 154 | 155 | await t.test('should support content in a label', async function () { 156 | const tree = fromMarkdown(':a[b *c*\nd]', { 157 | extensions: [directive()], 158 | mdastExtensions: [directiveFromMarkdown()] 159 | }) 160 | 161 | removePosition(tree, {force: true}) 162 | 163 | assert.deepEqual(tree, { 164 | type: 'root', 165 | children: [ 166 | { 167 | type: 'paragraph', 168 | children: [ 169 | { 170 | type: 'textDirective', 171 | name: 'a', 172 | attributes: {}, 173 | children: [ 174 | {type: 'text', value: 'b '}, 175 | {type: 'emphasis', children: [{type: 'text', value: 'c'}]}, 176 | {type: 'text', value: '\nd'} 177 | ] 178 | } 179 | ] 180 | } 181 | ] 182 | }) 183 | }) 184 | 185 | await t.test('should support attributes', async function () { 186 | const tree = fromMarkdown(':a{#b.c.d e=f g="h&i&unknown;j"}', { 187 | extensions: [directive()], 188 | mdastExtensions: [directiveFromMarkdown()] 189 | }) 190 | 191 | removePosition(tree, {force: true}) 192 | 193 | assert.deepEqual(tree, { 194 | type: 'root', 195 | children: [ 196 | { 197 | type: 'paragraph', 198 | children: [ 199 | { 200 | type: 'textDirective', 201 | name: 'a', 202 | attributes: {id: 'b', class: 'c d', e: 'f', g: 'h&i&unknown;j'}, 203 | children: [] 204 | } 205 | ] 206 | } 207 | ] 208 | }) 209 | }) 210 | 211 | await t.test( 212 | 'should not support non-terminated character references', 213 | async function () { 214 | const tree = fromMarkdown(':a{b=¶m c="¶m" d=\'¶m\'}', { 215 | extensions: [directive()], 216 | mdastExtensions: [directiveFromMarkdown()] 217 | }) 218 | 219 | removePosition(tree, {force: true}) 220 | 221 | assert.deepEqual(tree, { 222 | type: 'root', 223 | children: [ 224 | { 225 | type: 'paragraph', 226 | children: [ 227 | { 228 | type: 'textDirective', 229 | name: 'a', 230 | attributes: {b: '¶m', c: '¶m', d: '¶m'}, 231 | children: [] 232 | } 233 | ] 234 | } 235 | ] 236 | }) 237 | } 238 | ) 239 | 240 | await t.test('should support EOLs in attributes', async function () { 241 | const tree = fromMarkdown(':a{b\nc="d\ne"}', { 242 | extensions: [directive()], 243 | mdastExtensions: [directiveFromMarkdown()] 244 | }) 245 | 246 | removePosition(tree, {force: true}) 247 | 248 | assert.deepEqual(tree, { 249 | type: 'root', 250 | children: [ 251 | { 252 | type: 'paragraph', 253 | children: [ 254 | { 255 | type: 'textDirective', 256 | name: 'a', 257 | attributes: {b: '', c: 'd\ne'}, 258 | children: [] 259 | } 260 | ] 261 | } 262 | ] 263 | }) 264 | }) 265 | 266 | await t.test('should support directives in directives', async function () { 267 | const tree = fromMarkdown('::::a\n:::b\n:c\n:::\n::::', { 268 | extensions: [directive()], 269 | mdastExtensions: [directiveFromMarkdown()] 270 | }) 271 | 272 | removePosition(tree, {force: true}) 273 | 274 | assert.deepEqual(tree, { 275 | type: 'root', 276 | children: [ 277 | { 278 | type: 'containerDirective', 279 | name: 'a', 280 | attributes: {}, 281 | children: [ 282 | { 283 | type: 'containerDirective', 284 | name: 'b', 285 | attributes: {}, 286 | children: [ 287 | { 288 | type: 'paragraph', 289 | children: [ 290 | { 291 | type: 'textDirective', 292 | name: 'c', 293 | attributes: {}, 294 | children: [] 295 | } 296 | ] 297 | } 298 | ] 299 | } 300 | ] 301 | } 302 | ] 303 | }) 304 | }) 305 | }) 306 | 307 | test('directiveToMarkdown()', async function (t) { 308 | await t.test( 309 | 'should try to serialize a directive (text) w/o `name`', 310 | async function () { 311 | assert.deepEqual( 312 | toMarkdown( 313 | { 314 | type: 'paragraph', 315 | children: [ 316 | {type: 'text', value: 'a '}, 317 | // @ts-expect-error: check how the runtime handles `children`, `name` missing. 318 | {type: 'textDirective'}, 319 | {type: 'text', value: ' b.'} 320 | ] 321 | }, 322 | {extensions: [directiveToMarkdown()]} 323 | ), 324 | 'a : b.\n' 325 | ) 326 | } 327 | ) 328 | 329 | await t.test( 330 | 'should serialize a directive (text) w/ `name`', 331 | async function () { 332 | assert.deepEqual( 333 | toMarkdown( 334 | { 335 | type: 'paragraph', 336 | children: [ 337 | {type: 'text', value: 'a '}, 338 | // @ts-expect-error: check how the runtime handles `children` missing. 339 | {type: 'textDirective', name: 'b'}, 340 | {type: 'text', value: ' c.'} 341 | ] 342 | }, 343 | {extensions: [directiveToMarkdown()]} 344 | ), 345 | 'a :b c.\n' 346 | ) 347 | } 348 | ) 349 | 350 | await t.test( 351 | 'should serialize a directive (text) w/ `children`', 352 | async function () { 353 | assert.deepEqual( 354 | toMarkdown( 355 | { 356 | type: 'paragraph', 357 | children: [ 358 | {type: 'text', value: 'a '}, 359 | { 360 | type: 'textDirective', 361 | name: 'b', 362 | children: [{type: 'text', value: 'c'}] 363 | }, 364 | {type: 'text', value: ' d.'} 365 | ] 366 | }, 367 | {extensions: [directiveToMarkdown()]} 368 | ), 369 | 'a :b[c] d.\n' 370 | ) 371 | } 372 | ) 373 | 374 | await t.test( 375 | 'should escape brackets in a directive (text) label', 376 | async function () { 377 | assert.deepEqual( 378 | toMarkdown( 379 | { 380 | type: 'paragraph', 381 | children: [ 382 | {type: 'text', value: 'a '}, 383 | { 384 | type: 'textDirective', 385 | name: 'b', 386 | children: [{type: 'text', value: 'c[d]e'}] 387 | }, 388 | {type: 'text', value: ' f.'} 389 | ] 390 | }, 391 | {extensions: [directiveToMarkdown()]} 392 | ), 393 | 'a :b[c\\[d\\]e] f.\n' 394 | ) 395 | } 396 | ) 397 | 398 | await t.test( 399 | 'should support EOLs in a directive (text) label', 400 | async function () { 401 | assert.deepEqual( 402 | toMarkdown( 403 | { 404 | type: 'paragraph', 405 | children: [ 406 | {type: 'text', value: 'a '}, 407 | { 408 | type: 'textDirective', 409 | name: 'b', 410 | children: [{type: 'text', value: 'c\nd'}] 411 | }, 412 | {type: 'text', value: ' e.'} 413 | ] 414 | }, 415 | {extensions: [directiveToMarkdown()]} 416 | ), 417 | 'a :b[c\nd] e.\n' 418 | ) 419 | } 420 | ) 421 | 422 | await t.test( 423 | 'should serialize a directive (text) w/ `attributes`', 424 | async function () { 425 | assert.deepEqual( 426 | toMarkdown( 427 | { 428 | type: 'paragraph', 429 | children: [ 430 | {type: 'text', value: 'a '}, 431 | { 432 | type: 'textDirective', 433 | name: 'b', 434 | attributes: { 435 | c: 'd', 436 | e: 'f', 437 | g: '', 438 | h: null, 439 | i: undefined, 440 | // @ts-expect-error: check how the runtime handles `number`s 441 | j: 2 442 | }, 443 | children: [] 444 | }, 445 | {type: 'text', value: ' k.'} 446 | ] 447 | }, 448 | {extensions: [directiveToMarkdown()]} 449 | ), 450 | 'a :b{c="d" e="f" g j="2"} k.\n' 451 | ) 452 | } 453 | ) 454 | 455 | await t.test( 456 | 'should serialize a directive (text) w/ `id`, `class` attributes', 457 | async function () { 458 | assert.deepEqual( 459 | toMarkdown( 460 | { 461 | type: 'paragraph', 462 | children: [ 463 | {type: 'text', value: 'a '}, 464 | { 465 | type: 'textDirective', 466 | name: 'b', 467 | attributes: {class: 'a b\nc', id: 'd', key: 'value'}, 468 | children: [] 469 | }, 470 | {type: 'text', value: ' k.'} 471 | ] 472 | }, 473 | {extensions: [directiveToMarkdown()]} 474 | ), 475 | 'a :b{#d .a.b.c key="value"} k.\n' 476 | ) 477 | } 478 | ) 479 | 480 | await t.test( 481 | 'should encode the quote in an attribute value (text)', 482 | async function () { 483 | assert.deepEqual( 484 | toMarkdown( 485 | { 486 | type: 'paragraph', 487 | children: [ 488 | {type: 'text', value: 'a '}, 489 | { 490 | type: 'textDirective', 491 | name: 'b', 492 | attributes: {x: 'y"\'\r\nz'}, 493 | children: [] 494 | }, 495 | {type: 'text', value: ' k.'} 496 | ] 497 | }, 498 | {extensions: [directiveToMarkdown()]} 499 | ), 500 | 'a :b{x="y"\'\r\nz"} k.\n' 501 | ) 502 | } 503 | ) 504 | 505 | await t.test( 506 | 'should encode the quote in an attribute value (text)', 507 | async function () { 508 | assert.deepEqual( 509 | toMarkdown( 510 | { 511 | type: 'paragraph', 512 | children: [ 513 | {type: 'text', value: 'a '}, 514 | { 515 | type: 'textDirective', 516 | name: 'b', 517 | attributes: {x: 'y"\'\r\nz'}, 518 | children: [] 519 | }, 520 | {type: 'text', value: ' k.'} 521 | ] 522 | }, 523 | {extensions: [directiveToMarkdown()]} 524 | ), 525 | 'a :b{x="y"\'\r\nz"} k.\n' 526 | ) 527 | } 528 | ) 529 | 530 | await t.test( 531 | 'should not use the `id` shortcut if impossible characters exist', 532 | async function () { 533 | assert.deepEqual( 534 | toMarkdown( 535 | { 536 | type: 'paragraph', 537 | children: [ 538 | {type: 'text', value: 'a '}, 539 | { 540 | type: 'textDirective', 541 | name: 'b', 542 | attributes: {id: 'c#d'}, 543 | children: [] 544 | }, 545 | {type: 'text', value: ' e.'} 546 | ] 547 | }, 548 | {extensions: [directiveToMarkdown()]} 549 | ), 550 | 'a :b{id="c#d"} e.\n' 551 | ) 552 | } 553 | ) 554 | 555 | await t.test( 556 | 'should not use the `class` shortcut if impossible characters exist', 557 | async function () { 558 | assert.deepEqual( 559 | toMarkdown( 560 | { 561 | type: 'paragraph', 562 | children: [ 563 | {type: 'text', value: 'a '}, 564 | { 565 | type: 'textDirective', 566 | name: 'b', 567 | attributes: {class: 'c.d e :::b\n> c\n> :::\n::::\n' 961 | ) 962 | } 963 | ) 964 | 965 | await t.test( 966 | 'should escape a `:` in phrasing when followed by an alpha', 967 | async function () { 968 | assert.deepEqual( 969 | toMarkdown( 970 | { 971 | type: 'paragraph', 972 | children: [{type: 'text', value: 'a:b'}] 973 | }, 974 | {extensions: [directiveToMarkdown()]} 975 | ), 976 | 'a\\:b\n' 977 | ) 978 | } 979 | ) 980 | 981 | await t.test( 982 | 'should not escape a `:` in phrasing when followed by a non-alpha', 983 | async function () { 984 | assert.deepEqual( 985 | toMarkdown( 986 | { 987 | type: 'paragraph', 988 | children: [{type: 'text', value: 'a:9'}] 989 | }, 990 | {extensions: [directiveToMarkdown()]} 991 | ), 992 | 'a:9\n' 993 | ) 994 | } 995 | ) 996 | 997 | await t.test( 998 | 'should not escape a `:` in phrasing when preceded by a colon', 999 | async function () { 1000 | assert.deepEqual( 1001 | toMarkdown( 1002 | { 1003 | type: 'paragraph', 1004 | children: [{type: 'text', value: 'a::c'}] 1005 | }, 1006 | {extensions: [directiveToMarkdown()]} 1007 | ), 1008 | 'a::c\n' 1009 | ) 1010 | } 1011 | ) 1012 | 1013 | await t.test('should not escape a `:` at a break', async function () { 1014 | assert.deepEqual( 1015 | toMarkdown( 1016 | { 1017 | type: 'paragraph', 1018 | children: [{type: 'text', value: ':\na'}] 1019 | }, 1020 | {extensions: [directiveToMarkdown()]} 1021 | ), 1022 | ':\na\n' 1023 | ) 1024 | }) 1025 | 1026 | await t.test( 1027 | 'should not escape a `:` at a break when followed by an alpha', 1028 | async function () { 1029 | assert.deepEqual( 1030 | toMarkdown( 1031 | { 1032 | type: 'paragraph', 1033 | children: [{type: 'text', value: ':a'}] 1034 | }, 1035 | {extensions: [directiveToMarkdown()]} 1036 | ), 1037 | '\\:a\n' 1038 | ) 1039 | } 1040 | ) 1041 | 1042 | await t.test( 1043 | 'should escape a `:` at a break when followed by a colon', 1044 | async function () { 1045 | assert.deepEqual( 1046 | toMarkdown( 1047 | { 1048 | type: 'paragraph', 1049 | children: [{type: 'text', value: '::\na'}] 1050 | }, 1051 | {extensions: [directiveToMarkdown()]} 1052 | ), 1053 | '\\::\na\n' 1054 | ) 1055 | } 1056 | ) 1057 | 1058 | await t.test( 1059 | 'should escape a `:` at a break when followed by two colons', 1060 | async function () { 1061 | assert.deepEqual( 1062 | toMarkdown( 1063 | { 1064 | type: 'paragraph', 1065 | children: [{type: 'text', value: ':::\na'}] 1066 | }, 1067 | {extensions: [directiveToMarkdown()]} 1068 | ), 1069 | '\\:::\na\n' 1070 | ) 1071 | } 1072 | ) 1073 | 1074 | await t.test( 1075 | 'should escape a `:` at a break when followed by two colons', 1076 | async function () { 1077 | assert.deepEqual( 1078 | toMarkdown( 1079 | { 1080 | type: 'paragraph', 1081 | children: [{type: 'text', value: ':::\na'}] 1082 | }, 1083 | {extensions: [directiveToMarkdown()]} 1084 | ), 1085 | '\\:::\na\n' 1086 | ) 1087 | } 1088 | ) 1089 | 1090 | await t.test('should escape a `:` after a text directive', async function () { 1091 | assert.deepEqual( 1092 | toMarkdown( 1093 | { 1094 | type: 'paragraph', 1095 | children: [ 1096 | {type: 'textDirective', name: 'red', children: []}, 1097 | {type: 'text', value: ':'} 1098 | ] 1099 | }, 1100 | {extensions: [directiveToMarkdown()]} 1101 | ), 1102 | ':red:\n' 1103 | ) 1104 | }) 1105 | 1106 | await t.test( 1107 | 'should quote attribute values with double quotes by default', 1108 | async function () { 1109 | assert.deepEqual( 1110 | toMarkdown( 1111 | { 1112 | type: 'textDirective', 1113 | name: 'i', 1114 | attributes: {title: 'a'}, 1115 | children: [] 1116 | }, 1117 | {extensions: [directiveToMarkdown()]} 1118 | ), 1119 | ':i{title="a"}\n' 1120 | ) 1121 | } 1122 | ) 1123 | 1124 | await t.test('collapseEmptyAttributes', async function (t) { 1125 | await t.test( 1126 | 'should hide empty string attributes by default', 1127 | async function () { 1128 | assert.deepEqual( 1129 | toMarkdown( 1130 | { 1131 | type: 'textDirective', 1132 | name: 'i', 1133 | attributes: {title: ''}, 1134 | children: [] 1135 | }, 1136 | {extensions: [directiveToMarkdown()]} 1137 | ), 1138 | ':i{title}\n' 1139 | ) 1140 | } 1141 | ) 1142 | 1143 | await t.test( 1144 | 'should hide empty string attributes w/ `collapseEmptyAttributes: true`', 1145 | async function () { 1146 | assert.deepEqual( 1147 | toMarkdown( 1148 | { 1149 | type: 'textDirective', 1150 | name: 'i', 1151 | attributes: {title: ''}, 1152 | children: [] 1153 | }, 1154 | {extensions: [directiveToMarkdown({collapseEmptyAttributes: true})]} 1155 | ), 1156 | ':i{title}\n' 1157 | ) 1158 | } 1159 | ) 1160 | 1161 | await t.test( 1162 | 'should show empty string attributes w/ `collapseEmptyAttributes: false`', 1163 | async function () { 1164 | assert.deepEqual( 1165 | toMarkdown( 1166 | { 1167 | type: 'textDirective', 1168 | name: 'i', 1169 | attributes: {title: ''}, 1170 | children: [] 1171 | }, 1172 | { 1173 | extensions: [ 1174 | directiveToMarkdown({collapseEmptyAttributes: false}) 1175 | ] 1176 | } 1177 | ), 1178 | ':i{title=""}\n' 1179 | ) 1180 | } 1181 | ) 1182 | 1183 | await t.test( 1184 | 'should use quotes for empty string attributes w/ `collapseEmptyAttributes: false` and `preferUnquoted: true`', 1185 | async function () { 1186 | assert.deepEqual( 1187 | toMarkdown( 1188 | { 1189 | type: 'textDirective', 1190 | name: 'i', 1191 | attributes: {title: ''}, 1192 | children: [] 1193 | }, 1194 | { 1195 | extensions: [ 1196 | directiveToMarkdown({ 1197 | collapseEmptyAttributes: false, 1198 | preferUnquoted: true 1199 | }) 1200 | ] 1201 | } 1202 | ), 1203 | ':i{title=""}\n' 1204 | ) 1205 | } 1206 | ) 1207 | }) 1208 | 1209 | await t.test('preferShortcut', async function (t) { 1210 | await t.test( 1211 | 'should use `#` for `id`, `.` for `class` by default', 1212 | async function () { 1213 | assert.deepEqual( 1214 | toMarkdown( 1215 | { 1216 | type: 'textDirective', 1217 | name: 'i', 1218 | attributes: {class: 'a b', id: 'c'}, 1219 | children: [] 1220 | }, 1221 | {extensions: [directiveToMarkdown()]} 1222 | ), 1223 | ':i{#c .a.b}\n' 1224 | ) 1225 | } 1226 | ) 1227 | 1228 | await t.test( 1229 | 'should use `#` for `id`, `.` for `class` w/ `preferShortcut: true`', 1230 | async function () { 1231 | assert.deepEqual( 1232 | toMarkdown( 1233 | { 1234 | type: 'textDirective', 1235 | name: 'i', 1236 | attributes: {class: 'a b', id: 'c'}, 1237 | children: [] 1238 | }, 1239 | {extensions: [directiveToMarkdown({preferShortcut: true})]} 1240 | ), 1241 | ':i{#c .a.b}\n' 1242 | ) 1243 | } 1244 | ) 1245 | 1246 | await t.test( 1247 | 'should not use use `#` for `id`, `.` for `class` w/ `preferShortcut: false`', 1248 | async function () { 1249 | assert.deepEqual( 1250 | toMarkdown( 1251 | { 1252 | type: 'textDirective', 1253 | name: 'i', 1254 | attributes: {class: 'a b', id: 'c'}, 1255 | children: [] 1256 | }, 1257 | {extensions: [directiveToMarkdown({preferShortcut: false})]} 1258 | ), 1259 | ':i{id="c" class="a b"}\n' 1260 | ) 1261 | } 1262 | ) 1263 | }) 1264 | 1265 | await t.test('preferUnquoted', async function (t) { 1266 | await t.test('should omit quotes in `preferUnquoted`', async function () { 1267 | assert.deepEqual( 1268 | toMarkdown( 1269 | { 1270 | type: 'textDirective', 1271 | name: 'i', 1272 | attributes: {title: 'a'}, 1273 | children: [] 1274 | }, 1275 | {extensions: [directiveToMarkdown({preferUnquoted: true})]} 1276 | ), 1277 | ':i{title=a}\n' 1278 | ) 1279 | }) 1280 | 1281 | await t.test( 1282 | 'should keep quotes in `preferUnquoted` and impossible', 1283 | async function () { 1284 | assert.deepEqual( 1285 | toMarkdown( 1286 | { 1287 | type: 'textDirective', 1288 | name: 'i', 1289 | attributes: {title: 'a b'}, 1290 | children: [] 1291 | }, 1292 | {extensions: [directiveToMarkdown({preferUnquoted: true})]} 1293 | ), 1294 | ':i{title="a b"}\n' 1295 | ) 1296 | } 1297 | ) 1298 | 1299 | await t.test( 1300 | 'should not add `=` when omitting quotes on empty values', 1301 | async function () { 1302 | assert.deepEqual( 1303 | toMarkdown( 1304 | { 1305 | type: 'textDirective', 1306 | name: 'i', 1307 | attributes: {title: ''}, 1308 | children: [] 1309 | }, 1310 | {extensions: [directiveToMarkdown({preferUnquoted: true})]} 1311 | ), 1312 | ':i{title}\n' 1313 | ) 1314 | } 1315 | ) 1316 | }) 1317 | 1318 | await t.test('quoteSmart', async function (t) { 1319 | await t.test( 1320 | 'should quote attribute values with primary quotes if they occur less than the alternative', 1321 | async function () { 1322 | assert.deepEqual( 1323 | toMarkdown( 1324 | { 1325 | type: 'textDirective', 1326 | name: 'i', 1327 | attributes: {title: "'\"a'"}, 1328 | children: [] 1329 | }, 1330 | {extensions: [directiveToMarkdown({quoteSmart: true})]} 1331 | ), 1332 | ':i{title="\'"a\'"}\n' 1333 | ) 1334 | } 1335 | ) 1336 | 1337 | await t.test( 1338 | 'should quote attribute values with primary quotes if they occur as much as alternatives (#1)', 1339 | async function () { 1340 | assert.deepEqual( 1341 | toMarkdown( 1342 | { 1343 | type: 'textDirective', 1344 | name: 'i', 1345 | attributes: {title: '"a\''}, 1346 | children: [] 1347 | }, 1348 | {extensions: [directiveToMarkdown({quoteSmart: true})]} 1349 | ), 1350 | ':i{title=""a\'"}\n' 1351 | ) 1352 | } 1353 | ) 1354 | 1355 | await t.test( 1356 | 'should quote attribute values with primary quotes if they occur as much as alternatives (#2)', 1357 | async function () { 1358 | assert.deepEqual( 1359 | toMarkdown( 1360 | { 1361 | type: 'textDirective', 1362 | name: 'i', 1363 | attributes: {title: '"\'a\'"'}, 1364 | children: [] 1365 | }, 1366 | {extensions: [directiveToMarkdown({quoteSmart: true})]} 1367 | ), 1368 | ':i{title=""\'a\'""}\n' 1369 | ) 1370 | } 1371 | ) 1372 | 1373 | await t.test( 1374 | 'should quote attribute values with alternative quotes if the primary occurs', 1375 | async function () { 1376 | assert.deepEqual( 1377 | toMarkdown( 1378 | { 1379 | type: 'textDirective', 1380 | name: 'i', 1381 | attributes: {title: '"a"'}, 1382 | children: [] 1383 | }, 1384 | {extensions: [directiveToMarkdown({quoteSmart: true})]} 1385 | ), 1386 | ':i{title=\'"a"\'}\n' 1387 | ) 1388 | } 1389 | ) 1390 | 1391 | await t.test( 1392 | 'should quote attribute values with alternative quotes if they occur less than the primary', 1393 | async function () { 1394 | assert.deepEqual( 1395 | toMarkdown( 1396 | { 1397 | type: 'textDirective', 1398 | name: 'i', 1399 | attributes: {title: '"\'a"'}, 1400 | children: [] 1401 | }, 1402 | {extensions: [directiveToMarkdown({quoteSmart: true})]} 1403 | ), 1404 | ':i{title=\'"'a"\'}\n' 1405 | ) 1406 | } 1407 | ) 1408 | }) 1409 | 1410 | await t.test('quote', async function (t) { 1411 | await t.test( 1412 | "should quote attribute values with single quotes if `quote: '\\''`", 1413 | async function () { 1414 | assert.deepEqual( 1415 | toMarkdown( 1416 | { 1417 | type: 'textDirective', 1418 | name: 'i', 1419 | attributes: {title: 'a'}, 1420 | children: [] 1421 | }, 1422 | {extensions: [directiveToMarkdown({quote: "'"})]} 1423 | ), 1424 | ":i{title='a'}\n" 1425 | ) 1426 | } 1427 | ) 1428 | 1429 | await t.test( 1430 | "should quote attribute values with double quotes if `quote: '\\\"'`", 1431 | async function () { 1432 | assert.deepEqual( 1433 | toMarkdown( 1434 | { 1435 | type: 'textDirective', 1436 | name: 'i', 1437 | attributes: {title: 'a'}, 1438 | children: [] 1439 | }, 1440 | {extensions: [directiveToMarkdown({quote: '"'})]} 1441 | ), 1442 | ':i{title="a"}\n' 1443 | ) 1444 | } 1445 | ) 1446 | 1447 | await t.test( 1448 | "should quote attribute values with single quotes if `quote: '\\''` even if they occur in value", 1449 | async function () { 1450 | assert.deepEqual( 1451 | toMarkdown( 1452 | { 1453 | type: 'textDirective', 1454 | name: 'i', 1455 | attributes: {title: "'a'"}, 1456 | children: [] 1457 | }, 1458 | {extensions: [directiveToMarkdown({quote: "'"})]} 1459 | ), 1460 | ":i{title=''a''}\n" 1461 | ) 1462 | } 1463 | ) 1464 | 1465 | await t.test( 1466 | "should quote attribute values with double quotes if `quote: '\\\"'` even if they occur in value", 1467 | async function () { 1468 | assert.deepEqual( 1469 | toMarkdown( 1470 | { 1471 | type: 'textDirective', 1472 | name: 'i', 1473 | attributes: {title: '"a"'}, 1474 | children: [] 1475 | }, 1476 | {extensions: [directiveToMarkdown({quote: '"'})]} 1477 | ), 1478 | ':i{title=""a""}\n' 1479 | ) 1480 | } 1481 | ) 1482 | 1483 | await t.test('should throw on invalid quotes', async function () { 1484 | assert.throws(function () { 1485 | toMarkdown( 1486 | { 1487 | type: 'textDirective', 1488 | name: 'i', 1489 | attributes: {}, 1490 | children: [] 1491 | }, 1492 | // @ts-expect-error: check how the runtime handles an incorrect `quote` 1493 | {extensions: [directiveToMarkdown({quote: '`'})]} 1494 | ) 1495 | }, /Invalid quote ```, expected `'` or `"`/) 1496 | }) 1497 | }) 1498 | }) 1499 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declarationMap": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js", "index.d.ts"] 16 | } 17 | --------------------------------------------------------------------------------