@yozora/tokenizer-link 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -271,19 +271,18 @@ const uniqueName = '@yozora/tokenizer-link';
271
271
 
272
272
  class LinkTokenizer extends coreTokenizer.BaseInlineTokenizer {
273
273
  constructor(props = {}) {
274
- var _a, _b;
275
274
  super({
276
- name: (_a = props.name) !== null && _a !== void 0 ? _a : uniqueName,
277
- priority: (_b = props.priority) !== null && _b !== void 0 ? _b : coreTokenizer.TokenizerPriority.LINKS,
275
+ name: props.name ?? uniqueName,
276
+ priority: props.priority ?? coreTokenizer.TokenizerPriority.LINKS,
278
277
  });
279
- this.match = match;
280
- this.parse = parse;
281
278
  }
279
+ match = match;
280
+ parse = parse;
282
281
  }
283
282
 
284
283
  exports.LinkTokenizer = LinkTokenizer;
285
284
  exports.LinkTokenizerName = uniqueName;
286
285
  exports.checkBalancedBracketsStatus = checkBalancedBracketsStatus;
287
- exports["default"] = LinkTokenizer;
286
+ exports.default = LinkTokenizer;
288
287
  exports.eatLinkDestination = eatLinkDestination;
289
288
  exports.eatLinkTitle = eatLinkTitle;
@@ -267,14 +267,13 @@ const uniqueName = '@yozora/tokenizer-link';
267
267
 
268
268
  class LinkTokenizer extends BaseInlineTokenizer {
269
269
  constructor(props = {}) {
270
- var _a, _b;
271
270
  super({
272
- name: (_a = props.name) !== null && _a !== void 0 ? _a : uniqueName,
273
- priority: (_b = props.priority) !== null && _b !== void 0 ? _b : TokenizerPriority.LINKS,
271
+ name: props.name ?? uniqueName,
272
+ priority: props.priority ?? TokenizerPriority.LINKS,
274
273
  });
275
- this.match = match;
276
- this.parse = parse;
277
274
  }
275
+ match = match;
276
+ parse = parse;
278
277
  }
279
278
 
280
279
  export { LinkTokenizer, uniqueName as LinkTokenizerName, checkBalancedBracketsStatus, LinkTokenizer as default, eatLinkDestination, eatLinkTitle };
@@ -42,8 +42,8 @@ declare function eatLinkDestination(nodePoints: ReadonlyArray<INodePoint>, start
42
42
  */
43
43
  declare function eatLinkTitle(nodePoints: ReadonlyArray<INodePoint>, startIndex: number, endIndex: number): number;
44
44
 
45
- declare type T = LinkType;
46
- declare type INode = Link;
45
+ type T = LinkType;
46
+ type INode = Link;
47
47
  declare const uniqueName = "@yozora/tokenizer-link";
48
48
  interface IToken extends IPartialYastInlineToken<T> {
49
49
  /**
@@ -69,8 +69,8 @@ interface IDelimiter extends IYastTokenDelimiter {
69
69
  */
70
70
  titleContent?: INodeInterval;
71
71
  }
72
- declare type IThis = ITokenizer;
73
- declare type ITokenizerProps = Partial<IBaseInlineTokenizerProps>;
72
+ type IThis = ITokenizer;
73
+ type ITokenizerProps = Partial<IBaseInlineTokenizerProps>;
74
74
 
75
75
  /**
76
76
  * Lexical Analyzer for InlineLink.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yozora/tokenizer-link",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "author": {
5
5
  "name": "guanghechen",
6
6
  "url": "https://github.com/guanghechen/"
@@ -11,33 +11,37 @@
11
11
  "directory": "tokenizers/link"
12
12
  },
13
13
  "homepage": "https://github.com/yozorajs/yozora/tree/release-2.x.x/tokenizers/link",
14
- "main": "lib/cjs/index.js",
15
- "module": "lib/esm/index.js",
16
- "types": "lib/types/index.d.ts",
17
- "source": "src/index.ts",
14
+ "type": "module",
15
+ "exports": {
16
+ "types": "./lib/types/index.d.ts",
17
+ "import": "./lib/esm/index.mjs",
18
+ "require": "./lib/cjs/index.cjs"
19
+ },
20
+ "source": "./src/index.ts",
21
+ "types": "./lib/types/index.d.ts",
22
+ "main": "./lib/cjs/index.cjs",
23
+ "module": "./lib/esm/index.mjs",
18
24
  "license": "MIT",
19
25
  "engines": {
20
26
  "node": ">= 16.0.0"
21
27
  },
22
28
  "files": [
23
29
  "lib/",
24
- "!lib/**/*.js.map",
25
- "!lib/**/*.d.ts.map",
30
+ "src/",
26
31
  "package.json",
27
32
  "CHANGELOG.md",
28
33
  "LICENSE",
29
34
  "README.md"
30
35
  ],
