Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
# Only trigger when relevant code changes to save resources
paths:
- '.github/workflows/ci.yml'
- 'packages/linkify/**'
- 'packages/markdown-parser/**'
- 'packages/markdown-parser-lit/**'
- 'packages/markdown-parser-vue/**'
Expand All @@ -18,6 +19,7 @@ on:
pull_request:
paths:
- '.github/workflows/ci.yml'
- 'packages/linkify/**'
- 'packages/markdown-parser/**'
- 'packages/markdown-parser-lit/**'
- 'packages/markdown-parser-vue/**'
Expand Down Expand Up @@ -53,6 +55,8 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Build linkify package
run: pnpm --filter @fuyeor/linkify build
- name: Typecheck parser package
run: pnpm --filter @fuyeor/markdown-parser typecheck
- name: Run parser unit tests
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ WORKDIR /app
# prepare depends
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# copy shared packages
COPY packages/linkify/package.json packages/linkify/
COPY packages/markdown-parser/package.json packages/markdown-parser/
COPY packages/markdown-parser-lit/package.json packages/markdown-parser-lit/
COPY packages/playground/package.json packages/playground/
Expand All @@ -23,6 +24,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# copy source code
COPY . .

# build linkify before parser consumers resolve its package exports
RUN pnpm --filter @fuyeor/linkify build
# build parser package before workspace consumers resolve its package exports
RUN pnpm --filter @fuyeor/markdown-parser build

Expand Down
3 changes: 3 additions & 0 deletions packages/markdown-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@fuyeor/linkify": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^6.0.3",
Expand Down
51 changes: 18 additions & 33 deletions packages/markdown-parser/src/core/parser.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
// @fuyeor/markdown-parser/src/core/parser.ts
import { BlockState, InlineState } from './state';
import { linkify } from '@fuyeor/linkify';
import type {
ASTNode,
BlockRule,
InlineRule,
Linkifier,
MarkdownPlugin,
MarkdownParserOptions,
ParserContext,
} from '#/types';
import { isSafeLinkUrl } from '#/core/url';

const LINKIFY_REGEX =
/(https?:\/\/[^\s]+|(?<![@\w])(?:[a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}(?:\/[^\s]*)?)/gv;
const LINKIFY_CANDIDATE_REGEX = /(?:https?:\/\/|[A-Za-z0-9-]+\.)/u;

/** Skip linkify scans for text without a possible URL candidate. */
function linkifyWithCandidateCheck(text: string): ReturnType<typeof linkify> {
if (!LINKIFY_CANDIDATE_REGEX.test(text)) return [];
return linkify(text);
}

export class MarkdownParser {
#blockRuleMap = new Map<string, BlockRule[]>();
#inlineRuleMap = new Map<string, InlineRule[]>();
#idSequence = 0;
#isPreflight = false;
readonly #maxNestingDepth: number;
readonly #linkifier: Linkifier;

constructor(options: MarkdownParserOptions = {}) {
const maxNestingDepth = options.maxNestingDepth ?? 64;
if (!Number.isInteger(maxNestingDepth) || maxNestingDepth < 1)
throw new RangeError('maxNestingDepth must be a positive integer');

this.#maxNestingDepth = maxNestingDepth;
this.#linkifier = options.linkifier ?? linkifyWithCandidateCheck;
}

// construct a context for recursive rule calls
Expand Down Expand Up @@ -227,44 +236,20 @@ export class MarkdownParser {
const textBuffer = state.content.slice(textStart, endPos);
let lastIdx = 0;

for (const match of textBuffer.matchAll(LINKIFY_REGEX)) {
let urlStr = match[0];
let matchIdx = match.index!;

// remove the punctuation marks at the end.
const punctuation = '.,:;?!';
while (
urlStr.length > 0 &&
punctuation.includes(urlStr[urlStr.length - 1])
) {
urlStr = urlStr.slice(0, -1);
}

// handle bracket matching
// If a link ends with `)` and the inner brackets are unbalanced, then strip `)`
if (urlStr.endsWith(')')) {
const openCount = (urlStr.match(/\(/g) || []).length;
const closeCount = (urlStr.match(/\)/g) || []).length;
if (closeCount > openCount) urlStr = urlStr.slice(0, -1);
}

const fullUrl = urlStr.startsWith('http')
? urlStr
: `https://${urlStr}`;

if (isSafeLinkUrl(fullUrl)) {
if (matchIdx > lastIdx) {
for (const match of this.#linkifier(textBuffer)) {
if (isSafeLinkUrl(match.url)) {
if (match.index > lastIdx) {
nodes.push({
type: 'text',
content: textBuffer.slice(lastIdx, matchIdx),
content: textBuffer.slice(lastIdx, match.index),
});
}
nodes.push({
type: 'link',
url: fullUrl,
children: [{ type: 'text', content: urlStr }],
url: match.url,
children: [{ type: 'text', content: match.text }],
});
lastIdx = matchIdx + urlStr.length;
lastIdx = match.lastIndex;
}
}

Expand Down
10 changes: 10 additions & 0 deletions packages/markdown-parser/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ describe('test @fuyeor/markdown-parser', () => {
expect(children[3].url).toBe('https://fuyeor.com');
});

it('uses linkify for internationalized fuzzy domains', () => {
const ast = createFuyeorMarkdownParser()('Visit fuyeor.xn--p1ai');
const link = ast[0].children?.find((node) => node.type === 'link');

expect(link).toMatchObject({
url: 'https://fuyeor.рф',
children: [{ type: 'text', content: 'fuyeor.рф' }],
});
});

it('parse table', () => {
const content = `
| title 1 | title 2 |
Expand Down
1 change: 1 addition & 0 deletions packages/markdown-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type {
ParserContext,
BlockRule,
InlineRule,
Linkifier,
MarkdownParserOptions,
MarkdownPlugin,
} from './types';
4 changes: 4 additions & 0 deletions packages/markdown-parser/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @fuyeor/markdown-parser/src/types.ts

import type { LinkMatch } from '@fuyeor/linkify';
import type { MarkdownParser } from './core/parser';
import type { BlockState, InlineState } from './core/state';

Expand Down Expand Up @@ -73,8 +74,11 @@ export interface InlineRule {
) => { node: ASTNode; consumedChars: number } | null;
}

export type Linkifier = (text: string) => readonly LinkMatch[];

export interface MarkdownParserOptions {
maxNestingDepth?: number;
linkifier?: Linkifier;
}

export type MarkdownPlugin = (parser: MarkdownParser) => void;
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading