diff --git a/README.md b/README.md index 634b9af..49b66ef 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,13 @@ Options: Where target directore is `/`
Default: `true`
+- `extraAttrs` – array of additional attributes in format `[name, value]`, that will be added to file links\ + Default: `undefined` + +- `directiveSyntax` – enables new [directive syntax](#directive-syntax). \ + Available values: `'disabled' | 'enabled' | 'only'`\ + Default: `'disabled'` + ## File markup ```md @@ -75,23 +82,43 @@ Options: ### Supported attributes: -| Name | Required | Description | -| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src` | yes | URL of the file. Will be mapped to [`href` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-href) | -| `name` | yes | Name of the file. Will be mapped to [`download` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download) | -| `lang` | - | Language of the file content. Will be mapped to [`hreflang` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-hreflang) | -| `referrerpolicy` | - | [`referrerpolicy` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-referrerpolicy) | -| `rel` | - | [`rel` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-rel) | -| `target` | - | [`target` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target) | -| `type` | - | [`type` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-type) | +| Name | Required | Description | +| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `src` | yes | URL of the file. Will be mapped to [`href` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href) | +| `name` | yes | Name of the file. Will be mapped to [`download` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download) | +| `lang` | - | Language of the file content. Will be mapped to [`hreflang` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#hreflang) | +| `referrerpolicy` | - | [`referrerpolicy` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy) | +| `rel` | - | [`rel` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#rel) | +| `target` | - | [`target` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target) | +| `type` | - | [`type` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#type) | > _Note: other attributes will be ignored_ -### Plugin options +## Directive syntax + +Also you can use inline directive syntax for file links. For more information see here: [diplodoc-platform/directive](https://github.com/diplodoc-platform/directive/?tab=readme-ov-file#directive-syntax). + +To enable directive syntax pass `directiveSyntax: 'enabled'` to options. Or you can disabled old syntax and use only directive syntax: `directiveSyntax: 'only'`. + +```md +:file[](file-url) + + -| Name | Type | Description | -| ---------------- | -------------------- | ------------------------------------------------ | -| `fileExtraAttrs` | `[string, string][]` | Adds additional attributes to rendered hyperlink | +:file[readme.md](path/to/readme/md){type=text/plain} +``` + +### Supported attributes: + +| Name | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------- | +| `hreflang` | [anchor `hreflang` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#hreflang) | +| `referrerpolicy` | [anchor `referrerpolicy` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy) | +| `rel` | [anchor `rel` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#rel) | +| `target` | [anchor `target` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target) | +| `type` | [anchor `type` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#type) | + +> _Note: other attributes will be ignored_ ## CSS public variables diff --git a/package-lock.json b/package-lock.json index 838ff4c..a3069c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@diplodoc/file-extension", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@diplodoc/directive": "^0.3.0" + }, "devDependencies": { "@diplodoc/lint": "^1.2.0", "@diplodoc/tsconfig": "^1.0.2", @@ -497,6 +500,14 @@ "postcss-selector-parser": "^6.0.13" } }, + "node_modules/@diplodoc/directive": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@diplodoc/directive/-/directive-0.3.0.tgz", + "integrity": "sha512-1UD7UHthRqO0rju/XNAaKQIxSy/pa1giEMlARBZ7U4ZPi1QeTw+QNyXy1TwvnBb5hc6jAChTjfOxDwpct1AEdg==", + "dependencies": { + "markdown-it-directive": "2.0.4" + } + }, "node_modules/@diplodoc/lint": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@diplodoc/lint/-/lint-1.2.0.tgz", @@ -1604,14 +1615,12 @@ "node_modules/@types/linkify-it": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", - "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", - "dev": true + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==" }, "node_modules/@types/markdown-it": { "version": "13.0.9", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", - "dev": true, "dependencies": { "@types/linkify-it": "^3", "@types/mdurl": "^1" @@ -1620,8 +1629,7 @@ "node_modules/@types/mdurl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", - "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", - "dev": true + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==" }, "node_modules/@types/minimist": { "version": "1.2.5", @@ -5327,6 +5335,15 @@ "markdown-it": "bin/markdown-it.js" } }, + "node_modules/markdown-it-directive": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/markdown-it-directive/-/markdown-it-directive-2.0.4.tgz", + "integrity": "sha512-XtGbBcP0aoRgKiDIIsxvgSXZz3ptI5AOydLAfF+n/LRmdy4ROdHntboJMEg8Fd0B+SMtOynCpiDFDKboatPbMw==", + "peerDependencies": { + "@types/markdown-it": "^12.0.0 || ^13.0.0", + "markdown-it": "^12.0.0 || ^13.0.0" + } + }, "node_modules/markdown-it/node_modules/entities": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", diff --git a/package.json b/package.json index 5d8b776..4cc0155 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,9 @@ "npm-run-all": "^4.1.5", "typescript": "^5.6.3" }, + "dependencies": { + "@diplodoc/directive": "^0.3.0" + }, "peerDependencies": { "markdown-it": "^13.0.0" } diff --git a/src/plugin/directive.ts b/src/plugin/directive.ts new file mode 100644 index 0000000..388d633 --- /dev/null +++ b/src/plugin/directive.ts @@ -0,0 +1,59 @@ +import type {FileOptions} from './plugin'; + +import {directiveParser, registerInlineDirective} from '@diplodoc/directive'; + +import {fileRenderer} from './renderer'; +import {ENV_FLAG_NAME, FILE_TOKEN, FileClassName, LinkHtmlAttr} from './const'; + +const ALLOWED_ATTRS: readonly string[] = [ + LinkHtmlAttr.ReferrerPolicy, + LinkHtmlAttr.Rel, + LinkHtmlAttr.Target, + LinkHtmlAttr.Type, + LinkHtmlAttr.HrefLang, +]; + +export const fileDirective: markdownit.PluginWithOptions = (md, opts = {}) => { + const {fileExtraAttrs} = opts; + + fileRenderer(md); + + md.use(directiveParser()); + + registerInlineDirective(md, 'file', (state, params) => { + if (!params.content || !params.dests) { + return false; + } + + const filename = params.content.raw; + const filelink = params.dests.link || ''; + + const token = state.push(FILE_TOKEN, '', 0); + token.block = false; + token.markup = ':file'; + token.content = params.content.raw; + + token.attrSet('class', FileClassName.Link); + token.attrSet(LinkHtmlAttr.Href, filelink); + token.attrSet(LinkHtmlAttr.Download, filename); + + if (params.attrs) { + for (const attrName of ALLOWED_ATTRS) { + if (params.attrs[attrName]) { + token.attrSet(attrName, params.attrs[attrName]); + } + } + } + + if (Array.isArray(fileExtraAttrs)) { + for (const [name, value] of fileExtraAttrs) { + token.attrSet(name, value); + } + } + + state.env ??= {}; + state.env[ENV_FLAG_NAME] = true; + + return true; + }); +}; diff --git a/src/plugin/plugin.ts b/src/plugin/plugin.ts index 5fc30a4..495d89d 100644 --- a/src/plugin/plugin.ts +++ b/src/plugin/plugin.ts @@ -1,5 +1,3 @@ -import type MarkdownIt from 'markdown-it'; - import { ENV_FLAG_NAME, FILE_TOKEN, @@ -12,12 +10,15 @@ import { REQUIRED_ATTRS, RULE_NAME, } from './const'; +import {fileRenderer} from './renderer'; export type FileOptions = { fileExtraAttrs?: [string, string][]; }; -export const filePlugin: MarkdownIt.PluginWithOptions = (md, opts) => { +export const filePlugin: markdownit.PluginWithOptions = (md, opts) => { + fileRenderer(md); + md.inline.ruler.push(RULE_NAME, (state, silent) => { if (state.src.substring(state.pos, state.pos + PREFIX_LENGTH) !== PREFIX) { return false; @@ -82,10 +83,4 @@ export const filePlugin: MarkdownIt.PluginWithOptions = (md, opts) return true; }); - - md.renderer.rules[FILE_TOKEN] = (tokens, idx, _opts, _env, self) => { - const token = tokens[idx]; - const iconHtml = ``; - return `${iconHtml}${md.utils.escapeHtml(token.content)}`; - }; }; diff --git a/src/plugin/renderer.ts b/src/plugin/renderer.ts new file mode 100644 index 0000000..d1a1bfa --- /dev/null +++ b/src/plugin/renderer.ts @@ -0,0 +1,9 @@ +import {FILE_TOKEN, FileClassName} from './const'; + +export const fileRenderer: markdownit.PluginSimple = (md) => { + md.renderer.rules[FILE_TOKEN] = (tokens, idx, _opts, _env, self) => { + const token = tokens[idx]; + const iconHtml = ``; + return `${iconHtml}${md.utils.escapeHtml(token.content)}`; + }; +}; diff --git a/src/plugin/transform.ts b/src/plugin/transform.ts index 7bfbfe5..49da4e8 100644 --- a/src/plugin/transform.ts +++ b/src/plugin/transform.ts @@ -3,6 +3,7 @@ import MarkdownIt from 'markdown-it'; import {type FileOptions, filePlugin} from './plugin'; import {ENV_FLAG_NAME} from './const'; import {hidden} from './utils'; +import {fileDirective} from './directive'; export type PluginOptions = FileOptions & { output?: string; @@ -14,6 +15,16 @@ export type TransformOptions = { runtime?: string | {style: string}; bundle?: boolean; extraAttrs?: FileOptions['fileExtraAttrs']; + /** + * Enables directive syntax of yfm-file. + * + * - 'disabled' – directive syntax is disabled. + * - 'enabled' – directive syntax is enabled; old (curly bracket) syntax is also enabled. + * - 'only' – enabled only directive syntax; old (curly bracket) syntax is disabled. + * + * @default 'disabled' + */ + directiveSyntax?: 'disabled' | 'enabled' | 'only'; /** @internal */ onBundle?: (env: {bundled: Set}, output: string, runtime: RuntimeObj) => void; }; @@ -26,13 +37,20 @@ const registerTransform = ( onBundle, runtime, output, + directiveSyntax, }: Pick & { bundle: boolean; runtime: {style: string}; output: string; + directiveSyntax: NonNullable; }, ) => { - filePlugin(md, {fileExtraAttrs: extraAttrs}); + if (directiveSyntax === 'disabled' || directiveSyntax === 'enabled') { + filePlugin(md, {fileExtraAttrs: extraAttrs}); + } + if (directiveSyntax === 'enabled' || directiveSyntax === 'only') { + fileDirective(md, {fileExtraAttrs: extraAttrs}); + } md.core.ruler.push('yfm_file_after', ({env}) => { if (env?.[ENV_FLAG_NAME]) { @@ -49,7 +67,7 @@ const registerTransform = ( }; export const transform = (opts: TransformOptions = {}) => { - const {bundle = true} = opts; + const {bundle = true, directiveSyntax = 'disabled'} = opts; if (bundle && typeof opts.runtime === 'string') { throw new TypeError('Option `runtime` should be record when `bundle` is enabled.'); @@ -68,6 +86,7 @@ export const transform = (opts: TransformOptions = {}) => { bundle, runtime, output, + directiveSyntax, onBundle: opts.onBundle, extraAttrs: fileExtraAttrs ?? opts.extraAttrs, }); @@ -79,6 +98,7 @@ export const transform = (opts: TransformOptions = {}) => { registerTransform(md, { bundle, runtime, + directiveSyntax, output: destRoot, onBundle: opts.onBundle, extraAttrs: opts.extraAttrs, diff --git a/tests/src/__snapshots__/index.test.ts.snap b/tests/src/__snapshots__/index.test.ts.snap index 57b5f84..dc496b6 100644 --- a/tests/src/__snapshots__/index.test.ts.snap +++ b/tests/src/__snapshots__/index.test.ts.snap @@ -1,5 +1,128 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`File extension - plugin Options: directiveSyntax should render both file markups 1`] = ` +"

file.txt

+

video.mp4

+" +`; + +exports[`File extension - plugin Options: directiveSyntax should render only new (directive) file markup 1`] = ` +"

{% file src="../file" name="file.txt" %}

+

video.mp4

+" +`; + +exports[`File extension - plugin Options: directiveSyntax should render only old file markup 1`] = ` +"

file.txt

+

:filevideo.mp4

+" +`; + +exports[`File extension - plugin dyrective syntax should add allowed attrs 1`] = ` +"

readme.md

+" +`; + +exports[`File extension - plugin dyrective syntax should generate file token 1`] = ` +[ + Token { + "attrs": null, + "block": true, + "children": null, + "content": "", + "hidden": false, + "info": "", + "level": 0, + "map": [ + 0, + 1, + ], + "markup": "", + "meta": null, + "nesting": 1, + "tag": "p", + "type": "paragraph_open", + }, + Token { + "attrs": null, + "block": true, + "children": [ + Token { + "attrs": [ + [ + "class", + "yfm-file", + ], + [ + "href", + "path/to/video", + ], + [ + "download", + "video.mp4", + ], + ], + "block": false, + "children": null, + "content": "video.mp4", + "hidden": false, + "info": "", + "level": 0, + "map": null, + "markup": ":file", + "meta": null, + "nesting": 0, + "tag": "", + "type": "yfm_file", + }, + ], + "content": ":file[video.mp4](path/to/video)", + "hidden": false, + "info": "", + "level": 1, + "map": [ + 0, + 1, + ], + "markup": "", + "meta": null, + "nesting": 0, + "tag": "", + "type": "inline", + }, + Token { + "attrs": null, + "block": true, + "children": null, + "content": "", + "hidden": false, + "info": "", + "level": 0, + "map": null, + "markup": "", + "meta": null, + "nesting": -1, + "tag": "p", + "type": "paragraph_close", + }, +] +`; + +exports[`File extension - plugin dyrective syntax should not add attrs not from whitelist 1`] = ` +"

a.md

+" +`; + +exports[`File extension - plugin dyrective syntax should render file directive 1`] = ` +"

video.mp4

+" +`; + +exports[`File extension - plugin dyrective syntax should render file inside text 1`] = ` +"

beforefile.txtafter

+" +`; + exports[`File extension - plugin should add extra attrs 1`] = ` "

file.txt

" diff --git a/tests/src/index.test.ts b/tests/src/index.test.ts index d0b9278..2380414 100644 --- a/tests/src/index.test.ts +++ b/tests/src/index.test.ts @@ -1,5 +1,6 @@ import MarkdownIt from 'markdown-it'; import transform from '@diplodoc/transform'; +import dd from 'ts-dedent'; import {type TransformOptions, transform as fileTransformer} from '../../src/plugin'; @@ -7,8 +8,8 @@ function html(markup: string, opts?: TransformOptions) { return transform(markup, { // override the default markdown-it-attrs delimiters, // to make it easier to check html for non-valid file markup - leftDelimiter: '[', - rightDelimiter: ']', + leftDelimiter: '[[', + rightDelimiter: ']]', plugins: [fileTransformer({bundle: false, ...opts})], }).result.html; } @@ -175,4 +176,73 @@ describe('File extension - plugin', () => { }); expect(md.render('{% file src="../file" name="file.txt" %}')).toMatchSnapshot(); }); + + describe('dyrective syntax', () => { + it('should render file directive', () => { + expect( + html(':file[video.mp4](path/to/video)', {directiveSyntax: 'only'}), + ).toMatchSnapshot(); + }); + + it('should render file inside text', () => { + expect( + html('before:file[file.txt](../../docs/readme.md)after', {directiveSyntax: 'only'}), + ).toMatchSnapshot(); + }); + + it('should not render file without file url', () => { + expect(html(':file[file.txt]', {directiveSyntax: 'only'})).toBe( + `

:file[file.txt]

\n`, + ); + }); + + it('should not render file without file name', () => { + expect(html(':file(path/to/file)', {directiveSyntax: 'only'})).toBe( + `

:file(path/to/file)

\n`, + ); + }); + + it('should not add attrs not from whitelist', () => { + expect( + html(':file[a.md](a.md){data-list=1}', {directiveSyntax: 'only'}), + ).toMatchSnapshot(); + }); + + it('should add allowed attrs', () => { + expect( + html( + ':file[readme.md](../readme){referrerpolicy=origin rel=external target=\'_blank\' type="text/plain" hreflang=en}', + { + directiveSyntax: 'only', + }, + ), + ).toMatchSnapshot(); + }); + + it('should generate file token', () => { + expect( + tokens(':file[video.mp4](path/to/video)', {directiveSyntax: 'only'}), + ).toMatchSnapshot(); + }); + }); + + describe('Options: directiveSyntax', () => { + const markup = dd` + {% file src="../file" name="file.txt" %} + + :file[video.mp4](path/to/video) + `; + + it('should render only old file markup', () => { + expect(html(markup, {directiveSyntax: 'disabled'})).toMatchSnapshot(); + }); + + it('should render only new (directive) file markup', () => { + expect(html(markup, {directiveSyntax: 'only'})).toMatchSnapshot(); + }); + + it('should render both file markups', () => { + expect(html(markup, {directiveSyntax: 'enabled'})).toMatchSnapshot(); + }); + }); });