31
36
  "scripts": {
32
- "build": "cross-env NODE_ENV=production rollup -c ../../rollup.config.js",
33
- "prebuild": "rimraf lib/",
37
+ "build": "rimraf lib/ && cross-env NODE_ENV=production rollup -c ../../rollup.config.mjs",
34
38
  "prepublishOnly": "cross-env ROLLUP_SHOULD_SOURCEMAP=false yarn build",
35
- "test": "cross-env TS_NODE_FILES=true jest --config ../../jest.config.js --rootDir ."
39
+ "test": "cross-env TS_NODE_FILES=true NODE_OPTIONS=--experimental-vm-modules jest --config ../../jest.config.mjs --rootDir ."
36
40
  },
37
41
  "dependencies": {
38
- "@yozora/ast": "^2.0.4",
39
- "@yozora/character": "^2.0.4",
40
- "@yozora/core-tokenizer": "^2.0.4"
42
+ "@yozora/ast": "^2.0.5",
43
+ "@yozora/character": "^2.0.5",
44
+ "@yozora/core-tokenizer": "^2.0.5"
41
45
  },
42
- "gitHead": "c980b95254394dcacba0cbb4bea251350b09397c"
46
+ "gitHead": "7ba3bab49fe65cf2f57082c0503af73da9356cf0"
43
47
  }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './util/check-brackets'
