├── .gitignore ├── test ├── mocha.opts ├── test.cjs ├── fixtures │ └── ruby.txt └── test.mjs ├── .eslintrc.json ├── eslint.config.mjs ├── LICENSE ├── package.json ├── README.md ├── index.mjs └── index.cjs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | -R spec 2 | --ui bdd -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true 5 | } 6 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | ); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Hiroshi Takase. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-ruby", 3 | "version": "1.1.2", 4 | "description": "Ruby annotation plugin for markdown-it parser.", 5 | "keywords": [ 6 | "markdown-it", 7 | "markdown-it-plugin" 8 | ], 9 | "license": "MIT", 10 | "type": "module", 11 | "exports": { 12 | "import": "./index.mjs", 13 | "require": "./index.cjs" 14 | }, 15 | "main": "./index.cjs", 16 | "module": "./index.mjs", 17 | "repository": "lostandfound/markdown-it-ruby", 18 | "homepage": "https://github.com/lostandfound/markdown-it-ruby", 19 | "scripts": { 20 | "test": "mocha", 21 | "lint": "npx eslint ." 22 | }, 23 | "mocha": { 24 | "extensions": [ 25 | "js", 26 | "mjs" 27 | ] 28 | }, 29 | "author": "Hiroshi Takase", 30 | "devDependencies": { 31 | "@eslint/js": "^9.13.0", 32 | "@types/eslint__js": "^8.42.3", 33 | "eslint": "^9.13.0", 34 | "eslint-plugin-nodeca": "^1.0.3", 35 | "globals": "^15.11.0", 36 | "markdown-it": "^14.1.0", 37 | "markdown-it-testgen": "^0.1.4", 38 | "mocha": "^10.7.3", 39 | "typescript": "^5.6.3", 40 | "typescript-eslint": "^8.11.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-it-ruby 2 | 3 | [![NPM version](https://img.shields.io/npm/v/markdown-it-ruby.svg?style=flat)](https://www.npmjs.org/package/markdown-it-ruby) 4 | 5 | > Ruby annotations (``) tag plugin for [markdown-it](https://github.com/markdown-it/markdown-it) markdown parser. 6 | 7 | `{ruby base|ruby text}` => `ruby baseruby text` 8 | 9 | Markup is based on [DenDenMarkdown](https://conv.denshochan.com/markdown) definition. 10 | 11 | 12 | ## Install 13 | 14 | ```bash 15 | npm install markdown-it-ruby --save 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### ESM (Recommended) 21 | ```js 22 | import MarkdownIt from 'markdown-it'; 23 | import rubyPlugin from 'markdown-it-ruby'; 24 | 25 | const md = new MarkdownIt().use(rubyPlugin); 26 | md.render('{ruby base|ruby text}'); // => '

ruby baseruby text

' 27 | ``` 28 | 29 | ### CommonJS 30 | ```js 31 | const MarkdownIt = require('markdown-it'); 32 | const rubyPlugin = require('markdown-it-ruby'); 33 | 34 | const md = new MarkdownIt().use(rubyPlugin); 35 | md.render('{ruby base|ruby text}'); // => '

ruby baseruby text

' 36 | ``` 37 | 38 | ### Options 39 | 40 | You can pass options to the plugin: 41 | 42 | ```js 43 | const md = new MarkdownIt().use(rubyPlugin, { 44 | rp: ['(', ')'] // Add parentheses around ruby text 45 | }); 46 | 47 | // Output: 漢字(かんじ) 48 | md.render('{漢字|かんじ}'); 49 | ``` 50 | 51 | #### Available Options 52 | 53 | | Option | Type | Default | Description | 54 | |--------|------|---------|-------------| 55 | | `rp` | `[string, string]` | `['', '']` | Array of opening and closing parentheses to wrap around ruby text. When both values are empty strings, no `rp` elements will be output. | 56 | 57 | ## License 58 | 59 | [MIT](https://github.com/lostandfound/markdown-it-ruby/blob/master/LICENSE) 60 | -------------------------------------------------------------------------------- /test/test.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const generate = require('markdown-it-testgen'); 5 | const MarkdownIt = require('markdown-it'); 6 | const rubyPlugin = require('../index.cjs'); 7 | const assert = require('assert'); 8 | 9 | /*eslint-env mocha*/ 10 | 11 | describe('markdown-it-ruby (CommonJS)', () => { 12 | const md = new MarkdownIt() 13 | .use(rubyPlugin); 14 | 15 | generate(path.join(__dirname, 'fixtures/ruby.txt'), md); 16 | 17 | describe('rp option', () => { 18 | it('should add rp elements when rp option is provided', () => { 19 | const mdWithRp = new MarkdownIt().use(rubyPlugin, { rp: ['(', ')'] }); 20 | const result = mdWithRp.render('{漢字|かんじ}'); 21 | assert.strictEqual( 22 | result.trim(), 23 | '

