Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-08-26 14:54:15 +02:00
parent 1d90013344
commit f5736dad0f
37 changed files with 2025 additions and 0 deletions

View file

@ -0,0 +1,208 @@
# SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
#
# SPDX-License-Identifier: CC0-1.0
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
trim_trailing_whitespace = true
ij_continuation_indent_size = 8
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = false
ij_smart_tabs = false
ij_wrap_on_typing = false
[{*.ats,*.ts,*.tsx}]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_field_in_interface = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_blank_lines_around_method_in_interface = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = keep
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = false
ij_typescript_force_semicolon_style = true
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = true
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = false
ij_typescript_indent_package_children = 0
ij_typescript_jsdoc_include_types = false
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 1
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = true
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = true
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = false
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = true
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = true
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = false
ij_typescript_use_explicit_js_extension = global
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = false
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}]
indent_size = 2
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = true
ij_json_space_before_comma = false
ij_json_spaces_within_braces = false
ij_json_spaces_within_brackets = false
ij_json_wrap_long_lines = false
[{*.yaml,*.yml}]
indent_size = 2
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: CC0-1.0
*/
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": [
"./tsconfig.test.json"
]
},
"plugins": [
"@typescript-eslint",
"jest",
"prettier"
],
"env": {
"jest": true,
"jest/globals": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"prettier/prettier": "error",
"jest/no-disabled-tests": "warn",
"jest/no-focused-tests": "error",
"jest/no-identical-title": "error",
"jest/prefer-to-have-length": "warn",
"jest/valid-expect": "error"
}
}

1
markdown-it-plugins/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

View file