2
+ export * from './util/link-destination'
3
+ export * from './util/link-title'
4
+ export { LinkTokenizer, LinkTokenizer as default } from './tokenizer'
5
+ export { uniqueName as LinkTokenizerName } from './types'
6
+ export type {
7
+ IThis as ILinkHookContext,
8
+ IToken as ILinkToken,
9
+ ITokenizerProps as ILinkTokenizerProps,
10
+ } from './types'
package/src/match.ts ADDED
@@ -0,0 +1,207 @@
1
+ import { LinkType } from '@yozora/ast'
2
+ import type { INodePoint } from '@yozora/character'
3
+ import { AsciiCodePoint } from '@yozora/character'
4
+ import type {
5
+ IMatchInlineHookCreator,
6
+ IResultOfIsDelimiterPair,
7
+ IResultOfProcessDelimiterPair,
8
+ IYastInlineToken,
9
+ } from '@yozora/core-tokenizer'
10
+ import { eatOptionalWhitespaces, genFindDelimiter, isLinkToken } from '@yozora/core-tokenizer'
11
+ import type { IDelimiter, IThis, IToken, T } from './types'
12
+ import { checkBalancedBracketsStatus } from './util/check-brackets'
13
+ import { eatLinkDestination } from './util/link-destination'
14
+ import { eatLinkTitle } from './util/link-title'
15
+
16
+ /**
17
+ * An inline link consists of a link text followed immediately by a left
18
+ * parenthesis '(', optional whitespace, an optional link destination, an
19
+ * optional link title separated from the link destination by whitespace,
20
+ * optional whitespace, and a right parenthesis ')'. The link’s text consists
21
+ * of the inlines contained in the link text (excluding the enclosing square
22
+ * brackets).
23
+ * The link’s URI consists of the link destination, excluding enclosing '<...>'
24
+ * if present, with backslash-escapes in effect as described above. The link’s
25
+ * title consists of the link title, excluding its enclosing delimiters, with
26
+ * backslash-escapes in effect as described above.
27
+ *
28
+ * ------
29
+ *
30
+ * A 'opener' type delimiter is one of the following forms:
31
+ *
32
+ * - '['
33
+ *
34
+ * A 'closer' type delimiter is one of the following forms:
35
+ *
36
+ * - '](url)'
37
+ * - '](url "title")'
38
+ * - '](<url>)'
39
+ * - '](<url> "title")'
40
+ *
41
+ * @see https://github.com/syntax-tree/mdast#link
42
+ * @see https://github.github.com/gfm/#links
43
+ */
44
+ export const match: IMatchInlineHookCreator<T, IDelimiter, IToken, IThis> = function (api) {
45
+ return {
46
+ findDelimiter: () => genFindDelimiter<IDelimiter>(_findDelimiter),
47
+ isDelimiterPair,
48
+ processDelimiterPair,
49
+ }
50
+
51
+ /**
52
+ * An inline link consists of a link text followed immediately by a left
53
+ * parenthesis '(', optional whitespace, an optional link destination, an
54
+ * optional link title separated from the link destination by whitespace,
55
+ * optional whitespace, and a right parenthesis ')'
56
+ * @see https://github.github.com/gfm/#inline-link
57
+ */
58
+ function _findDelimiter(startIndex: number, endIndex: number): IDelimiter | null {
59
+ const nodePoints: ReadonlyArray<INodePoint> = api.getNodePoints()
60
+ const blockEndIndex = api.getBlockEndIndex()
61
+
62
+ /**
63
+ * FIXME:
64
+ *
65
+ * This is a hack method to fix the situation where a higher priority token
66
+ * is embedded in the delimiter, at this time, ignore the tokens that have
67
+ * been parsed, and continue to match the content until the delimiter meets
68
+ * its own definition or reaches the right boundary of the block content.
69
+ *
70
+ * This algorithm has not been strictly logically verified, but I think it
71
+ * can work well in most cases. After all, it has passed many test cases.
72
+ * @see https://github.github.com/gfm/#example-588
73
+ */
74
+ for (let i = startIndex; i < endIndex; ++i) {
75
+ const p = nodePoints[i]
76
+ switch (p.codePoint) {
77
+ case AsciiCodePoint.BACKSLASH:
78
+ i += 1
79
+ break
80
+ /**
81
+ * A link text consists of a sequence of zero or more inline elements
82
+ * enclosed by square brackets ([ and ])
83
+ * @see https://github.github.com/gfm/#link-text
84
+ */
85
+ case AsciiCodePoint.OPEN_BRACKET: {
86
+ const delimiter: IDelimiter = {
87
+ type: 'opener',
88
+ startIndex: i,
89
+ endIndex: i + 1,
90
+ }
91
+ return delimiter
92
+ }
93
+ /**
94
+ * An inline link consists of a link text followed immediately by a
95
+ * left parenthesis '(', ..., and a right parenthesis ')'
96
+ * @see https://github.github.com/gfm/#inline-link
97
+ */
98
+ case AsciiCodePoint.CLOSE_BRACKET: {
99
+ if (i + 1 >= endIndex || nodePoints[i + 1].codePoint !== AsciiCodePoint.OPEN_PARENTHESIS)
100
+ break
101
+
102
+ // try to match link destination
103
+ const destinationStartIndex = eatOptionalWhitespaces(nodePoints, i + 2, blockEndIndex)
104
+ const destinationEndIndex = eatLinkDestination(
105
+ nodePoints,
106
+ destinationStartIndex,
107
+ blockEndIndex,
108
+ )
109
+ if (destinationEndIndex < 0) break // no valid destination matched
110
+
111
+ // try to match link title
112
+ const titleStartIndex = eatOptionalWhitespaces(
113
+ nodePoints,
114
+ destinationEndIndex,
115
+ blockEndIndex,
116
+ )
117
+ const titleEndIndex = eatLinkTitle(nodePoints, titleStartIndex, blockEndIndex)
118
+ if (titleEndIndex < 0) break
119
+
120
+ const _startIndex = i
121
+ const _endIndex = eatOptionalWhitespaces(nodePoints, titleEndIndex, blockEndIndex) + 1
122
+ if (
123
+ _endIndex > blockEndIndex ||
124
+ nodePoints[_endIndex - 1].codePoint !== AsciiCodePoint.CLOSE_PARENTHESIS
125
+ )
126
+ break
127
+
128
+ /**
129
+ * Both the title and the destination may be omitted
130
+ * @see https://github.github.com/gfm/#example-495
131
+ */
132
+ return {
133
+ type: 'closer',
134
+ startIndex: _startIndex,
135
+ endIndex: _endIndex,
136
+ destinationContent:
137
+ destinationStartIndex < destinationEndIndex
138
+ ? {
139
+ startIndex: destinationStartIndex,
140
+ endIndex: destinationEndIndex,
141
+ }
142
+ : undefined,
143
+ titleContent:
144
+ titleStartIndex < titleEndIndex
145
+ ? { startIndex: titleStartIndex, endIndex: titleEndIndex }
146
+ : undefined,
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return null
152
+ }
153
+
154
+ function isDelimiterPair(
155
+ openerDelimiter: IDelimiter,
156
+ closerDelimiter: IDelimiter,
157
+ internalTokens: ReadonlyArray<IYastInlineToken>,
158
+ ): IResultOfIsDelimiterPair {
159
+ const nodePoints: ReadonlyArray<INodePoint> = api.getNodePoints()
160
+
161
+ /**
162
+ * Links may not contain other links, at any level of nesting.
163
+ * @see https://github.github.com/gfm/#example-540
164
+ * @see https://github.github.com/gfm/#example-541
165
+ */
166
+ const hasInternalLinkToken: boolean = internalTokens.find(isLinkToken) != null
167
+ if (hasInternalLinkToken) {
168
+ return { paired: false, opener: false, closer: false }
169
+ }
170
+
171
+ const balancedBracketsStatus: -1 | 0 | 1 = checkBalancedBracketsStatus(
172
+ openerDelimiter.endIndex,
173
+ closerDelimiter.startIndex,
174
+ internalTokens,
175
+ nodePoints,
176
+ )
177
+ switch (balancedBracketsStatus) {
178
+ case -1:
179
+ return { paired: false, opener: false, closer: true }
180
+ case 0:
181
+ return { paired: true }
182
+ case 1:
183
+ return { paired: false, opener: true, closer: false }
184
+ }
185
+ }
186
+
187
+ function processDelimiterPair(
188
+ openerDelimiter: IDelimiter,
189
+ closerDelimiter: IDelimiter,
190
+ internalTokens: ReadonlyArray<IYastInlineToken>,
191
+ ): IResultOfProcessDelimiterPair<T, IToken, IDelimiter> {
192
+ const children: ReadonlyArray<IYastInlineToken> = api.resolveInternalTokens(
193
+ internalTokens,
194
+ openerDelimiter.endIndex,
195
+ closerDelimiter.startIndex,
196
+ )
197
+ const token: IToken = {
198
+ nodeType: LinkType,
199
+ startIndex: openerDelimiter.startIndex,
200
+ endIndex: closerDelimiter.endIndex,
201
+ destinationContent: closerDelimiter.destinationContent,
202
+ titleContent: closerDelimiter.titleContent,
203
+ children,
204
+ }
205
+ return { tokens: [token] }
206
+ }
207
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { Node } from '@yozora/ast'
2
+ import { LinkType } from '@yozora/ast'
3
+ import type { INodePoint } from '@yozora/character'
4
+ import { AsciiCodePoint, calcEscapedStringFromNodePoints } from '@yozora/character'
5
+ import type { IParseInlineHookCreator } from '@yozora/core-tokenizer'
6
+ import { encodeLinkDestination } from '@yozora/core-tokenizer'
7
+ import type { INode, IThis, IToken, T } from './types'
8
+
9
+ export const parse: IParseInlineHookCreator<T, IToken, INode, IThis> = function (api) {
10
+ return {
11
+ parse: tokens =>
12
+ tokens.map(token => {
13
+ const nodePoints: ReadonlyArray<INodePoint> = api.getNodePoints()
14
+
15
+ // calc url
16
+ let url = ''
17
+ if (token.destinationContent != null) {
18
+ let { startIndex, endIndex } = token.destinationContent
19
+ if (nodePoints[startIndex].codePoint === AsciiCodePoint.OPEN_ANGLE) {
20
+ startIndex += 1
21
+ endIndex -= 1
22
+ }
23
+ const destination = calcEscapedStringFromNodePoints(
24
+ nodePoints,
25
+ startIndex,
26
+ endIndex,
27
+ true,
28
+ )
29
+ url = encodeLinkDestination(destination)
30
+ }
31
+
32
+ // calc title
33
+ let title: string | undefined
34
+ if (token.titleContent != null) {
35
+ const { startIndex, endIndex } = token.titleContent
36
+ title = calcEscapedStringFromNodePoints(nodePoints, startIndex + 1, endIndex - 1)
37
+ }
38
+
39
+ const children: Node[] = api.parseInlineTokens(token.children)
40
+ const node: INode = api.shouldReservePosition
41
+ ? { type: LinkType, position: api.calcPosition(token), url, title, children }
42
+ : { type: LinkType, url, title, children }
43
+ return node
44
+ }),
45
+ }
46
+ }
@@ -0,0 +1,32 @@
1
+ import type {
2
+ IInlineTokenizer,
3
+ IMatchInlineHookCreator,
4
+ IParseInlineHookCreator,
5
+ } from '@yozora/core-tokenizer'
6
+ import { BaseInlineTokenizer, TokenizerPriority } from '@yozora/core-tokenizer'
7
+ import { match } from './match'
8
+ import { parse } from './parse'
9
+ import type { IDelimiter, INode, IThis, IToken, ITokenizerProps, T } from './types'
10
+ import { uniqueName } from './types'
11
+
12
+ /**
13
+ * Lexical Analyzer for InlineLink.
14
+ * @see https://github.com/syntax-tree/mdast#link
15
+ * @see https://github.github.com/gfm/#links
16
+ */
17
+ export class LinkTokenizer
18
+ extends BaseInlineTokenizer<T, IDelimiter, IToken, INode, IThis>
19
+ implements IInlineTokenizer<T, IDelimiter, IToken, INode, IThis>
20
+ {
21
+ /* istanbul ignore next */
22
+ constructor(props: ITokenizerProps = {}) {
23
+ super({
24
+ name: props.name ?? uniqueName,
25
+ priority: props.priority ?? TokenizerPriority.LINKS,
26
+ })
27
+ }
28
+
29
+ public override readonly match: IMatchInlineHookCreator<T, IDelimiter, IToken, IThis> = match
30
+
31
+ public override readonly parse: IParseInlineHookCreator<T, IToken, INode, IThis> = parse
32
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { Link, LinkType } from '@yozora/ast'
2
+ import type { INodeInterval } from '@yozora/character'
3
+ import type {
4
+ IBaseInlineTokenizerProps,
5
+ IPartialYastInlineToken,
6
+ ITokenizer,
7
+ IYastTokenDelimiter,
8
+ } from '@yozora/core-tokenizer'
9
+
10
+ export type T = LinkType
11
+ export type INode = Link
12
+ export const uniqueName = '@yozora/tokenizer-link'
13
+
14
+ export interface IToken extends IPartialYastInlineToken<T> {
15
+ /**
16
+ * Link destination interval.
17
+ */
18
+ destinationContent?: INodeInterval
19
+ /**
20
+ * Link title interval.
21
+ */
22
+ titleContent?: INodeInterval
23
+ }
24
+
25
+ export interface IDelimiter extends IYastTokenDelimiter {
26
+ /**
27
+ * IDelimiter type.
28
+ */
29
+ type: 'opener' | 'closer'
30
+ /**
31
+ * Link destination interval.
32
+ */
33
+ destinationContent?: INodeInterval
34
+ /**
35
+ * Link title interval.
36
+ */
37
+ titleContent?: INodeInterval
38
+ }
39
+
40
+ export type IThis = ITokenizer
41
+
42
+ export type ITokenizerProps = Partial<IBaseInlineTokenizerProps>
@@ -0,0 +1,54 @@
1
+ import type { INodePoint } from '@yozora/character'
2
+ import { AsciiCodePoint } from '@yozora/character'
3
+ import type { IYastInlineToken } from '@yozora/core-tokenizer'
4
+
5
+ /**
6
+ * The link text may contain balanced brackets, but not unbalanced ones,
7
+ * unless they are escaped
8
+ *
9
+ * @see https://github.github.com/gfm/#example-520
10
+ * @see https://github.github.com/gfm/#example-521
11
+ * @see https://github.github.com/gfm/#example-522
12
+ * @see https://github.github.com/gfm/#example-523
13
+ */
14
+ export const checkBalancedBracketsStatus = (
15
+ startIndex: number,
16
+ endIndex: number,
17
+ internalTokens: ReadonlyArray<IYastInlineToken>,
18
+ nodePoints: ReadonlyArray<INodePoint>,
19
+ ): -1 | 0 | 1 => {
20
+ let i = startIndex
21
+ let bracketCount = 0
22
+
23
+ // update bracketCount
24
+ const updateBracketCount = (): void => {
25
+ const c = nodePoints[i].codePoint
26
+ switch (c) {
27
+ case AsciiCodePoint.BACKSLASH:
28
+ i += 1
29
+ break
30
+ case AsciiCodePoint.OPEN_BRACKET:
31
+ bracketCount += 1
32
+ break
33
+ case AsciiCodePoint.CLOSE_BRACKET:
34
+ bracketCount -= 1
35
+ break
36
+ }
37
+ }
38
+
39
+ for (const token of internalTokens) {
40
+ if (token.startIndex < startIndex) continue
41
+ if (token.endIndex > endIndex) break
42
+ for (; i < token.startIndex; ++i) {
43
+ updateBracketCount()
44
+ if (bracketCount < 0) return -1
45
+ }
46
+ i = token.endIndex
47
+ }
48
+
49
+ for (; i < endIndex; ++i) {
50
+ updateBracketCount()
51
+ if (bracketCount < 0) return -1
52
+ }
53
+ return bracketCount > 0 ? 1 : 0
54
+ }
@@ -0,0 +1,85 @@
1
+ import type { INodePoint } from '@yozora/character'
2
+ import {
3
+ AsciiCodePoint,
4
+ VirtualCodePoint,
5
+ isAsciiControlCharacter,
6
+ isWhitespaceCharacter,
7
+ } from '@yozora/character'
8
+
9
+ /**
10
+ * A link destination consists of either
11
+ * - a sequence of zero or more characters between an opening '<' and a closing '>'
12
+ * that contains no line breaks or unescaped '<' or '>' characters, or
13
+ * - a nonempty sequence of characters that does not start with '<', does not include
14
+ * ASCII space or control characters, and includes parentheses only if
15
+ * (a) they are backslash-escaped or
16
+ * (b) they are part of a balanced pair of unescaped parentheses. (Implementations
17
+ * may impose limits on parentheses nesting to avoid performance issues, but
18
+ * at least three levels of nesting should be supported.)
19
+ * @see https://github.github.com/gfm/#link-destination
20
+ * @return position at next iteration
21
+ */
22
+ export function eatLinkDestination(
23
+ nodePoints: ReadonlyArray<INodePoint>,
24
+ startIndex: number,
25
+ endIndex: number,
26
+ ): number {
27
+ if (startIndex >= endIndex) return -1
28
+
29
+ let i = startIndex
30
+ switch (nodePoints[i].codePoint) {
31
+ /**
32
+ * In pointy brackets:
33
+ * - A sequence of zero or more characters between an opening '<' and
34
+ * a closing '>' that contains no line breaks or unescaped '<' or '>' characters
35
+ */
36
+ case AsciiCodePoint.OPEN_ANGLE: {
37
+ for (i += 1; i < endIndex; ++i) {
38
+ const p = nodePoints[i]
39
+ switch (p.codePoint) {
40
+ case AsciiCodePoint.BACKSLASH:
41
+ i += 1
42
+ break
43
+ case AsciiCodePoint.OPEN_ANGLE:
44
+ case VirtualCodePoint.LINE_END:
45
+ return -1
46
+ case AsciiCodePoint.CLOSE_ANGLE:
47
+ return i + 1
48
+ }
49
+ }
50
+ return -1
51
+ }
52
+ /**
53
+ * Not in pointy brackets:
54
+ * - A nonempty sequence of characters that does not start with '<', does not include
55
+ * ASCII space or control characters, and includes parentheses only if
56
+ *
57
+ * a) they are backslash-escaped or
58
+ * b) they are part of a balanced pair of unescaped parentheses. (Implementations
59
+ * may impose limits on parentheses nesting to avoid performance issues,
60
+ * but at least three levels of nesting should be supported.)
61
+ */
62
+ default: {
63
+ let openParensCount = 0
64
+ for (; i < endIndex; ++i) {
65
+ const c = nodePoints[i].codePoint
66
+ switch (c) {
67
+ case AsciiCodePoint.BACKSLASH:
68
+ i += 1
69
+ break
70
+ case AsciiCodePoint.OPEN_PARENTHESIS:
71
+ openParensCount += 1
72
+ break
73
+ case AsciiCodePoint.CLOSE_PARENTHESIS:
74
+ openParensCount -= 1
75
+ if (openParensCount < 0) return i
76
+ break
77
+ default:
78
+ if (isWhitespaceCharacter(c) || isAsciiControlCharacter(c)) return i
79
+ break
80
+ }
81
+ }
82
+ return openParensCount === 0 ? i : -1
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,82 @@
1
+ import type { INodePoint } from '@yozora/character'
2
+ import { AsciiCodePoint, VirtualCodePoint } from '@yozora/character'
3
+ import { eatOptionalBlankLines } from '@yozora/core-tokenizer'
4
+
5
+ /**
6
+ * A link title consists of either
7
+ *
8
+ * - a sequence of zero or more characters between straight double-quote
9
+ * characters '"', including a '"' character only if it is backslash-escaped, or
10
+ *
11
+ * - a sequence of zero or more characters between straight single-quote
12
+ * characters '\'', including a '\'' character only if it is backslash-escaped, or
13
+ *
14
+ * - a sequence of zero or more characters between matching parentheses '(...)',
15
+ * including a '(' or ')' character only if it is backslash-escaped.
16
+ */
17
+ export function eatLinkTitle(
18
+ nodePoints: ReadonlyArray<INodePoint>,
19
+ startIndex: number,
20
+ endIndex: number,
21
+ ): number {
22
+ if (startIndex >= endIndex) return -1
23
+
24
+ let i = startIndex
25
+ const titleWrapSymbol = nodePoints[i].codePoint
26
+ switch (titleWrapSymbol) {
27
+ case AsciiCodePoint.DOUBLE_QUOTE:
28
+ case AsciiCodePoint.SINGLE_QUOTE: {
29
+ for (i += 1; i < endIndex; ++i) {
30
+ const p = nodePoints[i]
31
+ switch (p.codePoint) {
32
+ case AsciiCodePoint.BACKSLASH:
33
+ i += 1
34
+ break
35
+ case titleWrapSymbol:
36
+ return i + 1
37
+ /**
38
+ * Although link titles may span multiple lines, they may not contain a blank line.
39
+ */
40
+ case VirtualCodePoint.LINE_END: {
41
+ const j = eatOptionalBlankLines(nodePoints, startIndex, i)
42
+ if (nodePoints[j].line > p.line + 1) return -1
43
+ break
44
+ }
45
+ }
46
+ }
47
+ break
48
+ }
49
+ case AsciiCodePoint.OPEN_PARENTHESIS: {
50
+ let openParens = 1
51
+ for (i += 1; i < endIndex; ++i) {
52
+ const p = nodePoints[i]
53
+ switch (p.codePoint) {
54
+ case AsciiCodePoint.BACKSLASH:
55
+ i += 1
56
+ break
57
+ /**
58
+ * Although link titles may span multiple lines, they may not contain a blank line.
59
+ */
60
+ case VirtualCodePoint.LINE_END: {
61
+ const j = eatOptionalBlankLines(nodePoints, startIndex, i)
62
+ if (nodePoints[j].line > p.line + 1) return -1
63
+ break
64
+ }
65
+ case AsciiCodePoint.OPEN_PARENTHESIS:
66
+ openParens += 1
67
+ break
68
+ case AsciiCodePoint.CLOSE_PARENTHESIS:
69
+ openParens -= 1
70
+ if (openParens === 0) return i + 1
71
+ break
72
+ }
73
+ }
74
+ break
75
+ }
76
+ case AsciiCodePoint.CLOSE_PARENTHESIS:
77
+ return i
78
+ default:
79
+ return -1
80
+ }
81
+ return -1
82
+ }