漢字(かんじ)

' 24 | ); 25 | }); 26 | 27 | it('should work with double parentheses', () => { 28 | const mdWithDoubleRp = new MarkdownIt().use(rubyPlugin, { rp: ['((', '))'] }); 29 | const result = mdWithDoubleRp.render('{漢字|かんじ}'); 30 | assert.strictEqual( 31 | result.trim(), 32 | '

漢字((かんじ))

' 33 | ); 34 | }); 35 | 36 | it('should work when surrounded with other token', () => { 37 | const mdWithRp = new MarkdownIt().use(rubyPlugin, { rp: ['(', ')'] }); 38 | const result = mdWithRp.render('*{漢字|かんじ}*'); 39 | assert.strictEqual( 40 | result.trim(), 41 | '

漢字(かんじ)

' 42 | ); 43 | }); 44 | 45 | it('should not add rp elements when rp option is empty strings', () => { 46 | const mdWithEmptyRp = new MarkdownIt().use(rubyPlugin, { rp: ['', ''] }); 47 | const result = mdWithEmptyRp.render('{漢字|かんじ}'); 48 | assert.strictEqual( 49 | result.trim(), 50 | '

漢字かんじ

' 51 | ); 52 | }); 53 | }); 54 | }); -------------------------------------------------------------------------------- /test/fixtures/ruby.txt: -------------------------------------------------------------------------------- 1 | . 2 | Lore ipsum. 3 | . 4 |

Lore ipsum.

5 | . 6 | 7 | . 8 | {ruby base|ruby text} 9 | . 10 |

ruby baseruby text

11 | . 12 | 13 | . 14 | {鬼|き}{門|もん}の{方|ほう}{角|がく}を{凝|ぎょう}{視|し}する。 15 | . 16 |

もんほうがくぎょうする。

17 | . 18 | 19 | . 20 | {鬼門|きもん}の{方角|ほうがく}を{凝視|ぎょうし}する。 21 | . 22 |

鬼門きもん方角ほうがく凝視ぎょうしする。

23 | . 24 | 25 | . 26 | {鬼門|き|もん}の{方角|ほう|がく}を{凝視|ぎょう|し}する。 27 | . 28 |

もんほうがくぎょうする。

29 | . 30 | 31 | . 32 | {編集者|editor} 33 | . 34 |

編集者editor

35 | . 36 | 37 | . 38 | {editor|エディター} 39 | . 40 |

editorエディター

41 | . 42 | 43 | . 44 | **{ruby base|ruby text}** 45 | . 46 |

ruby baseruby text

47 | . 48 | 49 | . 50 | {**ruby base**|ruby text} 51 | . 52 |

ruby baseruby text

53 | . 54 | 55 | . 56 | {ruby base|**ruby text**} 57 | . 58 |

ruby baseruby text