@ -0,0 +1,11 @@
{
"parser": "typescript",
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"tabWidth": 2,
"trailingComma": "none",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "always"
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,49 @@
{
"extends": [
"config:base",
":gitSignOff",
":preserveSemverRanges",
":prHourlyLimitNone",
":dependencyDashboard",
":maintainLockFilesWeekly"
],
"prHourlyLimit": 0,
"schedule": [
"on Saturday"
],
"labels": [
"dependencies"
],
"packageRules": [
{
"extends": "packages:linters",
"groupName": "linters"
},
{
"extends": "monorepo:typescript-eslint",
"groupName": "typescript-eslint monorepo"
},
{
"extends": "monorepo:react",
"groupName": "react monorepo"
},
{
"extends": "monorepo:reactrouter",
"groupName": "reactrouter monorepo"
},
{
"groupName": "definitelyTyped",
"matchPackagePatterns": [
"^@types/"
]
},
{
"groupName": "Jest",
"matchPackageNames": [
"jest",
"ts-jest",
"@types/jest"
]
}
]
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 The HedgeDoc developers (see AUTHORS file)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,143 @@
<!--
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0
-->
# Markdown-It plugins
This repository contains plugins for [Markdown-It](https://github.com/markdown-it/markdown-it) that are used by HedgeDoc.
All these plugins are (re)created in typescript.
## License
Everything is licensed under MIT
## Usage
Install the lib using `yarn install @hedgedoc/markdown-it-plugins` or `npm i @hedgedoc/markdown-it-plugins`
## Development
If you want to contribute to this lib then:
- Clone this repository
- Install the dependencies using `yarn install`. Don't use `npm`!
- Make your changes
- Make sure that your changes are covered by tests. Use `yarn test` to run all tests
- Make sure that your code follows the code style. Use `yarn lint` to check the style
- Commit your changes (please use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)) and create a pull request
## markdown-it-image-size
A markdown-it plugin for size-specified image markups. This plugin overloads the original image renderer of markdown-it.
> This is a typescript port of https://github.com/tatsy/markdown-it-imsize without the local file system support.
### Usage
#### Enable plugin
```ts
import MarkdownIt from 'markdown-it'
import { imageSize } from '@hedgedoc/markdown-it-plugins'
const md = new MarkdownIt({
html: true,
linkify: true,
typography: true
}).use(imageSize);
```
#### Example
```md
![test](image.png =100x200)
```
is interpreted as
```html
<p><img src="image.png" alt="test" width="100" height="200"></p>
```
## markdown-it-better-task-lists
A markdown-it plugin that renders GitHub-style task-lists. It builds [task/todo lists](https://github.com/blog/1825-task-lists-in-all-markdown-documents) out of Markdown lists with items starting with `[ ]` or `[x]`.
This is a typescript port of [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists).
### Why is this useful?
When you have markdown documentation with checklists, rendering HTML checkboxes
out of the list items looks nicer than the raw square brackets.
### Usage
Use it the same as a normal markdown-it plugin:
```ts
import MarkdownIt from 'markdown-it'
import { taskLists } from '@hedgedoc/markdown-it-plugins'
const parser = new MarkdownIt().use(taskLists)
const result = parser.render(`
- [ ] Open task
- [x] Done task
- Not a task
`) // markdown string containing task list items
```
The rendered checkboxes are disabled; to change this, set `enabled` property of the
plugin options to `true`:
```ts
const parser = new MarkdownIt().use(taskLists, { enabled: true })
```
If you need to know which line in the markdown document the generated checkbox comes
set the `lineNumber` property of the plugin options to `true` for the
`<input>` tag to be created with a data-line attribute containing the line number:
```ts
const parser = new MarkdownIt().use(taskLists, { lineNumber: true })
```
If you'd like to wrap the rendered list items in a `<label>` element for UX
purposes, set the `label` property of the plugin options to `true`:
```ts
const parser = new MarkdownIt().use(taskLists, { label: true })
```
To add the label after the checkbox set the `labelAfter` property of the plugin
options to `true`:
```ts
const parser = new MarkdownIt().use(taskLists, { label: true, labelAfter: true })
```
**Note:** This option does require the `label` option to be truthy.
The options can be combined, of course.
## markdown-it-toc
A markdown-it plugin that renders a table of contents.
It uses the found headlines as content.
This is a typescript port of [markdown-it-toc-done-right](https://github.com/nagaozen/markdown-it-toc-done-right).
### Usage
Use it the same as a normal markdown-it plugin:
```ts
import MarkdownIt from 'markdown-it'
import { toc } from '@hedgedoc/markdown-it-plugins'
const parser = new MarkdownIt().use(toc)
const result = parser.render(`
[toc]
# head 1
# head 2
`) // markdown string containing task list items
```

34
markdown-it-plugins/build.sh Executable file
View file

@ -0,0 +1,34 @@
#!/bin/bash
#
# SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
#
# SPDX-License-Identifier: MIT
#
set -e
echo "Clear dist directory.."
rm -rf dist
echo "Compile to CJS.."
tsc --project tsconfig.cjs.json
echo "Compile to ESM.."
tsc --project tsconfig.esm.json
echo "Fix CJS package.json.."
cat > dist/cjs/package.json <<!EOF
{
"type": "commonjs"
}
!EOF
echo "Fix ESM package.json.."
cat > dist/esm/package.json <<!EOF
{
"type": "module"
}
!EOF
echo "Done!"

View file

@ -0,0 +1,26 @@
{
"testRegex" : "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
"testPathIgnorePatterns" : [
"/dist/"
],
"moduleFileExtensions" : [
"ts",
"tsx",
"js"
],
"extensionsToTreatAsEsm" : [
".ts"
],
"moduleNameMapper" : {
"^(\\.{1,2}/.*)\\.js$" : "$1"
},
"transform" : {
"^.+\\.tsx?$" : [
"ts-jest",
{
"tsconfig" : "tsconfig.test.json",
"useESM" : true
}
]
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,133 @@
{
"name": "@hedgedoc/markdown-it-plugins",
"type": "module",
"version": "2.1.3",
"description": "A collection of used and modified markdown-it plugins.",
"source": "src/index.ts",
"main": "../dist/cjs/index.js",
"types": "dist/cjs/index.d.ts",
"module": "../dist/esm/index.js",
"exports": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.ts",
"default": "./dist/cjs/index.js"
}
},
"files": [
"LICENSES/*",
"package.json",
"README.md",
"dist/cjs/image-size/index.d.ts",
"dist/cjs/image-size/index.js",
"dist/cjs/image-size/index.js.map",
"dist/cjs/image-size/parse-image-size.d.ts",
"dist/cjs/image-size/parse-image-size.js",
"dist/cjs/image-size/parse-image-size.js.map",
"dist/cjs/image-size/specialCharacters.d.ts",
"dist/cjs/image-size/specialCharacters.js",
"dist/cjs/image-size/specialCharacters.js.map",
"dist/cjs/index.d.ts",
"dist/cjs/index.js",
"dist/cjs/index.js.map",
"dist/cjs/package.json",
"dist/cjs/task-lists/index.d.ts",
"dist/cjs/task-lists/index.js",
"dist/cjs/task-lists/index.js.map",
"dist/cjs/toc/index.d.ts",
"dist/cjs/toc/index.js",
"dist/cjs/toc/index.js.map",
"dist/cjs/toc/plugin.d.ts",
"dist/cjs/toc/plugin.js",
"dist/cjs/toc/plugin.js.map",
"dist/cjs/toc/toc-ast.d.ts",
"dist/cjs/toc/toc-ast.js",
"dist/cjs/toc/toc-ast.js.map",
"dist/cjs/toc/toc-body-renderer.d.ts",
"dist/cjs/toc/toc-body-renderer.js",
"dist/cjs/toc/toc-body-renderer.js.map",
"dist/cjs/toc/toc-options.d.ts",
"dist/cjs/toc/toc-options.js",
"dist/cjs/toc/toc-options.js.map",
"dist/esm/image-size/index.d.ts",
"dist/esm/image-size/index.js",
"dist/esm/image-size/index.js.map",
"dist/esm/image-size/parse-image-size.d.ts",
"dist/esm/image-size/parse-image-size.js",
"dist/esm/image-size/parse-image-size.js.map",
"dist/esm/image-size/specialCharacters.d.ts",
"dist/esm/image-size/specialCharacters.js",
"dist/esm/image-size/specialCharacters.js.map",
"dist/esm/index.d.ts",
"dist/esm/index.js",
"dist/esm/index.js.map",
"dist/esm/package.json",
"dist/esm/task-lists/index.d.ts",
"dist/esm/task-lists/index.js",
"dist/esm/task-lists/index.js.map",
"dist/esm/toc/index.d.ts",
"dist/esm/toc/index.js",
"dist/esm/toc/index.js.map",
"dist/esm/toc/plugin.d.ts",
"dist/esm/toc/plugin.js",
"dist/esm/toc/plugin.js.map",
"dist/esm/toc/toc-ast.d.ts",
"dist/esm/toc/toc-ast.js",
"dist/esm/toc/toc-ast.js.map",
"dist/esm/toc/toc-body-renderer.d.ts",
"dist/esm/toc/toc-body-renderer.js",
"dist/esm/toc/toc-body-renderer.js.map",
"dist/esm/toc/toc-options.d.ts",
"dist/esm/toc/toc-options.js",
"dist/esm/toc/toc-options.js.map"
],
"scripts": {
"test": "../node_modules/.bin/jest",
"build": "build.sh",
"prepublish": "rm -rf ../dist && yarn lint && yarn build && yarn test",
"lint": "../node_modules/.bin/eslint --ext .ts markdown-it-plugins/src",
"lint:fix": "../node_modules/.bin/eslint --fix --ext .ts markdown-it-plugins/src"
},
"keywords": [
"markdown",
"markdown-it",
"image size",
"task lists"
],
"author": "The HedgeDoc developers",
"license": "MIT",
"peerDependencies": {
"markdown-it": ">=12"
},
"dependencies": {
"@mrdrogdrog/optional": "^1.2.1",
"html-entities": "^2.4.0"
},
"devDependencies": {
"@jest/types": "29.6.3",
"@types/markdown-it": "13.0.0",
"@typescript-eslint/eslint-plugin": "6.4.1",
"@typescript-eslint/parser": "6.4.1",
"eslint": "8.48.0",
"eslint-config-prettier": "9.0.0",
"eslint-plugin-jest": "27.2.3",
"eslint-plugin-prettier": "5.0.0",
"jest": "29.6.4",
"markdown-it": "13.0.1",
"prettier": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.2.2"
},
"repository": {
"type": "git",
"url": "git+https://github.com/hedgedoc/markdown-it-plugins.git"
},
"bugs": {
"url": "https://github.com/hedgedoc/markdown-it-plugins/issues"
},
"homepage": "https://github.com/hedgedoc/markdown-it-plugins#readme",
"packageManager": "yarn@3.6.3"
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import MarkdownIt from 'markdown-it/lib'
import { imageSize } from './index.js'
import { describe, expect, it } from '@jest/globals'
describe('markdown-it-imsize', function () {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
}).use(imageSize)
it('renders a image without size or title', () => {
expect(md.renderInline('![test](x)')).toBe('<img src="x" alt="test">')
})
it('renders a image with title', () => {
expect(md.renderInline('![test](x "thisisthetitle")')).toBe('<img src="x" alt="test" title="thisisthetitle">')
})
it('renders an image with absolute width and height', () => {
expect(md.renderInline('![test](x =100x200)')).toBe('<img src="x" alt="test" width="100" height="200">')
})
it('renders an image with relative width and height', () => {
expect(md.renderInline('![test](x =100%x200%)')).toBe('<img src="x" alt="test" width="100%" height="200%">')
})
it('renders an image with title and size', () => {
expect(md.renderInline('![test](x "thisisthetitle" =100x200)')).toBe(
'<img src="x" alt="test" title="thisisthetitle" width="100" height="200">'
)
})
it('renders an image with no size but x', () => {
expect(md.renderInline('![test](x "thisisthetitle" =x)')).toBe('<img src="x" alt="test" title="thisisthetitle">')
})
it("doesn't render an image with invalid size syntax", () => {
expect(md.renderInline('![test](x "thisisthetitle" =xx)')).toBe('![test](x “thisisthetitle” =xx)')
})
it('renders an image with only width', () => {
expect(md.renderInline('![test](x =100x)')).toBe('<img src="x" alt="test" width="100">')
})
it('renders an image with only height', () => {
expect(md.renderInline('![test](x =x200)')).toBe('<img src="x" alt="test" height="200">')
})
})

View file

@ -0,0 +1,245 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import MarkdownIt from 'markdown-it'
import ParserInline from 'markdown-it/lib/parser_inline.js'
import StateInline from 'markdown-it/lib/rules_inline/state_inline.js'
import { ParseImageSize, parseImageSize } from './parse-image-size.js'
import { SpecialCharacters } from './specialCharacters.js'
const checkForImageTagStart = (state: StateInline): boolean => {
return (
state.src.charCodeAt(state.pos) === SpecialCharacters.EXCLAMATION_MARK &&
state.src.charCodeAt(state.pos + 1) === SpecialCharacters.OPENING_BRACKET
)
}
const skipWhiteSpaces = (startPosition: number, state: StateInline): number => {
let position = startPosition
while (position < state.posMax) {
const code = state.src.charCodeAt(position)
if (code !== SpecialCharacters.WHITESPACE && code !== SpecialCharacters.NEW_LINE) {
break
}
position += 1
}
return position
}
function createImageToken(
state: StateInline,
labelStartIndex: number,
labelEndIndex: number,
href: string,
title: string,
width: string,
height: string
) {
state.pos = labelStartIndex
state.posMax = labelEndIndex
const token = state.push('image', 'img', 0)
token.children = []
const newState = new state.md.inline.State(
state.src.slice(labelStartIndex, labelEndIndex),
state.md,
state.env,
token.children
)
newState.md.inline.tokenize(newState)
token.attrSet('src', href)
token.attrSet('alt', '')
if (title) {
token.attrSet('title', title)
}
if (width !== '') {
token.attrSet('width', width)
}
if (height !== '') {
token.attrSet('height', height)
}
}
function parseSizeParameters(startPosition: number, state: StateInline): ParseImageSize | undefined {
// [link]( <href> "title" =WxH )
// ^^^^ parsing image size
if (startPosition - 1 < 0) {
return
}
const code = state.src.charCodeAt(startPosition - 1)
if (code !== SpecialCharacters.WHITESPACE) {
return
}
const res = parseImageSize(state.src, startPosition, state.posMax)
if (!res) {
return
}
// [link]( <href> "title" =WxH )
// ^^ skipping these spaces
return {
position: skipWhiteSpaces(res.position, state),
width: res.width,
height: res.height
}
}
export interface ParseLinkResult {
position: number
href: string
}
// [link]( <href> "title" )
// ^^^^^^ parsing link destination
function parseLink(state: StateInline, startPosition: number): ParseLinkResult | undefined {
const linkParseResult = state.md.helpers.parseLinkDestination(state.src, startPosition, state.posMax)
if (!linkParseResult.ok) {
return
}
const href = state.md.normalizeLink(linkParseResult.str)
if (state.md.validateLink(href)) {
return { position: linkParseResult.pos, href }
} else {
return { position: startPosition, href: '' }
}
}
const imageWithSize: ParserInline.RuleInline = (state, silent) => {
let position,
title,
start,
href = '',
width = '',
height = ''
const oldPos = state.pos,
max = state.posMax
if (!checkForImageTagStart(state)) {
return false
}
const labelStartIndex = state.pos + 2
const labelEndIndex = state.md.helpers.parseLinkLabel(state, state.pos + 1, false)
// parser failed to find ']', so it's not a valid link
if (labelEndIndex < 0) {
return false
}
position = labelEndIndex + 1
if (position < max && state.src.charCodeAt(position) === SpecialCharacters.OPENING_PARENTHESIS) {
//
// Inline link
//
// [link]( <href> "title" )
// ^^ skipping these spaces
position += 1
position = skipWhiteSpaces(position, state)
if (position >= max) {
return false
}
const parseLinkResult = parseLink(state, position)
if (!parseLinkResult) {
return false
}
position = parseLinkResult.position
href = parseLinkResult.href
// [link]( <href> "title" )
// ^^ skipping these spaces
start = position
position = skipWhiteSpaces(position, state)
// [link]( <href> "title" )
// ^^^^^^^ parsing link title
const parseLinkTitleResult = state.md.helpers.parseLinkTitle(state.src, position, state.posMax)
if (position < max && start !== position && parseLinkTitleResult.ok) {
title = parseLinkTitleResult.str
position = parseLinkTitleResult.pos
// [link]( <href> "title" )
// ^^ skipping these spaces
position = skipWhiteSpaces(position, state)
} else {
title = ''
}
const parseSizeParametersResult = parseSizeParameters(position, state)
if (parseSizeParametersResult) {
position = parseSizeParametersResult.position
width = parseSizeParametersResult.width
height = parseSizeParametersResult.height
}
if (position >= max || state.src.charCodeAt(position) !== SpecialCharacters.CLOSING_PARENTHESIS) {
state.pos = oldPos
return false
}
position += 1
} else {
//
// Link reference
//
if (typeof state.env.references === 'undefined') {
return false
}
// [foo] [bar]
// ^^ optional whitespace (can include newlines)
position = skipWhiteSpaces(position, state)
let label
if (position < max && state.src.charCodeAt(position) === SpecialCharacters.OPENING_BRACKET) {
start = position + 1
position = state.md.helpers.parseLinkLabel(state, position)
if (position >= 0) {
label = state.src.slice(start, (position += 1))
} else {
position = labelEndIndex + 1
}
} else {
position = labelEndIndex + 1
}
// covers label === '' and label === undefined
// (collapsed reference link and shortcut reference link respectively)
if (!label) {
label = state.src.slice(labelStartIndex, labelEndIndex)
}
const ref = state.env.references[state.md.utils.normalizeReference(label)]
if (!ref) {
state.pos = oldPos
return false
}
href = ref.href
title = ref.title
}
//
// We found the end of the link, and know for a fact it's a valid link;
// so all that's left to do is to call tokenizer.
//
if (!silent) {
createImageToken(state, labelStartIndex, labelEndIndex, href, title, width, height)
}
state.pos = position
state.posMax = max
return true
}
export const imageSize: MarkdownIt.PluginSimple = (md: MarkdownIt) => {
md.inline.ruler.before('emphasis', 'image', imageWithSize)
}

View file

@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import { SpecialCharacters } from './specialCharacters.js'
export interface ParseImageSize {
position: number
width: string
height: string
}
export interface ParseNextNumber {
position: number
value: string
}
function isCharacterADigit(code: number) {
return code >= SpecialCharacters.NUMBER_ZERO && code <= SpecialCharacters.NUMBER_NINE
}
function findNextNotNumberCharacter(startPosition: number, maximalPosition: number, content: string): number {
for (let position = startPosition; position < maximalPosition; position += 1) {
const code = content.charCodeAt(position)
if (!isCharacterADigit(code) && code !== SpecialCharacters.PERCENTAGE) {
return position
}
}
return maximalPosition
}
function parseNextNumber(content: string, startPosition: number, maximalPosition: number): ParseNextNumber {
const endCharacterIndex = findNextNotNumberCharacter(startPosition, maximalPosition, content)
return {
position: endCharacterIndex,
value: content.slice(startPosition, endCharacterIndex)
}
}
/*
size must follow = without any white spaces as follows
(1) =300x200
(2) =300x
(3) =x200
*/
const checkImageSizeStart = (code: number): boolean => {
return (
code === SpecialCharacters.LOWER_CASE_X ||
(code >= SpecialCharacters.NUMBER_ZERO && code <= SpecialCharacters.NUMBER_NINE)
)
}
export function parseImageSize(
imageSize: string,
startCharacterPosition: number,
maximalCharacterPosition: number
): ParseImageSize | undefined {
if (startCharacterPosition >= maximalCharacterPosition) {
return
}
let currentCharacterPosition = startCharacterPosition
if (imageSize.charCodeAt(currentCharacterPosition) !== SpecialCharacters.EQUALS /* = */) {
return
}
currentCharacterPosition += 1
if (!checkImageSizeStart(imageSize.charCodeAt(currentCharacterPosition))) {
return
}
// parse width
const width = parseNextNumber(imageSize, currentCharacterPosition, maximalCharacterPosition)
currentCharacterPosition = width.position
// next charactor must be 'x'
const code = imageSize.charCodeAt(currentCharacterPosition)
if (code !== SpecialCharacters.LOWER_CASE_X /* x */) {
return
}
currentCharacterPosition += 1
// parse height
const height = parseNextNumber(imageSize, currentCharacterPosition, maximalCharacterPosition)
currentCharacterPosition = height.position
return {
width: width.value,
height: height.value,
position: currentCharacterPosition
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
export enum SpecialCharacters {
EXCLAMATION_MARK = 0x21,
OPENING_BRACKET = 0x5b,
OPENING_PARENTHESIS = 0x28,
WHITESPACE = 0x20,
NEW_LINE = 0x0a,
EQUALS = 0x3d,
LOWER_CASE_X = 0x78,
NUMBER_ZERO = 0x30,
NUMBER_NINE = 0x39,
PERCENTAGE = 0x25,
CLOSING_PARENTHESIS = 0x29
}

View file

@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
export { imageSize } from './image-size'
export { taskLists } from './task-lists'
export * from './toc'

View file

@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
exports[`markdown-it-task-lists renders bullet correctly 1`] = `
"<ul class="contains-task-list">
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-1" />unchecked item 1</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-2" />unchecked item 2</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-3" />unchecked item 3</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-4" />checked item 4</li>
</ul>
"
`;
exports[`markdown-it-task-lists renders dirty correctly 1`] = `
"<ul class="contains-task-list">
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-1" />unchecked todo item 1</li>
<li>[ ]</li>
<li>[ ] not a todo item 2</li>
<li>[ x] not a todo item 3</li>
<li>[x ] not a todo item 4</li>
<li>[ x ] not a todo item 5</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-7" />todo item 6</li>
</ul>
"
`;
exports[`markdown-it-task-lists renders mixedNested correctly 1`] = `
"<h1>Test 1</h1>
<ol>
<li>foo
<ul class="contains-task-list">
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-4" />nested unchecked item 1</li>
<li>not a todo item 2</li>
<li>not a todo item 3</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-7" />nested checked item 4</li>
</ul>
</li>
<li>bar</li>
<li>spam</li>
</ol>
<h1>Test 2</h1>
<ul>
<li>foo
<ul class="contains-task-list">
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-14" />nested unchecked item 1</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-15" />nested unchecked item 2</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-16" />nested checked item 3</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-17" />nested checked item 4</li>
</ul>
</li>
</ul>
"
`;
exports[`markdown-it-task-lists renders ordered correctly 1`] = `
"<ol class="contains-task-list">
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-1" />checked ordered 1</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-2" />unchecked ordered 2</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" checked="" disabled="" id="task-item-3" />checked ordered 3</li>
<li class="task-list-item "><input class="task-list-item-checkbox" type="checkbox" disabled="" id="task-item-4" />unchecked ordered 4</li>
</ol>
"
`;

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import MarkdownIt from 'markdown-it/lib'
import { taskLists } from './index.js'
import { describe, expect, it } from '@jest/globals'
describe('markdown-it-task-lists', () => {
it('renders bullet correctly', () => {
const taskListMarkdownParser = new MarkdownIt().use(taskLists)
expect(
taskListMarkdownParser.render(`
- [ ] unchecked item 1
- [ ] unchecked item 2
- [ ] unchecked item 3
- [x] checked item 4
`)
).toMatchSnapshot()
})
it('renders dirty correctly', () => {
const taskListMarkdownParser = new MarkdownIt().use(taskLists)
expect(
taskListMarkdownParser.render(`
- [ ] unchecked todo item 1
- [ ]
- [ ] not a todo item 2
- [ x] not a todo item 3
- [x ] not a todo item 4
- [ x ] not a todo item 5
- [x] todo item 6
`)
).toMatchSnapshot()
})
it('renders mixedNested correctly', () => {
const taskListMarkdownParser = new MarkdownIt().use(taskLists)
expect(
taskListMarkdownParser.render(`
# Test 1
1. foo
* [ ] nested unchecked item 1
* not a todo item 2
* not a todo item 3
* [x] nested checked item 4
2. bar
3. spam
# Test 2
- foo
- [ ] nested unchecked item 1
- [ ] nested unchecked item 2
- [x] nested checked item 3
- [X] nested checked item 4
`)
).toMatchSnapshot()
})
it('renders ordered correctly', () => {
const taskListMarkdownParser = new MarkdownIt().use(taskLists)
expect(
taskListMarkdownParser.render(`
1. [x] checked ordered 1
2. [ ] unchecked ordered 2
3. [x] checked ordered 3
4. [ ] unchecked ordered 4
`)
).toMatchSnapshot()
})
})

View file

@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
// Markdown-it plugin to render GitHub-style task lists; see
//
// https://github.com/blog/1375-task-lists-in-gfm-issues-pulls-comments
// https://github.com/blog/1825-task-lists-in-all-markdown-documents
import MarkdownIt from 'markdown-it/lib'
import StateCore from 'markdown-it/lib/rules_core/state_core.js'
import Token from 'markdown-it/lib/token.js'
interface TaskListsOptions {
enabled: boolean
label: boolean
lineNumber: boolean
}
const checkboxRegex = /^ *\[([\sx])] /i
export function taskLists(
md: MarkdownIt,
options: TaskListsOptions = { enabled: false, label: false, lineNumber: false }
): void {
md.core.ruler.after('inline', 'task-lists', (state) => processToken(state, options))
md.renderer.rules.taskListItemCheckbox = (tokens) => {
const token = tokens[0]
const checkedAttribute = token.attrGet('checked') ? 'checked="" ' : ''
const disabledAttribute = token.attrGet('disabled') ? 'disabled="" ' : ''
const line = token.attrGet('line')
const idAttribute = `id="${token.attrGet('id')}" `
const dataLineAttribute = line && options.lineNumber ? `data-line="${line}" ` : ''
return `<input class="task-list-item-checkbox" type="checkbox" ${checkedAttribute}${disabledAttribute}${dataLineAttribute}${idAttribute}/>`
}
md.renderer.rules.taskListItemLabel_close = () => {
return '</label>'
}
md.renderer.rules.taskListItemLabel_open = (tokens: Token[]) => {
const token = tokens[0]
const id = token.attrGet('id')
return `<label for="${id}">`
}
}
function processToken(state: StateCore, options: TaskListsOptions): boolean {
const allTokens = state.tokens
for (let i = 2; i < allTokens.length; i++) {
if (!isTodoItem(allTokens, i)) {
continue
}
todoify(allTokens[i], options)
allTokens[i - 2].attrJoin('class', `task-list-item ${options.enabled ? ' enabled' : ''}`)
const parentToken = findParentToken(allTokens, i - 2)
if (parentToken) {
const classes = parentToken.attrGet('class') ?? ''
if (!classes.match(/(^| )contains-task-list/)) {
parentToken.attrJoin('class', 'contains-task-list')
}
}
}
return false
}
function findParentToken(tokens: Token[], index: number): Token | undefined {
const targetLevel = tokens[index].level - 1
for (let currentTokenIndex = index - 1; currentTokenIndex >= 0; currentTokenIndex--) {
if (tokens[currentTokenIndex].level === targetLevel) {
return tokens[currentTokenIndex]
}
}
return undefined
}
function isTodoItem(tokens: Token[], index: number): boolean {
return (
isInline(tokens[index]) &&
isParagraph(tokens[index - 1]) &&
isListItem(tokens[index - 2]) &&
startsWithTodoMarkdown(tokens[index])
)
}
function todoify(token: Token, options: TaskListsOptions): void {
if (token.children == null) {
return
}
const id = generateIdForToken(token)
token.children.splice(0, 0, createCheckboxToken(token, options.enabled, id))
token.children[1].content = token.children[1].content.replace(checkboxRegex, '')
if (options.label) {
token.children.splice(1, 0, createLabelBeginToken(id))
token.children.push(createLabelEndToken())
}
}
function generateIdForToken(token: Token): string {
if (token.map) {
return `task-item-${token.map[0]}`
} else {
return `task-item-${Math.ceil(Math.random() * (10000 * 1000) - 1000)}`
}
}
function createCheckboxToken(token: Token, enabled: boolean, id: string): Token {
const checkbox = new Token('taskListItemCheckbox', '', 0)
if (!enabled) {
checkbox.attrSet('disabled', 'true')
}
if (token.map) {
checkbox.attrSet('line', token.map[0].toString())
}
checkbox.attrSet('id', id)
const checkboxRegexResult = checkboxRegex.exec(token.content)
const isChecked = checkboxRegexResult?.[1].toLowerCase() === 'x'
if (isChecked) {
checkbox.attrSet('checked', 'true')
}
return checkbox
}
function createLabelBeginToken(id: string): Token {
const labelBeginToken = new Token('taskListItemLabel_open', '', 1)
labelBeginToken.attrSet('id', id)
return labelBeginToken
}
function createLabelEndToken(): Token {
return new Token('taskListItemLabel_close', '', -1)
}
function isInline(token: Token): boolean {
return token.type === 'inline'
}
function isParagraph(token: Token): boolean {
return token.type === 'paragraph_open'
}
function isListItem(token: Token): boolean {
return token.type === 'list_item_open'
}
function startsWithTodoMarkdown(token: Token): boolean {
return checkboxRegex.test(token.content)
}

View file

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
/*
* SPDX-FileCopyrightText: Original: (c) 2018 Fabio Zendhi Nagao / Modifications: (c) 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
exports[`toc ignores the levels in the placeholder if not sorted 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-1">Head 1</a><ol><li><a href="#head-2">Head 2</a><ol><li><a href="#head-3">Head 3</a><ol><li><a href="#head-4">Head 4</a></li></ol></li></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Head 2</h2>
<h3>Head 3</h3>
<h4>Head 4</h4>
"
`;
exports[`toc renders a toc with a level number array 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-2">Head 2</a><ol><li><a href="#head-4">Head 4</a></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Head 2</h2>
<h3>Head 3</h3>
<h4>Head 4</h4>
"
`;
exports[`toc renders a toc with a single level number 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-2">Head 2</a><ol><li><a href="#head-3">Head 3</a></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Head 2</h2>
<h3>Head 3</h3>
"
`;
exports[`toc renders a toc with custom allowed token types 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#text"> text</a></li><li><a href="#head-2">Head 2</a></li></ol></nav><h1><code>Head 1</code> text</h1>
<h1>Head 2</h1>
"
`;
exports[`toc renders a toc with custom classes 1`] = `
"<nav id="containerId" class="containerClass"><ol class="listClass"><li class="itemClass"><a class="linkClass" href="#head-1">Head 1</a><ol class="listClass"><li class="itemClass"><a class="linkClass" href="#heading-2">Heading 2</a><ol class="listClass"><li class="itemClass"><a class="linkClass" href="#heading-3">Heading 3</a></li></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
"
`;
exports[`toc renders a toc with custom format function 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-1">HEAD 1</a><ol><li><a href="#heading-2">HEADING 2</a><ol><li><a href="#heading-3">HEADING 3</a></li></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
"
`;
exports[`toc renders a toc with custom slugify 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#slug-Head 1-0">Head 1</a><ol><li><a href="#slug-Heading 2-0">Heading 2</a><ol><li><a href="#slug-Heading 3-0">Heading 3</a></li></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
"
`;
exports[`toc renders a toc with custom unique slug start index 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-1-10">Head 1</a><ol><li><a href="#heading-2-10">Heading 2</a><ol><li><a href="#heading-3-10">Heading 3</a></li></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
"
`;
exports[`toc renders a toc with default settings 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-1">Head 1</a><ol><li><a href="#heading-2">Heading 2</a><ol><li><a href="#heading-3">Heading 3</a></li></ol></li></ol></li><li><a href="#head-1-1">Head 1</a></li><li><a href="#head-1-2">Head 1</a></li><li><a href="#head-1-3">Head 1</a></li></ol></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h1>Head 1</h1>
<h1>Head 1</h1>
<h1>Head 1</h1>
"
`;
exports[`toc renders a toc with levels in the placeholder 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-2">Head 2</a><ol><li><a href="#head-3">Head 3</a><ol></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Head 2</h2>
<h3>Head 3</h3>
<h4>Head 4</h4>
"
`;
exports[`toc renders a toc with ordered list 1`] = `
"<nav class="table-of-contents"><ol><li><a href="#head-1">Head 1</a><ol><li><a href="#heading-2">Heading 2</a><ol><li><a href="#heading-3">Heading 3</a></li></ol></li></ol></li></ol></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
"
`;
exports[`toc renders a toc with unordered list 1`] = `
"<nav class="table-of-contents"><ul><li><a href="#head-1">Head 1</a><ul><li><a href="#heading-2">Heading 2</a><ul><li><a href="#heading-3">Heading 3</a></li></ul></li></ul></li></ul></nav><h1>Head 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
"
`;

View file

@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import MarkdownIt from 'markdown-it/lib'
import { toc } from './plugin.js'
import { describe, expect, it, jest } from '@jest/globals'
describe('toc', () => {
const simpleContent = `
[toc]
# Head 1
## Heading 2
### Heading 3
`
it('renders a toc with default settings', () => {
const markdownIt = new MarkdownIt().use(toc)
expect(
markdownIt.render(`
[toc]
# Head 1
## Heading 2
### Heading 3
# Head 1
# Head 1
# Head 1
`)
).toMatchSnapshot()
})
it('renders a toc with custom slugify', () => {
const markdownIt = new MarkdownIt().use(toc, { slugify: (slug, index) => `slug-${slug}-${index}` })
expect(markdownIt.render(simpleContent)).toMatchSnapshot()
})
it('renders a toc with custom unique slug start index', () => {
const markdownIt = new MarkdownIt().use(toc, { uniqueSlugStartIndex: 10 })
expect(markdownIt.render(simpleContent)).toMatchSnapshot()
})
it('renders a toc with custom classes', () => {
const markdownIt = new MarkdownIt().use(toc, {
containerClass: 'containerClass',
listClass: 'listClass',
itemClass: 'itemClass',
linkClass: 'linkClass',
containerId: 'containerId'
})
expect(markdownIt.render(simpleContent)).toMatchSnapshot()
})
it('renders a toc with a single level number', () => {
const markdownIt = new MarkdownIt().use(toc, { level: 2 })
expect(
markdownIt.render(`
[toc]
# Head 1
## Head 2
### Head 3
`)
).toMatchSnapshot()
})
it('renders a toc with a level number array', () => {
const markdownIt = new MarkdownIt().use(toc, { level: [2, 4] })
expect(
markdownIt.render(`
[toc]
# Head 1
## Head 2
### Head 3
#### Head 4
`)
).toMatchSnapshot()
})
it('renders a toc with levels in the placeholder', () => {
const markdownIt = new MarkdownIt().use(toc)
expect(
markdownIt.render(`
[toc:2:3]
# Head 1
## Head 2
### Head 3
#### Head 4
`)
).toMatchSnapshot()
})
it('ignores the levels in the placeholder if not sorted', () => {
const markdownIt = new MarkdownIt().use(toc)
expect(
markdownIt.render(`
[toc:3:2]
# Head 1
## Head 2
### Head 3
#### Head 4
`)
).toMatchSnapshot()
})
it('renders a toc with ordered list', () => {
const markdownIt = new MarkdownIt().use(toc, { listType: 'ol' })
expect(markdownIt.render(simpleContent)).toMatchSnapshot()
})
it('renders a toc with unordered list', () => {
const markdownIt = new MarkdownIt().use(toc, { listType: 'ul' })
expect(markdownIt.render(simpleContent)).toMatchSnapshot()
})
it('renders a toc with custom format function', () => {
const markdownIt = new MarkdownIt().use(toc, { format: (name) => name.toUpperCase() })
expect(markdownIt.render(simpleContent)).toMatchSnapshot()
})
it('renders a toc and executes the callback', () => {
const callback = jest.fn()
const markdownIt = new MarkdownIt().use(toc, { callback })
markdownIt.render(simpleContent)
expect(callback).toBeCalledWith({
children: [
{
children: [
{
children: [
{
children: [],
level: 3,
name: 'Heading 3'
}
],
level: 2,
name: 'Heading 2'
}
],
level: 1,
name: 'Head 1'
}
],
level: 0,
name: ''
})
})
it('renders a toc with custom allowed token types', () => {
const markdownIt = new MarkdownIt().use(toc, { allowedTokenTypes: ['text'] })
expect(
markdownIt.render(`
[toc]
# \`Head 1\` text
# Head 2
`)
).toMatchSnapshot()
})
})

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
export { toc } from './plugin.js'
export { defaultOptions } from './toc-options.js'
export type { TocAst } from './toc-ast.js'
export type { TocOptions } from './toc-options.js'

View file

@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: Original: (c) 2018 Fabio Zendhi Nagao / Modifications: (c) 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import { Optional } from '@mrdrogdrog/optional'
import { encode as htmlencode } from 'html-entities'
import MarkdownIt from 'markdown-it'
import StateBlock from 'markdown-it/lib/rules_block/state_block.js'
import Token from 'markdown-it/lib/token.js'
import { TocAst } from './toc-ast.js'
import { renderAstToHtml } from './toc-body-renderer.js'
import { defaultOptions, TocOptions } from './toc-options.js'
class Plugin {
private readonly tocOptions: TocOptions
private currentAst?: TocAst
public readonly START_LEVEL_ATTRIBUTE_NAME = 'startLevel'
public readonly END_LEVEL_ATTRIBUTE_NAME = 'endLevel'
private readonly TOC_PLACEHOLDER = /^\[\[toc(?::(\d+):(\d+))?]]|\[toc(?::(\d+):(\d+))?]$/i
public constructor(md: MarkdownIt, tocOptions?: Partial<TocOptions>) {
this.tocOptions = {
...defaultOptions,
...tocOptions
}
md.renderer.rules.tocOpen = this.renderTocOpen.bind(this)
md.renderer.rules.tocClose = this.renderTocClose.bind(this)
md.renderer.rules.tocBody = this.renderTocBody.bind(this)
md.core.ruler.push('generateTocAst', (state) => this.generateTocAst(state.tokens))
md.block.ruler.before('heading', 'toc', this.generateTocToken.bind(this), {
alt: ['paragraph', 'reference', 'blockquote']
})
}
private generateTocToken(state: StateBlock, startLine: number, _endLine: number, silent: boolean): boolean {
const pos = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
// use whitespace as a line tokenizer and extract the first token
// to test against the placeholder anchored pattern, rejecting if false
const lineFirstToken = state.src.slice(pos, max).split(' ')[0]
const matches = this.TOC_PLACEHOLDER.exec(lineFirstToken)
if (matches === null) {
return false
}
if (silent) {
return true
}
state.line = startLine + 1
const tocOpenToken = state.push('tocOpen', 'nav', 1)
tocOpenToken.markup = ''
tocOpenToken.map = [startLine, state.line]
const tocBodyToken = state.push('tocBody', '', 0)
tocBodyToken.markup = ''
tocBodyToken.map = [startLine, state.line]
tocBodyToken.children = []
const startLevel = matches[3]
const endLevel = matches[4]
if (startLevel !== undefined && endLevel !== undefined) {
tocBodyToken.attrSet(this.START_LEVEL_ATTRIBUTE_NAME, startLevel)
tocBodyToken.attrSet(this.END_LEVEL_ATTRIBUTE_NAME, endLevel)
}
const tocCloseToken = state.push('tocClose', 'nav', -1)
tocCloseToken.markup = ''
return true
}
private generateTocAst(tokens: Token[]) {
this.currentAst = this.headings2ast(tokens)
this.tocOptions.callback?.(this.currentAst)
}
private renderTocOpen(): string {
const id = this.tocOptions.containerId ? ` id="${htmlencode(this.tocOptions.containerId)}"` : ''
return `<nav${id} class="${htmlencode(this.tocOptions.containerClass)}">`
}
private renderTocClose(): string {
return '</nav>'
}
private createNumberRangeArray(from: number, to: number): number[] {
return Array.from(Array(to - from + 1).keys()).map((value) => value + from)
}
private renderTocBody(tokens: Token[], index: number): string {
const bodyToken = tokens[index]
const startLevel = Optional.ofNullable(bodyToken?.attrGet(this.START_LEVEL_ATTRIBUTE_NAME))
.map(parseInt)
.filter(isFinite)
.orElse(null)
const endLevel = Optional.ofNullable(bodyToken?.attrGet(this.END_LEVEL_ATTRIBUTE_NAME))
.map(parseInt)
.filter(isFinite)
.orElse(null)
const modifiedTocOptions =
startLevel !== null && endLevel !== null && startLevel <= endLevel
? { ...this.tocOptions, level: this.createNumberRangeArray(startLevel, endLevel) }
: this.tocOptions
return this.currentAst ? renderAstToHtml(this.currentAst, modifiedTocOptions) : ''
}
private headings2ast(tokens: Token[]): TocAst {
const ast: TocAst = { level: 0, name: '', children: [] }
const stack = [ast]
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) {
const token = tokens[tokenIndex]
if (token.type !== 'heading_open') {
continue
}
const nextToken = tokens[tokenIndex + 1]
const key = (nextToken?.children ?? [])
.filter((token) => this.tocOptions.allowedTokenTypes.includes(token.type))
.reduce((s, t) => s + t.content, '')
const node: TocAst = {
level: parseInt(token.tag.slice(1), 10),
name: key,
children: []
}
if (node.level > stack[0].level) {
stack[0].children.push(node)
stack.unshift(node)
} else if (node.level === stack[0].level) {
stack[1].children.push(node)
stack[0] = node
} else {
while (node.level <= stack[0].level) stack.shift()
stack[0].children.push(node)
stack.unshift(node)
}
}
return ast
}
}
/**
* Creates a new TOC plugin.
*
* @param md The markdown-it instance that should be configured
* @param options The additional options that configure the plugin
*/
export const toc: MarkdownIt.PluginWithOptions<Partial<TocOptions>> = (md, options) => new Plugin(md, options)

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
export interface TocAst {
level: number
name: string
children: TocAst[]
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import { encode as htmlencode } from 'html-entities'
import { TocAst } from './toc-ast.js'
import { TocOptions } from './toc-options.js'
/**
* Renders an HTML listing of the given tree.
*
* @param tree The tree that should be represented as HTML tree
* @param tocOptions additional options that configure the rendering
*/
export function renderAstToHtml(tree: TocAst, tocOptions: TocOptions): string {
if (tree.children.length === 0) {
return ''
}
let buffer = ''
const tag = htmlencode(tocOptions.listType)
if (tree.level === 0 || isLevelSelected(tree.level, tocOptions.level)) {
const listClass = tocOptions.listClass !== '' ? ` class="${htmlencode(tocOptions.listClass)}"` : ''
buffer += `<${tag + listClass}>`
}
const usedSlugs: string[] = []
const parts = tree.children.map((node) => {
const subNodesHtml = renderAstToHtml(node, tocOptions)
if (isLevelSelected(node.level, tocOptions.level)) {
const anchorContent = htmlencode(tocOptions.format?.(node.name) ?? node.name)
const anchorId = generateUniqueSlug(node.name, tocOptions, usedSlugs)
usedSlugs.push(anchorId)
const itemClass = tocOptions.itemClass !== '' ? ` class="${htmlencode(tocOptions.itemClass)}"` : ''
const linkClass = tocOptions.linkClass !== '' ? ` class="${htmlencode(tocOptions.linkClass)}"` : ''
return `<li${itemClass}><a${linkClass} href="#${anchorId}">${anchorContent}</a>${subNodesHtml}</li>`
} else {
return subNodesHtml
}
})
buffer += parts.join('')
if (tree.level === 0 || isLevelSelected(tree.level, tocOptions.level)) {
buffer += `</${tag}>`
}
return buffer
}
function isLevelSelected(level: number, levels: number | number[]): boolean {
return Array.isArray(levels) ? levels.includes(level) : level >= levels
}
function generateUniqueSlug(slug: string, tocOptions: TocOptions, usedSlugs: string[]): string {
for (let index = tocOptions.uniqueSlugStartIndex; index < Number.MAX_VALUE; index += 1) {
const slugCandidate: string = tocOptions.slugify(slug, index)
const slugWithIndex = index === 0 ? slugCandidate : `${slugCandidate}-${index}`
if (!usedSlugs.includes(slugWithIndex)) {
return slugWithIndex
}
}
throw new Error('Too many slug with same name')
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: MIT
*/
import { TocAst } from './toc-ast.js'
export type TocOptions = {
slugify: (name: string, index: number) => string
uniqueSlugStartIndex: number
containerClass: string
containerId: string
listClass: string
itemClass: string
linkClass: string
level: number | number[]
listType: 'ol' | 'ul'
format?: (name: string) => string
callback?: (ast: TocAst) => void
allowedTokenTypes: string[]
}
function defaultSlugify(name: string) {
return encodeURIComponent(String(name).trim().toLowerCase().replace(/\s+/g, '-'))
}
/**
* The default options for the toc plugin.
*/
export const defaultOptions: TocOptions = {
uniqueSlugStartIndex: 0,
containerClass: 'table-of-contents',
containerId: '',
listClass: '',
itemClass: '',
linkClass: '',
level: 1,
listType: 'ol',
allowedTokenTypes: ['text', 'code_inline'],
slugify: defaultSlugify
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"removeComments": true,
"preserveConstEnums": true,
"lib": [
"es2022",
"dom"
],
"declaration": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"allowJs": true,
"sourceMap": true
},
"include": [
"src"
],
"exclude": [
"../dist", "**/*.test.ts"]
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,10 @@
{
"extends" : "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES2015",
"outDir": "../dist/cjs",
"declarationDir": "../dist/cjs",
"moduleResolution": "node"
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,10 @@
{
"extends" : "./tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"target" : "esnext",
"outDir": "../dist/esm",
"moduleResolution": "NodeNext",
"declarationDir": "../dist/esm"
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,6 @@
{
"extends" : "./tsconfig.esm.json",
"exclude": [
"../dist"
]
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2023 Tilman Vatteroth
SPDX-License-Identifier: CC0-1.0