diff --git a/.gitignore b/.gitignore index 0fcfe27..ab234eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ **/node_modules **/dist +**/out **/tests.failed.json diff --git a/package.json b/package.json index 74cf8fd..c188742 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "fuyeor-markdown-parser", "license": "MIT", "author": "Fuyeor ", - "packageManager": "pnpm@11.20.0", + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621", "scripts": { "format": "prettier --write \"**/*.ts\"", "test": "pnpm -F @fuyeor/markdown-parser test", diff --git a/packages/extensions/vscode/.gitignore b/packages/extensions/vscode/.gitignore new file mode 100644 index 0000000..dfacd4d --- /dev/null +++ b/packages/extensions/vscode/.gitignore @@ -0,0 +1 @@ +*.vsix \ No newline at end of file diff --git a/packages/extensions/vscode/.vscode/launch.json b/packages/extensions/vscode/.vscode/launch.json new file mode 100644 index 0000000..a565d4b --- /dev/null +++ b/packages/extensions/vscode/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run FFM Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: compile" + } + ] +} diff --git a/packages/extensions/vscode/LICENSE b/packages/extensions/vscode/LICENSE new file mode 100644 index 0000000..f91ef59 --- /dev/null +++ b/packages/extensions/vscode/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Fuyeor + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/extensions/vscode/README.md b/packages/extensions/vscode/README.md new file mode 100644 index 0000000..8f55e2d --- /dev/null +++ b/packages/extensions/vscode/README.md @@ -0,0 +1,25 @@ +# Fuyeor Flavored Markdown + +The official extension for syntax highlighting and formatting of **FFM**. + +## ✨ Features + +- **Smart Spacing**: Auto-spacing for Chinese, English, numbers, and inline markup. +- **Structural Cleanup**: Standardizes list indentation, table layouts, and link formatting. +- **Hygiene**: Removes trailing spaces, repeated blank lines, and trims document boundaries. +- **Semantic Conversion**: Automatically upgrades long `>` blockquotes to `quote` fences. +- **Content Protection**: Never breaks code fences, inline code, or math formulas. + +## 🚀 Usage + +1. **Install** the extension. +2. **Open** any `.ffm` file. +3. **Format** using standard shortcuts: + - **Windows/Linux**: `Shift + Alt + F` + - **macOS**: `Shift + Option + F` + - Or right-click and select **"Format Document"**. + +## 📖 Reference + +- **Playground**: [flavored.fuyeor.com](https://flavored.fuyeor.com) +- **FFM Rules**: [reference.fuyeor.com](https://reference.fuyeor.com/ffm/overview) diff --git a/packages/extensions/vscode/examples/en.ffm b/packages/extensions/vscode/examples/en.ffm new file mode 100644 index 0000000..663ae11 --- /dev/null +++ b/packages/extensions/vscode/examples/en.ffm @@ -0,0 +1,228 @@ +# A Small Creative Experiment + +This is an article written to demonstrate **Fuyeor Flavored Markdown** syntax. It also includes *italic*, **bold**, ***bold italic***, --strikethrough--, and __underline__. + +The goal of the article is simple: keep the content clear and give the structure enough expressive power. + +## 1. Starting from an Idea + +### 1.1 Capturing Inspiration + +Sometimes an idea just needs one sentence: `Write it down first, then refine it gradually.` + +If the code itself contains backticks, you can wrap it with two backticks, for example: `` const mark = `FFM` ``. + +We can also visit the [Fuyeor FFM Syntax Overview](https://reference.fuyeor.com/ffm/overview), or simply type an autolink: https://reference.fuyeor.com/ffm/overview. + +### 1.2 A Glance at All Six Heading Levels + +# Heading Level 1 +## Heading Level 2 +### Heading Level 3 +#### Heading Level 4 +##### Heading Level 5 +###### Heading Level 6 + +All six heading levels above use `#` at the start of the line, with a space after the hash. + +## 2. Organising Materials into Lists + +For unordered lists, the recommended hyphen style works well: + +- Observe the problem + - Find the core need + - Record constraints + - Distinguish facts from assumptions + - Identify who the reader is +- Design the solution +- Verify the final result + +Unordered lists can also use the asterisk style: + +* First material + * Second material +* Third material + +Ordered lists use digits, a half‑width period, and a space: + +1. Write down the topic +2. Organise paragraphs + - Add a nested unordered item + - And one more deeper item +3. Check the tone + +## 3. Quotations and Explanations + +> Good documentation is not about piling up all the information, but about helping readers find the next step. + +For longer quotations, you can use a `quote` code block: + +```quote +What really matters is not memorising every piece of syntax, +but letting the syntax serve clear expression. +``` + +## 4. Code and Implementation + +Here is a TypeScript example: + +```typescript +function greet(name: string): string { + return `Hello, ${name}!`; +} + +console.log(greet('FFM reader')); +``` + +If you need to show a nested FFM code block inside the article, you can increase the number of backticks on the outer fence: + +````ffm +```accordion +**Expand to see details** + +This is a piece of collapsed content. +``` +```` + +## 5. Comparing Options with a Table + +The table below shows how inline syntax works inside table cells. The first column is left‑aligned, the second is centered, and the third is right‑aligned. + +| Approach | Characteristics | Use Case | +| :--- | :---: | ---: | +| **Lit** | `lightweight`, embeddable | Web Components | +| *Vue* | component‑based, extensible | Application websites | +| **Vue + parser** | __clear separation__ | Documentation playground | + +## 6. Displaying Views Side by Side + +The `slide` block below is suitable for presenting comparative content: + +```slide +**First card: understand the problem** + +- Identify the reader +- Clarify the goal +- List constraints + +--- + +**Second card: choose the tool** + +- Compare complexity +- Evaluate maintainability +- Reserve room for future extensions + +--- + +**Third card: verify the result** + +- Check rendering +- Check mobile layout +- Check accessibility +``` + +## 7. Expressing a Process with chain + +A project typically goes through several stages: + +```chain +**Start from the problem** +First gather background information and confirm what actually needs to be solved, rather than rushing to choose a technology. + +**[x] Complete material organisation** +I have read the syntax documentation and recorded basic syntax, layout blocks, and advanced extensions. + +**[x] Create a minimal example** +I have prepared an independent FFM document that can be read and verified. + +**[ ] Expand to a real page** +The next step is to connect the example to a playground and observe how different renderers behave. + +**[ ] Collect reader feedback** +Let actual users point out unclear parts, then decide whether to adjust the structure. +``` + +In chain, each bold line on its own becomes a node; `[x]` means completed, `[ ]` means pending, and headings without checkboxes use the default state. + +## 8. Storing Details with accordion + +The following content is collapsed by default; readers can click the headings to reveal the answers: + +```accordion +**Why does FFM use `--strikethrough--`?** +Because tildes often carry a tonal function in Chinese, Japanese, and Korean contexts; using two hyphens reduces conflicts with everyday writing. + +**Why is raw HTML not allowed?** +This prevents documents from depending on browser tags and reduces security risks from unknown content. + +**When is accordion appropriate?** +It is suitable for FAQs, supplementary explanations, or behind‑the‑scenes details that you do not want to occupy too much space initially. +``` + +## 9. Mathematical Formulas + +Inline formulas can be placed directly in a sentence: Einstein's mass–energy relation is $E=mc^2$, and the area of a circle is $S=\pi r^2$. + +Block‑level formulas stand on their own line: + +$$ +\int_0^1 x^2\,dx = \frac{1}{3} +$$ + +And here is the quadratic formula: + +$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$ + +## 10. Flowcharts + +Mermaid is great for describing processes and relationships in text: + +```mermaid +graph TD + A[Ask a question] --> B{Do we already understand the need?} + B -- Yes --> C[Design an example] + B -- No --> D[Read more materials] + D --> B + C --> E[Verify the rendering result] +``` + +## 11. Chemical Structure Notation + +SMILES can be used inline to show molecular structures. For example, water can be written as #[smiles = `O`], ethanol as #[smiles = `CCO`]. + +You can also use a block‑level `smiles` block, with one molecule per line: + +```smiles +O +CCO +c1ccccc1 +CC(=O)OC1=CC=CC=C1C(=O)O +``` + +## 12. Music Notation + +ABC notation can describe a simple melody in plain text: + +```abc +X: 1 +T: A short melody +M: 4/4 +L: 1/4 +K: C +C D E F | G A B c | +``` + +## 13. A Complete Summary + +Now, in this single article, we have used headings, paragraphs, inline emphasis, links, images, lists, blockquotes, inline code, code blocks, tables, horizontal rules, slide, chain, accordion, mathematical formulas, Mermaid, SMILES, and ABC notation. + +If you are writing your own FFM document, you can start with the simplest structure: + +1. Use headings to establish hierarchy. +2. Use paragraphs to explain the background. +3. Use lists and tables to organise information. +4. Use blockquotes, code blocks, and extended blocks to supplement details. +5. Finally, check whether each block truly helps the reader understand the content. + +> The value of FFM lies not in having more symbols, but in using a small set of clear syntax to write content that is structurally stable, easy to read, and convenient to render. \ No newline at end of file diff --git a/packages/extensions/vscode/examples/zh-hans.ffm b/packages/extensions/vscode/examples/zh-hans.ffm new file mode 100644 index 0000000..94604ab --- /dev/null +++ b/packages/extensions/vscode/examples/zh-hans.ffm @@ -0,0 +1,228 @@ +# 一场小型创作实验 + +这是一篇用于展示 **Fuyeor Flavored Markdown** 语法的文章。它同时包含 *斜体*、**粗体**、***粗体斜体***、--删除线-- 和 __下划线__。 + +文章的目标很简单:让内容保持清晰,也让结构拥有足够的表现力。 + +## 一、从一个想法开始 + +### 1.1 记录灵感 + +有时,一个想法只需要一句话:`先写下来,再慢慢改进。` + +如果代码本身包含反引号,可以使用两个反引号包裹它,例如:`` const mark = `FFM` ``。 + +我们也可以访问 [Fuyeor FFM 语法总览](https://reference.fuyeor.com/ffm/overview),或者直接输入一个自动链接:https://reference.fuyeor.com/ffm/overview。 + +### 1.2 六级标题一览 + +# 一级标题 +## 二级标题 +### 三级标题 +#### 四级标题 +##### 五级标题 +###### 六级标题 + +上面的六种标题都使用行首的 `#`,并且在井号之后保留一个空格。 + +## 二、把材料整理成清单 + +无序清单可以使用推荐的连字符写法: + +- 观察问题 + - 找到核心需求 + - 记录约束条件 + - 区分事实与猜测 + - 明确读者是谁 +- 设计解决方案 +- 验证最终结果 + +无序清单也可以使用星号写法: + +* 第一种材料 + * 第二种材料 +* 第三种材料 + +有序清单直接使用数字、半角句点和空格: + +1. 写下主题 +2. 组织段落 + - 添加一个嵌套的无序项目 + - 再补充一个更深层的项目 +3. 检查语气 + +## 三、引用与解释 + +> 好的文档不是把所有信息堆在一起,而是帮助读者找到下一步。 + +对于较长的引用,可以使用 `quote` 代码块: + +```quote +真正重要的不是记住多少语法, +而是让语法服务于清晰的表达。 +``` + +## 四、代码与实现 + +下面是一段 TypeScript 示例: + +```typescript +function greet(name: string): string { + return `你好,${name}!`; +} + +console.log(greet('FFM 读者')); +``` + +如果需要在文章中展示一个嵌套的 FFM 代码块,可以提高外层围栏的反引号数量: + +````ffm +```accordion +**展开说明** + +这里是一段折叠内容。 +``` +```` + +## 五、用表格比较方案 + +下面的表格展示了行内语法在单元格中的使用方式。第一列左对齐,第二列居中,第三列右对齐。 + +| 方案 | 特点 | 适用场景 | +| :--- | :---: | ---: | +| **Lit** | `轻量`、可嵌入 | Web Component | +| *Vue* | 组件化、易扩展 | 应用网站 | +| **Vue + parser** | __职责清晰__ | 文档 playground | + +## 六、并排展示观点 + +下面的 `slide` 区块适合展示相互比较的内容: + +```slide +**第一张卡片:先理解问题** + +- 识别读者 +- 明确目标 +- 列出限制条件 + +--- + +**第二张卡片:再选择工具** + +- 比较复杂度 +- 评估可维护性 +- 预留未来扩展空间 + +--- + +**第三张卡片:最后验证结果** + +- 检查渲染 +- 检查移动端布局 +- 检查无障碍体验 +``` + +## 七、用 chain 表达过程 + +一个项目通常会经历几个阶段: + +```chain +**从问题出发** +先收集背景资料,确认真正需要解决的事情,而不是急着选择技术。 + +**[x] 完成资料整理** +已经阅读语法文档,并记录了基础语法、布局区块和高级扩展。 + +**[x] 建立最小示例** +已经准备好一个可以独立阅读和验证的 FFM 文档。 + +**[ ] 扩展为真实页面** +下一步可以把示例接入 playground,观察不同渲染器的表现。 + +**[ ] 收集读者反馈** +让实际使用者指出难以理解的地方,再决定是否调整结构。 +``` + +chain 中的独占粗体行会成为节点;`[x]` 表示已完成,`[ ]` 表示待完成,不带复选框的标题则使用默认状态。 + +## 八、用 accordion 收纳细节 + +下面的内容默认折叠,读者可以点击标题查看答案: + +```accordion +**FFM 为什么使用 `--删除线--`?** +因为波浪号在中文、日文和韩文语境中经常具有语气作用,使用两个连字符可以减少与日常书写的冲突。 + +**为什么不允许原始 HTML?** +这样可以避免文档依赖浏览器标签,并降低未知内容带来的安全风险。 + +**什么时候适合使用 accordion?** +当内容属于 FAQ、补充解释或幕后细节,而且不希望它们一开始占据大量版面时,就适合使用 accordion。 +``` + +## 九、数学公式 + +行内公式可以直接放在句子中:爱因斯坦的质能关系是 $E=mc^2$,圆的面积是 $S=\pi r^2$。 + +块级公式会独立占据一行: + +$$ +\int_0^1 x^2\,dx = \frac{1}{3} +$$ + +再看一个二次方程的求根公式: + +$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$ + +## 十、流程图 + +Mermaid 适合用文本描述流程和关系: + +```mermaid +graph TD + A[提出问题] --> B{是否已经理解需求?} + B -- 是 --> C[设计示例] + B -- 否 --> D[继续阅读资料] + D --> B + C --> E[验证渲染结果] +``` + +## 十一、化学结构式 + +SMILES 可以用行内标注展示分子结构。例如,水可以写成 #[smiles = `O`],乙醇可以写成 #[smiles = `CCO`]。 + +也可以使用块级 `smiles` 区块,每一行表示一个分子: + +```smiles +O +CCO +c1ccccc1 +CC(=O)OC1=CC=CC=C1C(=O)O +``` + +## 十二、音乐记谱 + +ABC notation 可以用纯文本描述一段简单旋律: + +```abc +X: 1 +T: 一段短旋律 +M: 4/4 +L: 1/4 +K: C +C D E F | G A B c | +``` + +## 十三、一个完整的小结 + +现在,我们已经在同一篇文章中使用了标题、段落、行内强调、链接、图片、列表、引用、行内代码、代码块、表格、水平分隔线、slide、chain、accordion、数学公式、Mermaid、SMILES 和 ABC 记谱。 + +如果你正在编写自己的 FFM 文档,可以先从最简单的结构开始: + +1. 用标题建立层次。 +2. 用段落讲清楚背景。 +3. 用列表和表格整理信息。 +4. 用引用、代码块和扩展区块补充细节。 +5. 最后检查每个区块是否真的帮助读者理解内容。 + +> FFM 的价值不在于拥有更多符号,而在于用少量明确的语法,写出结构稳定、容易阅读、方便渲染的内容。 \ No newline at end of file diff --git a/packages/extensions/vscode/extension/icon.png b/packages/extensions/vscode/extension/icon.png new file mode 100644 index 0000000..0b22fcb Binary files /dev/null and b/packages/extensions/vscode/extension/icon.png differ diff --git a/packages/extensions/vscode/language-configuration.json b/packages/extensions/vscode/language-configuration.json new file mode 100644 index 0000000..da22b13 --- /dev/null +++ b/packages/extensions/vscode/language-configuration.json @@ -0,0 +1,19 @@ +{ + "brackets": [ + ["(", ")"], + ["[", "]"], + ["{", "}"] + ], + "autoClosingPairs": [ + { "open": "(", "close": ")" }, + { "open": "[", "close": "]" }, + { "open": "{", "close": "}" }, + { "open": "`", "close": "`", "notIn": ["string", "comment"] } + ], + "surroundingPairs": [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["`", "`"] + ] +} diff --git a/packages/extensions/vscode/package.json b/packages/extensions/vscode/package.json new file mode 100644 index 0000000..171d802 --- /dev/null +++ b/packages/extensions/vscode/package.json @@ -0,0 +1,74 @@ +{ + "name": "ffm", + "displayName": "Fuyeor Flavored Markdown", + "description": "Syntax highlighting for Fuyeor Flavored Markdown in Visual Studio Code.", + "version": "0.1.0", + "publisher": "Fuyeor", + "license": "MIT", + "icon": "extension/icon.png", + "repository": { + "type": "git", + "url": "https://github.com/Fuyeor/markdown-parser.git", + "directory": "packages/vscode-extension" + }, + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Programming Languages", + "Formatters" + ], + "main": "./out/extension.js", + "contributes": { + "languages": [ + { + "id": "ffm", + "aliases": [ + "ffm", + "fuyeor-flavored-markdown" + ], + "extensions": [ + ".ffm" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "ffm", + "scopeName": "text.ffm", + "path": "./syntaxes/ffm.tmLanguage.json" + } + ], + "configurationDefaults": { + "[ffm]": { + "editor.wordWrap": "on", + "editor.wrappingStrategy": "advanced", + "editor.unicodeHighlight.ambiguousCharacters": false + } + } + }, + "files": [ + "out", + "examples", + "syntaxes", + "extension", + "language-configuration.json", + "LICENSE" + ], + "scripts": { + "compile": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=out/extension.js", + "vscode:prepublish": "npm run compile", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --config ../../vitest.config.ts src/*.spec.ts" + }, + "dependencies": { + "@fuyeor/markdown-formatter": "workspace:*" + }, + "devDependencies": { + "@types/vscode": "^1.80.0", + "esbuild": "^0.27.7", + "typescript": "^6.0.3", + "vitest": "^4.1.0" + } +} diff --git a/packages/extensions/vscode/src/extension.spec.ts b/packages/extensions/vscode/src/extension.spec.ts new file mode 100644 index 0000000..4a4c235 --- /dev/null +++ b/packages/extensions/vscode/src/extension.spec.ts @@ -0,0 +1,88 @@ +// packages/vscode-extension/src/extension.spec.ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { activate } from './extension'; + +vi.mock('vscode', () => { + const registerDocumentFormattingEditProvider = vi.fn(); + class Range { + constructor( + readonly start: unknown, + readonly end: unknown, + ) {} + } + const replace = vi.fn((range: unknown, newText: string) => ({ + range, + newText, + })); + return { + Range, + TextEdit: { replace }, + languages: { registerDocumentFormattingEditProvider }, + }; +}); + +describe('FFM VS Code formatting provider', () => { + beforeEach(() => vi.clearAllMocks()); + + it('registers for ffm and replaces the complete document when needed', () => { + const disposable = { dispose: vi.fn() }; + vi.mocked( + vscode.languages.registerDocumentFormattingEditProvider, + ).mockReturnValue(disposable); + const subscriptions: unknown[] = []; + activate({ subscriptions } as unknown as vscode.ExtensionContext); + + expect( + vscode.languages.registerDocumentFormattingEditProvider, + ).toHaveBeenCalledWith('ffm', expect.any(Object)); + expect(subscriptions).toContain(disposable); + + const provider = vi.mocked( + vscode.languages.registerDocumentFormattingEditProvider, + ).mock.calls[0]![1]; + const source = '这是10个XX'; + const edits = provider.provideDocumentFormattingEdits( + { + getText: () => source, + positionAt: (offset: number) => ({ offset }), + } as unknown as vscode.TextDocument, + {} as vscode.FormattingOptions, + {} as vscode.CancellationToken, + ); + + expect(edits).toEqual([ + { + range: { + start: { offset: 0 }, + end: { offset: source.length }, + }, + newText: '这是 10 个 XX', + }, + ]); + }); + + it('returns no edit for already formatted documents', () => { + const disposable = { dispose: vi.fn() }; + vi.mocked( + vscode.languages.registerDocumentFormattingEditProvider, + ).mockReturnValue(disposable); + const subscriptions: unknown[] = []; + activate({ subscriptions } as unknown as vscode.ExtensionContext); + + const provider = vi.mocked( + vscode.languages.registerDocumentFormattingEditProvider, + ).mock.calls[0]![1]; + const source = '已经格式化'; + const edits = provider.provideDocumentFormattingEdits( + { + getText: () => source, + positionAt: (offset: number) => ({ offset }), + } as unknown as vscode.TextDocument, + {} as vscode.FormattingOptions, + {} as vscode.CancellationToken, + ); + + expect(edits).toEqual([]); + }); +}); diff --git a/packages/extensions/vscode/src/extension.ts b/packages/extensions/vscode/src/extension.ts new file mode 100644 index 0000000..ae9d4ad --- /dev/null +++ b/packages/extensions/vscode/src/extension.ts @@ -0,0 +1,31 @@ +// packages/vscode-extension/src/extension.ts +import * as vscode from 'vscode'; +import { format } from '@fuyeor/markdown-formatter'; + +/** Replace the complete document only when formatting changes its content. */ +const formatter: vscode.DocumentFormattingEditProvider = { + provideDocumentFormattingEdits( + document, + _options, + _token, + ): vscode.TextEdit[] { + const source = document.getText(); + const formatted = format(source); + if (formatted === source) return []; + + const fullDocument = new vscode.Range( + document.positionAt(0), + document.positionAt(source.length), + ); + return [vscode.TextEdit.replace(fullDocument, formatted)]; + }, +}; + +/** Register FFM formatting without adding any persistent runtime state. */ +export function activate(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider('ffm', formatter), + ); +} + +export function deactivate(): void {} diff --git a/packages/extensions/vscode/src/ffm-grammar.spec.ts b/packages/extensions/vscode/src/ffm-grammar.spec.ts new file mode 100644 index 0000000..8489701 --- /dev/null +++ b/packages/extensions/vscode/src/ffm-grammar.spec.ts @@ -0,0 +1,92 @@ +// packages/vscode-extension/src/ffm-grammar.spec.ts +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const packageDirectory = resolve(import.meta.dirname, '..'); +const readJson = (relativePath: string) => + JSON.parse( + readFileSync(resolve(packageDirectory, relativePath), 'utf8'), + ) as Record; + +describe('FFM VS Code extension source', () => { + it('declares a valid FFM language contribution', () => { + const manifest = readJson('package.json'); + const contributes = manifest.contributes as Record; + const languages = contributes.languages as Array>; + const language = languages.find((entry) => entry.id === 'ffm'); + + expect(language).toBeDefined(); + expect(language?.extensions).toEqual(['.ffm', '.fmd']); + expect(language?.configuration).toBe('./language-configuration.json'); + }); + + it('points the grammar contribution to the FFM TextMate grammar', () => { + const manifest = readJson('package.json'); + const contributes = manifest.contributes as Record; + const grammars = contributes.grammars as Array>; + + expect(grammars).toContainEqual({ + language: 'ffm', + scopeName: 'text.html.markdown.ffm', + path: './syntaxes/ffm.tmLanguage.json', + }); + }); + + it('covers the syntax families defined by the FFM tutorials', () => { + const grammar = readJson('syntaxes/ffm.tmLanguage.json'); + const repository = grammar.repository as Record; + const expectedRules = [ + 'heading', + 'bold_italic', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'list_marker', + 'link', + 'image', + 'blockquote', + 'fenced_code_block', + 'special_fenced_blocks', + 'math_block', + 'inline_math', + 'table_row', + ]; + + for (const ruleName of expectedRules) + expect(repository[ruleName]).toBeDefined(); + }); + + it('configures .ffm documents as wrapped natural text without ambiguous-character highlights', () => { + const manifest = readJson('package.json'); + const configurationDefaults = manifest.configurationDefaults as Record< + string, + unknown + >; + const ffmDefaults = configurationDefaults['[ffm]'] as Record< + string, + unknown + >; + + expect(ffmDefaults).toEqual({ + 'editor.wordWrap': 'on', + 'editor.wrappingStrategy': 'advanced', + 'editor.unicodeHighlight.ambiguousCharacters': false, + }); + }); + + it('recognizes FFM-only fenced block keywords and task titles', () => { + const grammar = readJson('syntaxes/ffm.tmLanguage.json'); + const repository = grammar.repository as Record; + const specialBlocks = repository.special_fenced_blocks as Record< + string, + unknown + >; + const title = repository.special_block_title as Record; + + expect(specialBlocks.begin).toContain( + 'quote|slide|chain|accordion|mermaid|smiles|abc', + ); + expect(title.match).toContain('\\[[ xX]\\]'); + }); +}); diff --git a/packages/extensions/vscode/src/vscode.mock.ts b/packages/extensions/vscode/src/vscode.mock.ts new file mode 100644 index 0000000..61537ae --- /dev/null +++ b/packages/extensions/vscode/src/vscode.mock.ts @@ -0,0 +1,20 @@ +// packages/vscode-extension/src/vscode.mock.ts +import { vi } from 'vitest'; + +export class Range { + constructor( + readonly start: unknown, + readonly end: unknown, + ) {} +} + +export const TextEdit = { + replace: vi.fn((range: unknown, newText: string) => ({ + range, + newText, + })), +}; + +export const languages = { + registerDocumentFormattingEditProvider: vi.fn(), +}; diff --git a/packages/extensions/vscode/syntaxes/ffm.tmLanguage.json b/packages/extensions/vscode/syntaxes/ffm.tmLanguage.json new file mode 100644 index 0000000..eaad6b4 --- /dev/null +++ b/packages/extensions/vscode/syntaxes/ffm.tmLanguage.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Fuyeor Flavored Markdown", + "scopeName": "text.ffm", + "patterns": [ + { "include": "#layout_blocks" }, + { "include": "#fenced_code_block" }, + { "include": "#math_block" }, + { "include": "#table_row" }, + { "include": "#heading" }, + { "include": "#blockquote" }, + { "include": "#list_marker" }, + { "include": "#image" }, + { "include": "#link" }, + { "include": "#inline_code" }, + { "include": "#inline_math" }, + { "include": "#bold_italic" }, + { "include": "#bold" }, + { "include": "#underline" }, + { "include": "#strikethrough" }, + { "include": "#italic" } + ], + "repository": { + "layout_blocks": { + "begin": "^\\s*(`{3,})(quote|slide|chain|accordion|mermaid|smiles|abc)\\s*$", + "beginCaptures": { + "1": { "name": "punctuation.definition.fenced.begin.ffm" }, + "2": { "name": "entity.name.type.block.ffm" } + }, + "end": "^\\s*\\1\\s*$", + "endCaptures": { + "0": { "name": "punctuation.definition.fenced.end.ffm" } + }, + "name": "markup.fenced.block.ffm", + "patterns": [ + { "include": "#layout_block_title" }, + { "include": "#slide_separator" }, + { "include": "#blockquote" }, + { "include": "#image" }, + { "include": "#link" }, + { "include": "#inline_code" }, + { "include": "#inline_math" }, + { "include": "#bold_italic" }, + { "include": "#bold" }, + { "include": "#underline" }, + { "include": "#strikethrough" }, + { "include": "#italic" } + ] + }, + "fenced_code_block": { + "begin": "^\\s*(`{3,})([A-Za-z][A-Za-z0-9_+.-]*)?\\s*$", + "beginCaptures": { + "1": { "name": "punctuation.definition.fenced.begin.ffm" }, + "2": { "name": "entity.name.type.language.ffm" } + }, + "end": "^\\s*\\1\\s*$", + "endCaptures": { + "0": { "name": "punctuation.definition.fenced.end.ffm" } + }, + "name": "markup.fenced.code.block.ffm", + "contentName": "source.embedded.ffm" + }, + "math_block": { + "begin": "^\\s*(\\$\\$)\\s*$", + "beginCaptures": { + "1": { "name": "punctuation.definition.math.begin.ffm" } + }, + "end": "^\\s*(\\$\\$)\\s*$", + "endCaptures": { + "1": { "name": "punctuation.definition.math.end.ffm" } + }, + "name": "markup.math.block.ffm" + }, + "table_row": { + "begin": "^\\s*(?=\\|)", + "end": "$", + "name": "meta.table.row.ffm", + "patterns": [ + { "match": "\\|", "name": "punctuation.separator.table.ffm" }, + { + "match": "(?)(?:\\s+)(.*)$", + "captures": { + "1": { "name": "punctuation.definition.quote.begin.ffm" }, + "2": { "name": "markup.quote.ffm" } + } + }, + "list_marker": { + "patterns": [ + { + "match": "^(\\s{0,})([-*])(?=\\s+)", + "captures": { + "1": { "name": "punctuation.whitespace.list.indent.ffm" }, + "2": { "name": "punctuation.definition.list.unordered.ffm" } + } + }, + { + "match": "^(\\s{0,})(\\d+\\.)(?=\\s+)", + "captures": { + "1": { "name": "punctuation.whitespace.list.indent.ffm" }, + "2": { "name": "punctuation.definition.list.ordered.ffm" } + } + } + ] + }, + "image": { + "match": "!\\[([^]\\n]*)\\]\\(([^)\\n]+)\\)", + "captures": { + "1": { "name": "string.other.image.alt.ffm" }, + "2": { "name": "markup.underline.link.image.ffm" } + }, + "name": "meta.image.ffm" + }, + "link": { + "match": "\\[([^]\\n]*)\\]\\(([^)\\n]+)\\)", + "captures": { + "1": { "name": "string.other.link.title.ffm" }, + "2": { "name": "markup.underline.link.ffm" } + }, + "name": "meta.link.ffm" + }, + "inline_code": { + "match": "(`{1,3})([^`\\n]+?)\\1", + "captures": { + "1": { "name": "punctuation.definition.raw.begin.ffm" }, + "2": { "name": "markup.inline.raw.string.ffm" } + }, + "name": "markup.inline.raw.ffm" + }, + "inline_math": { + "match": "\\$(?!\\$)([^$\\n]+?)\\$", + "captures": { + "1": { "name": "string.other.math.ffm" } + }, + "name": "markup.math.inline.ffm" + }, + "bold_italic": { + "match": "\\*\\*\\*([^*\\n]+?)\\*\\*\\*", + "captures": { + "1": { "name": "markup.bold.italic.content.ffm" } + }, + "name": "markup.bold.italic.ffm" + }, + "bold": { + "match": "\\*\\*([^*\\n]+?)\\*\\*", + "captures": { + "1": { "name": "markup.bold.content.ffm" } + }, + "name": "markup.bold.ffm" + }, + "underline": { + "match": "__([^_\\n]+?)__", + "captures": { + "1": { "name": "markup.underline.content.ffm" } + }, + "name": "markup.underline.ffm" + }, + "strikethrough": { + "match": "--([^\\-\\n]+?)--", + "captures": { + "1": { "name": "markup.strikethrough.content.ffm" } + }, + "name": "markup.strikethrough.ffm" + }, + "italic": { + "match": "(?", + "type": "module", + "sideEffects": false, + "scripts": { + "test": "vitest run src/index.spec.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "exports": { + ".": "./src/index.ts" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^6.0.3", + "vitest": "^4.1.0" + } +} diff --git a/packages/markdown-formatter/src/fixtures/format.json b/packages/markdown-formatter/src/fixtures/format.json new file mode 100644 index 0000000..f188617 --- /dev/null +++ b/packages/markdown-formatter/src/fixtures/format.json @@ -0,0 +1,97 @@ +[ + { + "origin": "这是10个XX", + "formatted": "这是 10 个 XX", + "section": "cjk-spacing" + }, + { + "origin": "展示**Fuyeor Flavored Markdown**的文章", + "formatted": "展示 **Fuyeor Flavored Markdown** 的文章", + "section": "inline-markup-spacing" + }, + { + "origin": "句末。 \n下一句", + "formatted": "句末。\n下一句", + "section": "trailing-whitespace" + }, + { + "origin": " \n\n 第一行\n第二行 \n\n", + "formatted": "第一行\n第二行", + "section": "document-boundary" + }, + { + "origin": " - 第一项\n - 第二项", + "formatted": " - 第一项\n - 第二项", + "section": "list-spacing" + }, + { + "origin": "1. 写下主题\n2. 组织段落\n - 添加一个嵌套的无序项目\n - 再补充一个更深层的项目\n3. 检查语气", + "formatted": "1. 写下主题\n2. 组织段落\n - 添加一个嵌套的无序项目\n - 再补充一个更深层的项目\n3. 检查语气", + "section": "nested-list-spacing" + }, + { + "origin": "---", + "formatted": "---", + "section": "horizontal-rule" + }, + { + "origin": "| 表头一 | 表头二 |\n| :------- | -------: |\n| 内容一 | 内容二 |", + "formatted": "| 表头一 | 表头二 |\n| :--- | ---: |\n| 内容一 | 内容二 |", + "section": "table-spacing" + }, + { + "origin": "[Fuyeor FFM 语法总览]( https://reference.fuyeor.com/ffm/overview )", + "formatted": "[Fuyeor FFM 语法总览](https://reference.fuyeor.com/ffm/overview)", + "section": "link-destination" + }, + { + "origin": "> 引用 1\n>\n> 引用 2\n> \n> 引用 3", + "formatted": "```quote\n引用 1\n\n引用 2\n\n引用 3\n```", + "section": "blockquote-conversion" + }, + { + "origin": "> 引用 1\n>\n> 引用 2", + "formatted": "> 引用 1\n>\n> 引用 2", + "section": "short-blockquote" + }, + { + "origin": "> Text", + "formatted": "> Text", + "section": "blockquote-spacing" + }, + { + "origin": "文字`code`文字", + "formatted": "文字 `code` 文字", + "section": "inline-code-spacing" + }, + { + "origin": "文本``code``文本", + "formatted": "文本 ``code`` 文本", + "section": "multi-backtick-spacing" + }, + { + "origin": " text", + "formatted": "text", + "section": "ordinary-leading-whitespace" + }, + { + "origin": "text\n\n\n\ntext", + "formatted": "text\n\ntext", + "section": "repeated-blank-lines" + }, + { + "origin": "```quote\n这是10个XX。 \n```", + "formatted": "```quote\n这是 10 个 XX。\n```", + "section": "semantic-fence" + }, + { + "origin": "```ffm\n这是10个XX。 \n - code\n```", + "formatted": "```ffm\n这是10个XX。 \n - code\n```", + "section": "protected-code-fence" + }, + { + "origin": "文本 `这是10个XX` 与 $10个XX$", + "formatted": "文本 `这是10个XX` 与 $10个XX$", + "section": "protected-inline-content" + } +] diff --git a/packages/markdown-formatter/src/index.spec.ts b/packages/markdown-formatter/src/index.spec.ts new file mode 100644 index 0000000..86fbd53 --- /dev/null +++ b/packages/markdown-formatter/src/index.spec.ts @@ -0,0 +1,24 @@ +// @fuyeor/markdown-formatter/src/index.spec.ts +import { describe, expect, it } from 'vitest'; +import fixtureData from './fixtures/format.json'; +import { format } from './index'; + +type FormatFixture = { + origin: string; + formatted: string; + section: string; +}; + +const fixtures = fixtureData as FormatFixture[]; + +describe('format fixtures', () => { + for (const fixture of fixtures) { + it(fixture.section, () => { + expect(format(fixture.origin)).toBe(fixture.formatted); + }); + } + + it('fails fast for non-string input', () => { + expect(() => format(null as unknown as string)).toThrow(TypeError); + }); +}); diff --git a/packages/markdown-formatter/src/index.ts b/packages/markdown-formatter/src/index.ts new file mode 100644 index 0000000..2743e54 --- /dev/null +++ b/packages/markdown-formatter/src/index.ts @@ -0,0 +1,442 @@ +// @fuyeor/markdown-formatter/src/index.ts + +const CJK_CHARACTER = '\\p{Script=Han}'; +const LATIN_OR_DIGIT = 'A-Za-z0-9'; +const CJK_LATIN_BOUNDARY = new RegExp( + `(?<=[${CJK_CHARACTER}])(?=[${LATIN_OR_DIGIT}])|(?<=[${LATIN_OR_DIGIT}])(?=[${CJK_CHARACTER}])`, + 'gu', +); +const INLINE_MARKUP_PATTERN = /(\*{1,3}|_{2}|--)([^\n]+?)\1/gu; +const LINK_TARGET_PATTERN = /(!?\[[^\]\n]*\])\(\s*([^)]*?\S)\s*\)/gu; +const SEMANTIC_FENCE_LANGUAGES = new Set([ + 'quote', + 'slide', + 'chain', + 'accordion', +]); + +type Fence = { + character: '`' | '~'; + length: number; + language?: string; +}; + +type QuoteLine = { + content: string; +}; + +type ListIndentContext = { + levels: number[]; +}; + +/** Normalize line endings before applying deterministic line-based formatting. */ +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/gu, '\n'); +} + +/** Return a fenced-block opener while leaving all fenced content untouched. */ +function getFence(line: string): Fence | null { + const match = line.match(/^\s*(`{3,}|~{3,})([A-Za-z][A-Za-z0-9_+.-]*)?\s*$/u); + if (!match) return null; + return { + character: match[1]![0] as '`' | '~', + length: match[1]!.length, + language: match[2]?.toLowerCase(), + }; +} + +/** Check whether a line closes the currently active fence. */ +function isFenceClose(line: string, fence: Fence): boolean { + const marker = fence.character === '`' ? '`' : '~'; + const expression = new RegExp(`^\\s*${marker}{${fence.length},}\\s*$`, 'u'); + return expression.test(line); +} + +/** Split a table row without treating escaped or inline-code pipes as separators. */ +function splitTableCells(line: string): string[] { + const source = line.trim(); + const content = source.startsWith('|') ? source.slice(1) : source; + const cells: string[] = []; + let cell = ''; + let inlineCodeMarker = ''; + + for (let index = 0; index < content.length; index++) { + const character = content[index]!; + if (character === '\\' && content[index + 1] === '|') { + cell += '|'; + index++; + continue; + } + if (character === '`') { + let markerLength = 1; + while (content[index + markerLength] === '`') markerLength++; + const marker = '`'.repeat(markerLength); + inlineCodeMarker = + inlineCodeMarker === marker ? '' : inlineCodeMarker || marker; + cell += marker; + index += markerLength - 1; + continue; + } + if (character === '|' && !inlineCodeMarker) { + cells.push(cell.trim()); + cell = ''; + continue; + } + cell += character; + } + cells.push(cell.trim()); + if (cells.at(-1) === '') cells.pop(); + return cells; +} + +/** Identify the Markdown table delimiter row and its alignment cells. */ +function getTableDelimiterCells(line: string): string[] | null { + const cells = splitTableCells(line); + if (cells.length === 0 || cells.some((cell) => !/^:?-{3,}:?$/u.test(cell))) { + return null; + } + return cells; +} + +/** Reduce table padding and delimiter runs to the canonical FFM representation. */ +function formatTableRow(cells: readonly string[]): string { + return `| ${cells.map(formatText).join(' | ')} |`; +} + +/** Preserve alignment markers while removing redundant delimiter hyphens. */ +function formatTableDelimiter(cells: readonly string[]): string { + return formatTableRow( + cells.map((cell) => { + const leftAligned = cell.startsWith(':'); + const rightAligned = cell.endsWith(':'); + return `${leftAligned ? ':' : ''}---${rightAligned ? ':' : ''}`; + }), + ); +} + +/** Check whether a single character is a Han-script character. */ +function isCjkCharacter(character: string | undefined): boolean { + return character !== undefined && /^\p{Script=Han}$/u.test(character); +} + +/** Check whether a single character is a Latin letter or an ASCII digit. */ +function isLatinOrDigitCharacter(character: string | undefined): boolean { + return character !== undefined && /^[A-Za-z0-9]$/u.test(character); +} + +/** Apply CJK spacing to plain text without interpreting protected inline code. */ +function formatCjkBoundaries(segment: string): string { + return segment.replace(CJK_LATIN_BOUNDARY, ' '); +} + +/** Add spaces around inline markup when its content crosses a CJK boundary. */ +function formatInlineMarkupBoundaries(segment: string): string { + return segment.replace( + INLINE_MARKUP_PATTERN, + ( + full: string, + marker: string, + inner: string, + offset: number, + source: string, + ) => { + const previous = source[offset - 1]; + const next = source[offset + full.length]; + const leadingSpace = + isCjkCharacter(previous) && isLatinOrDigitCharacter(inner[0]) + ? ' ' + : ''; + const trailingSpace = + isCjkCharacter(next) && isLatinOrDigitCharacter(inner.at(-1)) + ? ' ' + : ''; + return `${leadingSpace}${marker}${formatCjkBoundaries(inner)}${marker}${trailingSpace}`; + }, + ); +} + +/** Trim only the outer whitespace of Markdown link destinations. */ +function trimLinkTargets(segment: string): string { + return segment.replace( + LINK_TARGET_PATTERN, + (_full: string, label: string, target: string) => `${label}(${target})`, + ); +} + +/** Apply inline spacing and link cleanup to an unprotected text segment. */ +function formatTextSegment(segment: string): string { + return formatCjkBoundaries( + formatInlineMarkupBoundaries(trimLinkTargets(segment)), + ); +} + +/** Add CJK spacing around a protected inline token without changing its content. */ +function formatProtectedToken( + token: string, + previous: string | undefined, + next: string | undefined, +): string { + const marker = + token[0] === '`' + ? '`'.repeat(countMarkerCharacters(token, 0, '`')) + : token.startsWith('$$') + ? '$$' + : '$'; + const inner = token.slice(marker.length, -marker.length); + const leadingSpace = + isCjkCharacter(previous) && isLatinOrDigitCharacter(inner[0]) ? ' ' : ''; + const trailingSpace = + isCjkCharacter(next) && isLatinOrDigitCharacter(inner.at(-1)) ? ' ' : ''; + return `${leadingSpace}${token}${trailingSpace}`; +} + +/** Format ordinary text while preserving inline code and math tokens byte-for-byte. */ +function formatText(line: string): string { + let result = ''; + let segmentStart = 0; + let index = 0; + + const appendPlainText = (end: number) => { + result += formatTextSegment(line.slice(segmentStart, end)); + }; + + while (index < line.length) { + const character = line[index]!; + if (character === '`' || character === '$') { + const marker = + character === '`' + ? '`'.repeat(countMarkerCharacters(line, index, '`')) + : line.startsWith('$$', index) + ? '$$' + : '$'; + const contentStart = index + marker.length; + const closingIndex = line.indexOf(marker, contentStart); + if ( + closingIndex !== -1 && + (character !== '$' || marker === '$$' || closingIndex > contentStart) + ) { + appendPlainText(index); + const contentEnd = closingIndex + marker.length; + result += formatProtectedToken( + line.slice(index, contentEnd), + line[index - 1], + line[contentEnd], + ); + index = contentEnd; + segmentStart = index; + continue; + } + } + index++; + } + + appendPlainText(line.length); + return result; +} + +/** Count a contiguous run of the selected marker character. */ +function countMarkerCharacters( + line: string, + start: number, + marker: '`' | '$', +): number { + let count = 0; + while (line[start + count] === marker) count++; + return count; +} + +/** Normalize one list level to two spaces while preserving nested list depth. */ +function formatListLine( + line: string, + context: ListIndentContext, +): string | null { + const match = line.match(/^(\s*)([-*]|\d+[.)])(?=\s+)/u); + if (!match) return null; + + const rawIndentation = match[1]!.replace(/\t/gu, ' ').length; + while (context.levels.length > 1 && rawIndentation < context.levels.at(-1)!) { + context.levels.pop(); + } + if (rawIndentation > context.levels.at(-1)!) { + context.levels.push(rawIndentation); + } + + const markerEnd = match[1]!.length + match[2]!.length; + const rest = formatText(line.slice(markerEnd).trimStart()); + const indentation = ' '.repeat((context.levels.length - 1) * 2); + return `${indentation}${match[2]} ${rest}`; +} + +/** Normalize one Markdown blockquote marker and its content spacing. */ +function formatQuoteLine(line: string): string | null { + const match = line.match(/^\s*(>+)[ \t]*(.*)$/u); + if (!match) return null; + const content = formatText(match[2]!.trimStart()); + return content ? `${match[1]} ${content}` : match[1]!; +} + +/** Format one non-fenced line without changing its Markdown delimiters. */ +function formatOrdinaryLine(line: string, context: ListIndentContext): string { + const quoteLine = formatQuoteLine(line); + const listLine = formatListLine(line, context); + const formatted = quoteLine ?? listLine ?? formatText(line).trimStart(); + if (!listLine) context.levels = [0]; + return formatted.replace(/[ \t]+$/u, ''); +} + +/** Parse a single line from a contiguous Markdown blockquote. */ +function getQuoteLine(line: string): QuoteLine | null { + const match = line.match(/^\s*>+\s?(.*)$/u); + return match ? { content: match[1]! } : null; +} + +/** Convert a blockquote with at least three non-empty quoted lines to FFM quote syntax. */ +function formatDeepQuote( + lines: readonly string[], + start: number, +): { lines: string[]; next: number } | null { + const first = getQuoteLine(lines[start]!); + if (!first) return null; + + const content = [first.content]; + let next = start + 1; + while (next < lines.length) { + const continuation = getQuoteLine(lines[next]!); + if (!continuation) break; + content.push(continuation.content); + next++; + } + + if (content.filter((line) => line.trim() !== '').length < 3) return null; + const listContext: ListIndentContext = { levels: [0] }; + return { + lines: [ + '```quote', + ...content.map((line) => formatOrdinaryLine(line, listContext)), + '```', + ], + next, + }; +} + +/** Format one complete Markdown table beginning at the supplied header line. */ +function formatTable( + lines: readonly string[], + start: number, +): { lines: string[]; next: number } | null { + if (!lines[start]!.includes('|')) return null; + const delimiterCells = getTableDelimiterCells(lines[start + 1] ?? ''); + if (!delimiterCells) return null; + + const formatted = [ + formatTableRow(splitTableCells(lines[start]!)), + formatTableDelimiter(delimiterCells), + ]; + let next = start + 2; + while (next < lines.length && lines[next]!.includes('|')) { + formatted.push(formatTableRow(splitTableCells(lines[next]!))); + next++; + } + return { lines: formatted, next }; +} + +/** Format content inside FFM semantic fences while preserving their delimiters. */ +function formatSemanticFence( + lines: readonly string[], + start: number, + fence: Fence, +): { lines: string[]; next: number } | null { + if (!fence.language || !SEMANTIC_FENCE_LANGUAGES.has(fence.language)) { + return null; + } + + let closingIndex = start + 1; + while (closingIndex < lines.length) { + if (isFenceClose(lines[closingIndex]!, fence)) break; + closingIndex++; + } + if (closingIndex >= lines.length) return null; + + const inner = format(lines.slice(start + 1, closingIndex).join('\n')); + return { + lines: [ + lines[start]!, + ...(inner ? inner.split('\n') : []), + lines[closingIndex]!, + ], + next: closingIndex + 1, + }; +} + +/** Remove empty boundary lines without treating list indentation as disposable file whitespace. */ +function trimDocumentBoundary(lines: readonly string[]): string { + let start = 0; + let end = lines.length; + while (start < end && lines[start]!.trim() === '') start++; + while (end > start && lines[end - 1]!.trim() === '') end--; + if (start === end) return ''; + + const body = lines.slice(start, end); + const listContext: ListIndentContext = { levels: [0] }; + body[0] = formatOrdinaryLine(body[0]!, listContext); + return body.join('\n').replace(/[ \t]+$/u, ''); +} + +/** Format a complete FFM document according to the editor's canonical style. */ +export function format(content: string): string { + if (typeof content !== 'string') + throw new TypeError('content must be a string'); + const lines = normalizeLineEndings(content).split('\n'); + const formatted: string[] = []; + const listContext: ListIndentContext = { levels: [0] }; + let fence: Fence | null = null; + + for (let index = 0; index < lines.length; ) { + const line = lines[index]!; + if (fence) { + formatted.push(line); + if (isFenceClose(line, fence)) fence = null; + index++; + continue; + } + + if (line.trim() === '') { + if (formatted.at(-1) !== '') formatted.push(''); + index++; + continue; + } + + const openingFence = getFence(line); + if (openingFence) { + const semanticFence = formatSemanticFence(lines, index, openingFence); + if (semanticFence) { + formatted.push(...semanticFence.lines); + index = semanticFence.next; + continue; + } + fence = openingFence; + formatted.push(line); + index++; + continue; + } + + const deepQuote = formatDeepQuote(lines, index); + if (deepQuote) { + formatted.push(...deepQuote.lines); + index = deepQuote.next; + continue; + } + + const table = formatTable(lines, index); + if (table) { + formatted.push(...table.lines); + index = table.next; + continue; + } + + formatted.push(formatOrdinaryLine(line, listContext)); + index++; + } + + return trimDocumentBoundary(formatted); +} diff --git a/packages/markdown-formatter/tsconfig.json b/packages/markdown-formatter/tsconfig.json new file mode 100644 index 0000000..6c7b3e6 --- /dev/null +++ b/packages/markdown-formatter/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../markdown-parser/tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79aae31..65ba15a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,18 @@ importers: specifier: ^4.21.0 version: 4.21.0 + packages/markdown-formatter: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.0 + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) + packages/markdown-parser: devDependencies: '@types/node': @@ -109,6 +121,25 @@ importers: specifier: ^8.0.1 version: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) + packages/vscode-extension: + dependencies: + '@fuyeor/markdown-formatter': + specifier: workspace:* + version: link:../markdown-formatter + devDependencies: + '@types/vscode': + specifier: ^1.80.0 + version: 1.134.0 + esbuild: + specifier: ^0.27.7 + version: 0.27.7 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.0 + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) + packages: '@asamuzakjp/css-color@5.1.11': @@ -516,6 +547,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/vscode@1.134.0': + resolution: {integrity: sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==} + '@vitest/coverage-v8@4.1.4': resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} peerDependencies: @@ -1351,6 +1385,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/vscode@1.134.0': {} + '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': dependencies: '@bcoe/v8-coverage': 1.0.2 diff --git a/vitest.config.ts b/vitest.config.ts index 2afced6..d83ecef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,17 @@ // vitest.config.ts +import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [], + resolve: { + alias: { + vscode: resolve( + import.meta.dirname, + 'packages/vscode-extension/src/vscode.mock.ts', + ), + }, + }, test: { // Test browser environment environment: 'jsdom',