59 | . 60 | 61 | . 62 | [{ruby base|ruby text}](http://example.com) 63 | . 64 |

ruby baseruby text

65 | . 66 | 67 | . 68 | {[ruby base](http://example.com)|ruby text} 69 | . 70 |

ruby baseruby text

71 | . 72 | 73 | . 74 | {ruby base|[ruby text](http://example.com)} 75 | . 76 |

ruby baseruby text

77 | . 78 | 79 | . 80 | \{ruby base|ruby text} 81 | . 82 |

{ruby base|ruby text}

83 | . 84 | 85 | . 86 | {ruby base\|ruby text} 87 | . 88 |

{ruby base|ruby text}

89 | . 90 | 91 | . 92 | {ruby base|ruby text\} 93 | . 94 |

{ruby base|ruby text}

95 | . 96 | 97 | -------------------------------------------------------------------------------- /test/test.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { fileURLToPath } from 'url'; 4 | import { dirname, join } from 'path'; 5 | import generate from 'markdown-it-testgen'; 6 | import MarkdownIt from 'markdown-it'; 7 | import rubyPlugin from '../index.mjs'; 8 | import { describe, it } from 'mocha'; 9 | import assert from 'assert'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = dirname(__filename); 13 | 14 | /*eslint-env mocha*/ 15 | 16 | describe('markdown-it-ruby (ESM)', () => { 17 | const md = new MarkdownIt() 18 | .use(rubyPlugin); 19 | 20 | generate(join(__dirname, 'fixtures/ruby.txt'), md); 21 | 22 | describe('rp option', () => { 23 | it('should add rp elements when rp option is provided', () => { 24 | const mdWithRp = new MarkdownIt().use(rubyPlugin, { rp: ['(', ')'] }); 25 | const result = mdWithRp.render('{漢字|かんじ}'); 26 | assert.strictEqual( 27 | result.trim(), 28 | '

漢字(かんじ)

' 29 | ); 30 | }); 31 | 32 | it('should work with double parentheses', () => { 33 | const mdWithDoubleRp = new MarkdownIt().use(rubyPlugin, { rp: ['((', '))'] }); 34 | const result = mdWithDoubleRp.render('{漢字|かんじ}'); 35 | assert.strictEqual( 36 | result.trim(), 37 | '

漢字((かんじ))

' 38 | ); 39 | }); 40 | 41 | it('should work when surrounded with other token', () => { 42 | const mdWithRp = new MarkdownIt().use(rubyPlugin, { rp: ['(', ')'] }); 43 | const result = mdWithRp.render('*{漢字|かんじ}*'); 44 | assert.strictEqual( 45 | result.trim(), 46 | '

漢字(かんじ)

' 47 | ); 48 | }); 49 | 50 | it('should not add rp elements when rp option is empty strings', () => { 51 | const mdWithEmptyRp = new MarkdownIt().use(rubyPlugin, { rp: ['', ''] }); 52 | const result = mdWithEmptyRp.render('{漢字|かんじ}'); 53 | assert.strictEqual( 54 | result.trim(), 55 | '

漢字かんじ

' 56 | ); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | // Process {ruby base|ruby text} 2 | 3 | /** 4 | * Parses and processes custom ruby annotation syntax within a Markdown string. 5 | * 6 | * This function identifies and processes text wrapped in curly braces `{}` 7 | * with a vertical bar `|` separating the base text and the ruby text. 8 | * It then generates the appropriate tokens for rendering ruby annotations. 9 | * 10 | * @param {Object} state - The state object of the Markdown parser. 11 | * @param {boolean} silent - If true, the function will not produce any tokens. 12 | * @returns {boolean} - Returns true if the ruby annotation was successfully parsed, otherwise false. 13 | */ 14 | function ddmd_ruby (state, silent) { 15 | // Validate rp option 16 | const rp = Array.isArray(state.md.ddmdOptions.rp) && 17 | state.md.ddmdOptions.rp.length === 2 && 18 | typeof state.md.ddmdOptions.rp[0] === 'string' && 19 | typeof state.md.ddmdOptions.rp[1] === 'string' 20 | ? state.md.ddmdOptions.rp 21 | : ["", ""]; 22 | 23 | // Only output rp elements if both opening and closing parentheses are non-empty 24 | const [rpOpen, rpClose] = rp; 25 | const shouldOutputRp = rpOpen !== "" && rpClose !== ""; 26 | 27 | // Initialize required variables 28 | const max = state.posMax; 29 | const start = state.pos; 30 | let token, 31 | tokens, 32 | devPos, // Position of delimiter '|' 33 | closePos, // Position of closing character '}' 34 | baseText, // Base text to apply ruby to 35 | rubyText, // Ruby text to be displayed above 36 | baseArray, // Array of base text characters 37 | rubyArray; // Array of ruby text segments 38 | 39 | // Exit if in silent mode or invalid starting character 40 | if (silent) { return false; } 41 | if (state.src.charCodeAt(start) !== 0x7b/* { */) { return false; } 42 | if (start + 4 >= max) { return false; } 43 | 44 | // Scan text to find delimiter and closing positions 45 | state.pos = start + 1; 46 | while (state.pos < max) { 47 | if (devPos) { 48 | // After finding delimiter, look for closing character 49 | if ( 50 | state.src.charCodeAt(state.pos) === 0x7D/* } */ 51 | && state.src.charCodeAt(state.pos - 1) !== 0x5C/* \ */ 52 | ) { 53 | closePos = state.pos; 54 | break; 55 | } 56 | } else if (state.src.charCodeAt(state.pos) === 0x7C/* | */ 57 | && state.src.charCodeAt(state.pos - 1) !== 0x5C/* \ */) { 58 | // Find non-escaped delimiter 59 | devPos = state.pos; 60 | } 61 | state.pos++; 62 | } 63 | 64 | if (!devPos) { 65 | state.pos = start; 66 | return false; // Delimiter '|' not found 67 | } 68 | if (!closePos) { 69 | state.pos = start; 70 | return false; // Closing brace '}' not found 71 | } 72 | if (start + 1 === state.pos) { 73 | state.pos = start; 74 | return false; // Empty content 75 | } 76 | 77 | state.posMax = state.pos; 78 | state.pos = start + 1; 79 | 80 | token = state.push('ruby_open', 'ruby', 1); 81 | token.markup = '{'; 82 | 83 | // Extract base text and ruby text 84 | baseText = state.src.slice(start + 1, devPos); 85 | rubyText = state.src.slice(devPos + 1, closePos); 86 | 87 | // Split texts into arrays 88 | baseArray = Array.from(baseText); // Use Array.from for Unicode support 89 | 90 | // Add length check for rubyArray 91 | if (rubyText.includes('|')) { 92 | rubyArray = rubyText.split('|').filter(text => text.length > 0); 93 | if (rubyArray.length === 0) { 94 | state.pos = start; 95 | return false; 96 | } 97 | } else { 98 | rubyArray = [rubyText]; 99 | } 100 | 101 | // Common function for token generation 102 | function parseAndPushTokens(content) { 103 | const tokens = []; 104 | state.md.inline.parse(content, state.md, state.env, tokens); 105 | tokens.forEach(t => state.tokens.push(t)); 106 | } 107 | 108 | // Optimize character-by-character ruby processing 109 | if (baseArray.length === rubyArray.length) { 110 | baseArray.forEach((content, idx) => { 111 | parseAndPushTokens(content); 112 | 113 | if (shouldOutputRp) { 114 | // Generate opening rp token 115 | token = state.push('rp_open', 'rp', 0); 116 | token.content = rpOpen; 117 | token.markup = rpOpen; 118 | } 119 | 120 | token = state.push('rt_open', 'rt', 1); 121 | parseAndPushTokens(rubyArray[idx]); 122 | token = state.push('rt_close', 'rt', -1); 123 | 124 | if (shouldOutputRp) { 125 | // Generate closing rp token 126 | token = state.push('rp_close', 'rp', 0); 127 | token.content = rpClose; 128 | token.markup = rpClose; 129 | } 130 | }); 131 | } else { 132 | // Whole-text ruby: Apply single ruby text to entire base text 133 | state.md.inline.parse( 134 | baseText, 135 | state.md, 136 | state.env, 137 | tokens = [] 138 | ); 139 | 140 | tokens.forEach(function(t) { 141 | state.tokens.push(t); 142 | }); 143 | 144 | if (shouldOutputRp) { 145 | // Generate opening rp token 146 | token = state.push('rp_open', 'rp', 0); 147 | token.content = rpOpen; 148 | token.markup = rpOpen; 149 | } 150 | 151 | token = state.push('rt_open', 'rt', 1); 152 | state.md.inline.parse( 153 | rubyText, 154 | state.md, 155 | state.env, 156 | tokens = [] 157 | ); 158 | 159 | tokens.forEach(function(t) { 160 | state.tokens.push(t); 161 | }); 162 | token = state.push('rt_close', 'rt', -1); 163 | 164 | if (shouldOutputRp) { 165 | // Generate closing rp token 166 | token = state.push('rp_close', 'rp', 0); 167 | token.content = rpClose; 168 | token.markup = rpClose; 169 | } 170 | } 171 | 172 | // Close ruby element 173 | token = state.push('ruby_close', 'ruby', -1); 174 | token.markup = '}'; 175 | 176 | // Update parser position 177 | state.pos = state.posMax + 1; 178 | state.posMax = max; 179 | 180 | return true; 181 | } 182 | 183 | export default (md, options = {}) => { 184 | // Merge default options with user-provided options 185 | const ddmdOptions = Object.assign({ 186 | // Define default settings 187 | rp: [] // rp element 188 | }, options); 189 | 190 | // Add options to markdown-it instance 191 | md.ddmdOptions = ddmdOptions; 192 | 193 | // Add rendering rules for rp elements 194 | md.renderer.rules.rp_open = function(tokens, idx) { 195 | return `${tokens[idx].content}`; 196 | }; 197 | 198 | md.renderer.rules.rp_close = function(tokens, idx) { 199 | return `${tokens[idx].content}`; 200 | }; 201 | 202 | md.inline.ruler.before('text', 'ddmd_ruby', ddmd_ruby); 203 | }; 204 | -------------------------------------------------------------------------------- /index.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | /* eslint-disable no-undef */ 3 | 'use strict'; 4 | 5 | /** 6 | * Parses and processes custom ruby annotation syntax within a Markdown string. 7 | * 8 | * This function identifies and processes text wrapped in curly braces `{}` 9 | * with a vertical bar `|` separating the base text and the ruby text. 10 | * It then generates the appropriate tokens for rendering ruby annotations. 11 | * 12 | * @param {Object} state - The state object of the Markdown parser. 13 | * @param {boolean} silent - If true, the function will not produce any tokens. 14 | * @returns {boolean} - Returns true if the ruby annotation was successfully parsed, otherwise false. 15 | */ 16 | function ddmd_ruby (state, silent) { 17 | // Validate rp option 18 | const rp = Array.isArray(state.md.ddmdOptions.rp) && 19 | state.md.ddmdOptions.rp.length === 2 && 20 | typeof state.md.ddmdOptions.rp[0] === 'string' && 21 | typeof state.md.ddmdOptions.rp[1] === 'string' 22 | ? state.md.ddmdOptions.rp 23 | : ["", ""]; 24 | 25 | // Only output rp elements if both opening and closing parentheses are non-empty 26 | const [rpOpen, rpClose] = rp; 27 | const shouldOutputRp = rpOpen !== "" && rpClose !== ""; 28 | 29 | // Initialize required variables 30 | const max = state.posMax; 31 | const start = state.pos; 32 | let token, 33 | tokens, 34 | devPos, // Position of delimiter '|' 35 | closePos, // Position of closing character '}' 36 | baseText, // Base text to apply ruby to 37 | rubyText, // Ruby text to be displayed above 38 | baseArray, // Array of base text characters 39 | rubyArray; // Array of ruby text segments 40 | 41 | // Exit if in silent mode or invalid starting character 42 | if (silent) { return false; } 43 | if (state.src.charCodeAt(start) !== 0x7b/* { */) { return false; } 44 | if (start + 4 >= max) { return false; } 45 | 46 | // Scan text to find delimiter and closing positions 47 | state.pos = start + 1; 48 | while (state.pos < max) { 49 | if (devPos) { 50 | // After finding delimiter, look for closing character 51 | if ( 52 | state.src.charCodeAt(state.pos) === 0x7D/* } */ 53 | && state.src.charCodeAt(state.pos - 1) !== 0x5C/* \ */ 54 | ) { 55 | closePos = state.pos; 56 | break; 57 | } 58 | } else if (state.src.charCodeAt(state.pos) === 0x7C/* | */ 59 | && state.src.charCodeAt(state.pos - 1) !== 0x5C/* \ */) { 60 | // Find non-escaped delimiter 61 | devPos = state.pos; 62 | } 63 | state.pos++; 64 | } 65 | 66 | if (!devPos) { 67 | state.pos = start; 68 | return false; // Delimiter '|' not found 69 | } 70 | if (!closePos) { 71 | state.pos = start; 72 | return false; // Closing brace '}' not found 73 | } 74 | if (start + 1 === state.pos) { 75 | state.pos = start; 76 | return false; // Empty content 77 | } 78 | 79 | state.posMax = state.pos; 80 | state.pos = start + 1; 81 | 82 | token = state.push('ruby_open', 'ruby', 1); 83 | token.markup = '{'; 84 | 85 | // Extract base text and ruby text 86 | baseText = state.src.slice(start + 1, devPos); 87 | rubyText = state.src.slice(devPos + 1, closePos); 88 | 89 | // Split texts into arrays 90 | baseArray = Array.from(baseText); // Use Array.from for Unicode support 91 | 92 | // Add length check for rubyArray 93 | if (rubyText.includes('|')) { 94 | rubyArray = rubyText.split('|').filter(text => text.length > 0); 95 | if (rubyArray.length === 0) { 96 | state.pos = start; 97 | return false; 98 | } 99 | } else { 100 | rubyArray = [rubyText]; 101 | } 102 | 103 | // Common function for token generation 104 | function parseAndPushTokens(content) { 105 | const tokens = []; 106 | state.md.inline.parse(content, state.md, state.env, tokens); 107 | tokens.forEach(t => state.tokens.push(t)); 108 | } 109 | 110 | // Optimize character-by-character ruby processing 111 | if (baseArray.length === rubyArray.length) { 112 | baseArray.forEach((content, idx) => { 113 | parseAndPushTokens(content); 114 | 115 | if (shouldOutputRp) { 116 | // Generate opening rp token 117 | token = state.push('rp_open', 'rp', 0); 118 | token.content = rpOpen; 119 | token.markup = rpOpen; 120 | } 121 | 122 | token = state.push('rt_open', 'rt', 1); 123 | parseAndPushTokens(rubyArray[idx]); 124 | token = state.push('rt_close', 'rt', -1); 125 | 126 | if (shouldOutputRp) { 127 | // Generate closing rp token 128 | token = state.push('rp_close', 'rp', 0); 129 | token.content = rpClose; 130 | token.markup = rpClose; 131 | } 132 | }); 133 | } else { 134 | // Whole-text ruby: Apply single ruby text to entire base text 135 | state.md.inline.parse( 136 | baseText, 137 | state.md, 138 | state.env, 139 | tokens = [] 140 | ); 141 | 142 | tokens.forEach(function(t) { 143 | state.tokens.push(t); 144 | }); 145 | 146 | if (shouldOutputRp) { 147 | // Generate opening rp token 148 | token = state.push('rp_open', 'rp', 0); 149 | token.content = rpOpen; 150 | token.markup = rpOpen; 151 | } 152 | 153 | token = state.push('rt_open', 'rt', 1); 154 | state.md.inline.parse( 155 | rubyText, 156 | state.md, 157 | state.env, 158 | tokens = [] 159 | ); 160 | 161 | tokens.forEach(function(t) { 162 | state.tokens.push(t); 163 | }); 164 | token = state.push('rt_close', 'rt', -1); 165 | 166 | if (shouldOutputRp) { 167 | // Generate closing rp token 168 | token = state.push('rp_close', 'rp', 0); 169 | token.content = rpClose; 170 | token.markup = rpClose; 171 | } 172 | } 173 | 174 | // Close ruby element 175 | token = state.push('ruby_close', 'ruby', -1); 176 | token.markup = '}'; 177 | 178 | // Update parser position 179 | state.pos = state.posMax + 1; 180 | state.posMax = max; 181 | 182 | return true; 183 | } 184 | 185 | module.exports = (md, options = {}) => { 186 | // Merge default options with user-provided options 187 | const ddmdOptions = Object.assign({ 188 | // Define default settings 189 | rp: [] // rp element 190 | }, options); 191 | 192 | // Add options to markdown-it instance 193 | md.ddmdOptions = ddmdOptions; 194 | 195 | // Add rendering rules for rp elements 196 | md.renderer.rules.rp_open = function(tokens, idx) { 197 | return `${tokens[idx].content}`; 198 | }; 199 | 200 | md.renderer.rules.rp_close = function(tokens, idx) { 201 | return `${tokens[idx].content}`; 202 | }; 203 | 204 | md.inline.ruler.before('text', 'ddmd_ruby', ddmd_ruby); 205 | }; 206 | --------------------------------------------------------------------------------