first commit
This commit is contained in:
115
src/services/markdownTable.test.ts
Normal file
115
src/services/markdownTable.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractMarkdownTable, splitMarkdownRow } from './markdownTable'
|
||||
|
||||
describe('splitMarkdownRow', () => {
|
||||
it('keeps pipes inside code spans and escaped pipes inside cells', () => {
|
||||
expect(splitMarkdownRow('| A | `x | y` | left \\| right |')).toEqual([
|
||||
'A',
|
||||
'`x | y`',
|
||||
'left \\| right',
|
||||
])
|
||||
})
|
||||
|
||||
it('matches arbitrary backtick fence lengths and ignores escaped backticks', () => {
|
||||
expect(splitMarkdownRow('| ``x | y`` | escaped \\` tick |')).toEqual([
|
||||
'``x | y``',
|
||||
'escaped \\` tick',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats an unmatched backtick as literal text', () => {
|
||||
expect(splitMarkdownRow('| A | unmatched `code | B |')).toEqual([
|
||||
'A',
|
||||
'unmatched `code',
|
||||
'B',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMarkdownTable', () => {
|
||||
it('finds a table and gathers its contiguous body rows', () => {
|
||||
const lines = [
|
||||
'intro',
|
||||
'| A | B |',
|
||||
'| --- | :---: |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
]
|
||||
|
||||
expect(extractMarkdownTable(lines)).toEqual({
|
||||
start: 1,
|
||||
end: 3,
|
||||
header: ['A', 'B'],
|
||||
rows: [['1', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['backtick', ['```markdown', '| Fake | Table |', '| --- | --- |', '```']],
|
||||
['tilde', ['~~~~', '| Fake | Table |', '| --- | --- |', '~~~~']],
|
||||
])('skips pseudo-tables inside %s fenced code', (_marker, fencedLines) => {
|
||||
const lines = [
|
||||
...fencedLines,
|
||||
'',
|
||||
'| Real | Table |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
]
|
||||
const realTableStart = fencedLines.length + 1
|
||||
|
||||
expect(extractMarkdownTable(lines)).toEqual({
|
||||
start: realTableStart,
|
||||
end: realTableStart + 2,
|
||||
header: ['Real', 'Table'],
|
||||
rows: [['1', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['four spaces', ' '],
|
||||
['a tab', '\t'],
|
||||
])('skips pseudo-tables indented with %s', (_indentation, indent) => {
|
||||
const lines = [
|
||||
`${indent}| Fake | Table |`,
|
||||
`${indent}| --- | --- |`,
|
||||
`${indent}| 0 | 0 |`,
|
||||
'',
|
||||
'| Real | Table |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
]
|
||||
|
||||
expect(extractMarkdownTable(lines)).toEqual({
|
||||
start: 4,
|
||||
end: 6,
|
||||
header: ['Real', 'Table'],
|
||||
rows: [['1', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it('starts its table search at fromIndex', () => {
|
||||
const lines = [
|
||||
'| First | Table |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 1 |',
|
||||
'',
|
||||
'| Second | Table |',
|
||||
'| --- | --- |',
|
||||
'| 2 | 2 |',
|
||||
]
|
||||
|
||||
expect(extractMarkdownTable(lines, 4)).toEqual({
|
||||
start: 4,
|
||||
end: 6,
|
||||
header: ['Second', 'Table'],
|
||||
rows: [['2', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no valid table exists', () => {
|
||||
expect(extractMarkdownTable(['plain text'])).toBeNull()
|
||||
expect(
|
||||
extractMarkdownTable(['| A | B |', '| -- | not-a-divider |']),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
147
src/services/markdownTable.ts
Normal file
147
src/services/markdownTable.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
function isEscaped(value: string, index: number): boolean {
|
||||
let backslashCount = 0
|
||||
|
||||
for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor--) {
|
||||
backslashCount++
|
||||
}
|
||||
|
||||
return backslashCount % 2 === 1
|
||||
}
|
||||
|
||||
function hasMatchingCodeFence(
|
||||
value: string,
|
||||
start: number,
|
||||
fenceLength: number,
|
||||
): boolean {
|
||||
for (let index = start + fenceLength; index < value.length; ) {
|
||||
if (value[index] !== '`') {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
let runLength = 1
|
||||
|
||||
while (value[index + runLength] === '`') {
|
||||
runLength++
|
||||
}
|
||||
|
||||
if (!isEscaped(value, index) && runLength === fenceLength) {
|
||||
return true
|
||||
}
|
||||
|
||||
index += runLength
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export interface MarkdownTable {
|
||||
start: number
|
||||
end: number
|
||||
header: string[]
|
||||
rows: string[][]
|
||||
}
|
||||
|
||||
export function splitMarkdownRow(row: string): string[] {
|
||||
const cells: string[] = []
|
||||
let cell = ''
|
||||
let codeFenceLength = 0
|
||||
|
||||
for (let index = 0; index < row.length; ) {
|
||||
const character = row[index]!
|
||||
|
||||
if (character === '`' && !isEscaped(row, index)) {
|
||||
let runLength = 1
|
||||
|
||||
while (row[index + runLength] === '`') {
|
||||
runLength++
|
||||
}
|
||||
|
||||
cell += row.slice(index, index + runLength)
|
||||
|
||||
if (
|
||||
codeFenceLength === 0 &&
|
||||
hasMatchingCodeFence(row, index, runLength)
|
||||
) {
|
||||
codeFenceLength = runLength
|
||||
} else if (codeFenceLength === runLength) {
|
||||
codeFenceLength = 0
|
||||
}
|
||||
|
||||
index += runLength
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
character === '|' &&
|
||||
codeFenceLength === 0 &&
|
||||
!isEscaped(row, index)
|
||||
) {
|
||||
cells.push(cell.trim())
|
||||
cell = ''
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
cell += character
|
||||
index++
|
||||
}
|
||||
|
||||
cells.push(cell.trim())
|
||||
|
||||
if (cells[0] === '') {
|
||||
cells.shift()
|
||||
}
|
||||
if (cells.at(-1) === '') {
|
||||
cells.pop()
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
const dividerCellPattern = /^:?-{3,}:?$/
|
||||
|
||||
function startsWithPipe(line: string): boolean {
|
||||
return line.trimStart().startsWith('|')
|
||||
}
|
||||
|
||||
export function extractMarkdownTable(
|
||||
lines: readonly string[],
|
||||
fromIndex = 0,
|
||||
): MarkdownTable | null {
|
||||
for (
|
||||
let start = Math.max(0, fromIndex);
|
||||
start < lines.length - 1;
|
||||
start++
|
||||
) {
|
||||
const headerLine = lines[start]!
|
||||
const dividerLine = lines[start + 1]!
|
||||
|
||||
if (!startsWithPipe(headerLine) || !startsWithPipe(dividerLine)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const header = splitMarkdownRow(headerLine)
|
||||
const divider = splitMarkdownRow(dividerLine)
|
||||
|
||||
if (
|
||||
header.length === 0 ||
|
||||
divider.length !== header.length ||
|
||||
!divider.every((cell) => dividerCellPattern.test(cell))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rows: string[][] = []
|
||||
let end = start + 1
|
||||
|
||||
while (end + 1 < lines.length && startsWithPipe(lines[end + 1]!)) {
|
||||
end++
|
||||
rows.push(splitMarkdownRow(lines[end]!))
|
||||
}
|
||||
|
||||
return { start, end, header, rows }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
15
src/services/naturalSort.test.ts
Normal file
15
src/services/naturalSort.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortFilesNaturally } from './naturalSort'
|
||||
|
||||
describe('sortFilesNaturally', () => {
|
||||
it('sorts numbered filenames naturally without mutating the input', () => {
|
||||
const files = [{ name: '10.md' }, { name: '2.md' }, { name: '1.md' }]
|
||||
const original = [...files]
|
||||
|
||||
const sorted = sortFilesNaturally(files)
|
||||
|
||||
expect(sorted.map(({ name }) => name)).toEqual(['1.md', '2.md', '10.md'])
|
||||
expect(files).toEqual(original)
|
||||
expect(sorted).not.toBe(files)
|
||||
})
|
||||
})
|
||||
12
src/services/naturalSort.ts
Normal file
12
src/services/naturalSort.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const filenameCollator = new Intl.Collator('zh-CN', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
|
||||
export function sortFilesNaturally<T extends { name: string }>(
|
||||
files: readonly T[],
|
||||
): T[] {
|
||||
return [...files].sort((left, right) =>
|
||||
filenameCollator.compare(left.name, right.name),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user