diff --git a/src/transform/plugins/table/attrs.ts b/src/transform/plugins/table/attrs.ts new file mode 100644 index 00000000..f332661d --- /dev/null +++ b/src/transform/plugins/table/attrs.ts @@ -0,0 +1,199 @@ +type Attrs = 'class' | 'id' | 'attr'; + +export class AttrsParser { + DELIMITER = '='; + SEPARATOR = ' '; + QUOTATION = '"'; + /* allowed in keys / values chars */ + ALLOWED_CHARS = /[a-zA-Z0-9_\- {}.|/]/; + /* allowed in all query chars */ + VALIDATION_CHARS = /[a-zA-Z0-9_\- {}.#="|/]/; + + #key = ''; + #pending = ''; + #isInsideQuotation = false; + #didQuotationClosed = false; + #currentKeyType: Attrs | undefined; + + #selectors: Record = { + id: /#/, + class: /\./, + attr: /[a-zA-Z-_]/, + }; + + #handlers = Object.entries(this.#selectors) as [Attrs, RegExp][]; + #state: Record = {}; + + parse(target: string): Record { + /* escape from {} */ + const content = this.extract(target); + + if (!content) { + return {}; + } + + for (const char of content) { + this.next(char); + } + + /* end-of-content mark */ + this.next(this.SEPARATOR); + + this.clear(); + + return this.#state; + } + + private extract(target: string): string | false { + if (!target.startsWith('{')) { + return false; + } + let balance = 1; + + for (let i = 1; i < target.length; i++) { + const char = target[i]; + + if (char === '}') { + balance--; + } + + if (char === '{') { + balance++; + } + + if (balance === 0) { + const contentInside = target.slice(1, i).trim(); + + return contentInside; + } + + if (balance < 0) { + return false; + } + + if (!this.VALIDATION_CHARS.test(char)) { + return false; + } + } + + return false; + } + + private next(value: string) { + if (!this.#currentKeyType) { + this.#currentKeyType = this.type(value); + + if (this.#currentKeyType === 'attr') { + this.#pending = value; + } + + return; + } + + if (this.isSeparator(value)) { + if (!this.#pending) { + /* (name= ) construction */ + if (!this.#isInsideQuotation) { + this.append(this.#key, ' '); + this.clear(); + + return; + } + } + + /* single key (.name #id contenteditable) */ + if (!this.#key && this.#pending) { + this.append(); + this.clear(); + + return; + } + + /* trying to find close quotation */ + if (this.#isInsideQuotation && !this.#didQuotationClosed) { + this.#pending += value; + return; + } + + if (this.#isInsideQuotation && this.#didQuotationClosed) { + this.append(this.#key, this.#pending); + } + + if (!this.#isInsideQuotation && !this.#didQuotationClosed) { + this.append(this.#key, this.#pending); + } + + this.clear(); + + return; + } + + if (this.isAllowedChar(value)) { + this.#pending += value; + + return; + } + + if (this.isQuotation(value)) { + if (this.#isInsideQuotation) { + this.#didQuotationClosed = true; + } else { + this.#isInsideQuotation = true; + } + } + + if (this.isDelimiter(value)) { + /* symbol is not delimiter, adding it to value */ + if (this.#key) { + this.#pending += value; + + return; + } + + this.#key = this.#pending; + this.#pending = ''; + } + } + + private type(of: string): Attrs | undefined { + return this.#handlers.find(([_, regex]) => regex.test(of))?.[0]; + } + + private append(key: string | undefined = this.#currentKeyType, value: string = this.#pending) { + if (!key) { + return; + } + + if (!this.#state[key]) { + this.#state[key] = []; + } + + this.#state[key].push(value); + } + + private clear() { + this.#key = ''; + this.#pending = ''; + + this.#isInsideQuotation = false; + this.#didQuotationClosed = false; + + this.#currentKeyType = undefined; + } + + private isDelimiter(target: string) { + return target === this.DELIMITER; + } + + private isSeparator(target: string) { + return target === this.SEPARATOR; + } + + private isQuotation(target: string) { + return target === this.QUOTATION; + } + + private isAllowedChar(target: string) { + return this.ALLOWED_CHARS.test(target); + } +} diff --git a/src/transform/plugins/table/index.ts b/src/transform/plugins/table/index.ts index 5e116ae8..6da42f82 100644 --- a/src/transform/plugins/table/index.ts +++ b/src/transform/plugins/table/index.ts @@ -1,7 +1,7 @@ import StateBlock from 'markdown-it/lib/rules_block/state_block'; import {MarkdownItPluginCb} from '../typings'; import Token from 'markdown-it/lib/token'; -import {parseAttrs} from './utils'; +import {AttrsParser} from './attrs'; const pluginName = 'yfm_table'; const pipeChar = 0x7c; // | @@ -225,7 +225,9 @@ function extractAttributes(state: StateBlock, pos: number): Record { const tableStart = state.tokens.length; token = state.push('yfm_table_open', 'table', 1); - for (const [property, values] of Object.entries(attrs)) { + const {attr: singleKeyAttrs = [], ...fullAttrs} = attrs; + for (const [property, values] of Object.entries(fullAttrs)) { token.attrJoin(property, values.join(' ')); } + for (const attr of singleKeyAttrs) { + token.attrJoin(attr, 'true'); + } + token.map = [startLine, endOfTable]; token = state.push('yfm_tbody_open', 'tbody', 1); diff --git a/src/transform/plugins/table/utils.ts b/src/transform/plugins/table/utils.ts deleted file mode 100644 index 08ec3aee..00000000 --- a/src/transform/plugins/table/utils.ts +++ /dev/null @@ -1,107 +0,0 @@ -type DatasetKey = `data-${string}`; -type Attrs = 'class' | 'id' | DatasetKey; - -type Selector = (value: string) => { - key: Attrs; - value: string; -} | null; - -const wrapToData = (key: string): DatasetKey => { - if (key.startsWith('data-')) { - return key as DatasetKey; - } - - return `data-${key}`; -}; - -const selectors = { - class(value: string) { - if (value.startsWith('.')) { - return { - key: 'class', - value: value.slice(1), - }; - } - - return null; - }, - id(value: string) { - if (value.startsWith('#')) { - return { - key: 'id', - value: value.slice(1), - }; - } - - return null; - }, - attr(value: string) { - const parts = value.split('='); - - if (parts.length === 2) { - return { - key: wrapToData(parts[0]) as DatasetKey, - value: parts[1], - }; - } - - return { - key: wrapToData(value) as DatasetKey, - value: 'true', - }; - }, -}; - -const handlers = Object.values(selectors) as Selector[]; - -export function parseAttrs(inputString: string) { - const validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ .=-_#'; - - if (!inputString.startsWith('{')) { - return null; - } - - for (let i = 1; i < inputString.length; i++) { - const char = inputString[i]; - - if (char === '}') { - const contentInside = inputString.slice(1, i).trim(); // content excluding { and } - - if (!contentInside) { - return null; - } - - const parts = contentInside.split(' '); - - const attrs: Record = { - class: [], - id: [], - }; - - parts.forEach((part) => { - const matched = handlers.find((test) => test(part)); - - if (!matched) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {key, value} = matched(part)!; - - if (!attrs[key]) { - attrs[key] = []; - } - - attrs[key].push(value); - }); - - return attrs; - } - - if (!validChars.includes(char)) { - return null; - } - } - - return null; -} diff --git a/src/transform/sanitize.ts b/src/transform/sanitize.ts index e889f060..8a623ddf 100644 --- a/src/transform/sanitize.ts +++ b/src/transform/sanitize.ts @@ -288,6 +288,7 @@ const htmlAttrs = [ 'referrerpolicy', 'aria-describedby', 'data-*', + 'wide-content', ]; const svgAttrs = [ diff --git a/test/table/attrs.test.ts b/test/table/attrs.test.ts new file mode 100644 index 00000000..1d529778 --- /dev/null +++ b/test/table/attrs.test.ts @@ -0,0 +1,145 @@ +import {AttrsParser} from '../../src/transform/plugins/table/attrs'; + +describe('attrs parser tests', () => { + it('parses classes and ids', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{.test .name #id #help}'); + + expect(result).toEqual({ + class: ['test', 'name'], + id: ['id', 'help'], + }); + }); + + it('parses single key attrs', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{is-visible data-is-visible}'); + + expect(result).toEqual({ + attr: ['is-visible', 'data-is-visible'], + }); + }); + + it('parses full attrs', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{is-visible=true data-is-visible="true"}'); + + expect(result).toEqual({ + 'is-visible': ['true'], + 'data-is-visible': ['true'], + }); + }); + + it('with not closed attrs', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{is-visible="true data-is-visible="}'); + + expect(result).toEqual({ + 'is-visible': ['true data-is-visible='], + }); + }); + + it('with complex attrs', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse( + '{wide-content wide-name="very big name" with-extend="true" extendable=true name}', + ); + + expect(result).toEqual({ + attr: ['wide-content', 'name'], + 'wide-name': ['very big name'], + 'with-extend': ['true'], + extendable: ['true'], + }); + }); + + it('with all attrs', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse( + '{wide-content .name .id wide-name="very big name" with-extend="true" .hello-world extendable=true name #id}', + ); + + expect(result).toEqual({ + class: ['name', 'id', 'hello-world'], + id: ['id'], + attr: ['wide-content', 'name'], + 'wide-name': ['very big name'], + 'with-extend': ['true'], + extendable: ['true'], + }); + }); + + it('with curly attrs', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse( + '{wide-content .name .id wide-name="very {{big}} name" with-extend="true" .hello-world extendable=true name #id}', + ); + + expect(result).toEqual({ + class: ['name', 'id', 'hello-world'], + id: ['id'], + attr: ['wide-content', 'name'], + 'wide-name': ['very {{big}} name'], + 'with-extend': ['true'], + extendable: ['true'], + }); + }); + + it('should not touch includes', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{% include create-folder %}'); + + expect(result).toEqual({}); + }); + + it('should return id', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{invalid= #valid-id}'); + + expect(result).toEqual({ + id: ['valid-id'], + invalid: [' '], + }); + }); + + it('should return id with " and spaces', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{invalid=" " #valid-id}'); + + expect(result).toEqual({ + id: ['valid-id'], + invalid: [' '], + }); + }); + + it('should return class with dots', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{.name.lastname.test}'); + + expect(result).toEqual({ + class: ['name.lastname.test'], + }); + }); + + it('should return url', () => { + const attrs = new AttrsParser(); + + const result = attrs.parse('{url=/test/route data-url="/route/test"}'); + + expect(result).toEqual({ + url: ['/test/route'], + 'data-url': ['/route/test'], + }); + }); +}); diff --git a/test/table/table.test.ts b/test/table/table.test.ts index cb04ee8a..c09901b6 100644 --- a/test/table/table.test.ts +++ b/test/table/table.test.ts @@ -79,7 +79,7 @@ describe('Table plugin', () => { '\n', ); }); - it('should render simple table', () => { + it('should render simple table with attrs', () => { expect( transformYfm( '#|\n' + @@ -89,10 +89,10 @@ describe('Table plugin', () => { '|Cell in column 2, row 2||\n' + '||Cell in column 1, row 3\n' + '|Cell in column 2, row 3||\n' + - '|# {data-diplodoc-large-table=true .test .name #id wide-preview}', + '|# {.test .name #id data-diplodoc-large-table data-wide-preview="true"}', ), ).toBe( - '\n' + + '
\n' + '\n' + '\n' + '
\n' + diff --git a/test/table/utils.test.ts b/test/table/utils.test.ts deleted file mode 100644 index 64d777b4..00000000 --- a/test/table/utils.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {parseAttrs} from '../../src/transform/plugins/table/utils'; - -describe('parseAttrsClass', () => { - it('should correctly parse a class in markdown attrs format', () => { - expect(parseAttrs('{property=value .class}')).toEqual({ - 'data-property': ['value'], - class: ['class'], - id: [], - }); - }); - - it('should correctly parse a class when its the only property', () => { - expect(parseAttrs('{.class}')).toEqual({ - class: ['class'], - id: [], - }); - }); - - it('should require a whitespace if there are other properties', () => { - expect(parseAttrs('{property=value.class}')).toEqual({ - 'data-property': ['value.class'], - id: [], - class: [], - }); - }); - - it('should bail if there are unexpected symbols', () => { - expect(parseAttrs('{property="value" .class}')).toEqual(null); - }); - - it('should allow a dash in the class name', () => { - expect(parseAttrs('{.cell-align-center}')).toEqual({ - id: [], - class: ['cell-align-center'], - }); - }); - - it('should not touch includes', () => { - expect(parseAttrs('{% include create-folder %}')).toEqual( - null, - ); - }); -});