Added ffm-js
This commit is contained in:
parent
29bb1453fc
commit
8c4b386a99
39 changed files with 15271 additions and 0 deletions
13
packages/ffm-js/.editorconfig
Normal file
13
packages/ffm-js/.editorconfig
Normal file
|
@ -0,0 +1,13 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
8
packages/ffm-js/.eslintignore
Normal file
8
packages/ffm-js/.eslintignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
/built
|
||||
/coverage
|
||||
/.eslintrc.js
|
||||
/jest.config.ts
|
||||
parser.js
|
||||
/test
|
||||
/test-d
|
56
packages/ffm-js/.eslintrc.js
Normal file
56
packages/ffm-js/.eslintrc.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
rules: {
|
||||
'indent': ['error', 'tab', {
|
||||
'SwitchCase': 1,
|
||||
'MemberExpression': 'off',
|
||||
'flatTernaryExpressions': true,
|
||||
'ArrayExpression': 'first',
|
||||
'ObjectExpression': 'first',
|
||||
}],
|
||||
'eol-last': ['error', 'always'],
|
||||
'semi': ['error', 'always'],
|
||||
'quotes': ['error', 'single'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'keyword-spacing': ['error', {
|
||||
'before': true,
|
||||
'after': true,
|
||||
}],
|
||||
'key-spacing': ['error', {
|
||||
'beforeColon': false,
|
||||
'afterColon': true,
|
||||
}],
|
||||
'space-infix-ops': ['error'],
|
||||
'space-before-blocks': ['error', 'always'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'nonblock-statement-body-position': ['error', 'beside'],
|
||||
'eqeqeq': ['error', 'always', { 'null': 'ignore' }],
|
||||
'no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||
'no-multi-spaces': ['error'],
|
||||
'no-var': ['error'],
|
||||
'prefer-arrow-callback': ['error'],
|
||||
'no-throw-literal': ['error'],
|
||||
'no-param-reassign': ['warn'],
|
||||
'no-constant-condition': ['warn'],
|
||||
'no-empty-pattern': ['warn'],
|
||||
'@typescript-eslint/no-unnecessary-condition': ['warn'],
|
||||
'@typescript-eslint/no-inferrable-types': ['warn'],
|
||||
'@typescript-eslint/no-non-null-assertion': ['warn'],
|
||||
'@typescript-eslint/explicit-function-return-type': ['warn'],
|
||||
'@typescript-eslint/no-misused-promises': ['error', {
|
||||
'checksVoidReturn': false,
|
||||
}],
|
||||
},
|
||||
};
|
7
packages/ffm-js/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
7
packages/ffm-js/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
contact_links:
|
||||
- name: 👪 Misskey Forum
|
||||
url: https://forum.misskey.io/
|
||||
about: Ask questions and share knowledge
|
||||
- name: 💬 Misskey official Discord
|
||||
url: https://discord.gg/Wp8gVStHW3
|
||||
about: Chat freely about Misskey
|
20
packages/ffm-js/.github/pull_request_template.md
vendored
Normal file
20
packages/ffm-js/.github/pull_request_template.md
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!-- ℹ お読みください
|
||||
PRありがとうございます! PRを作成する前に、コントリビューションガイドをご確認ください:
|
||||
https://github.com/misskey-dev/misskey.js/blob/develop/CONTRIBUTING.md
|
||||
-->
|
||||
<!-- ℹ README
|
||||
Thank you for your PR! Before creating a PR, please check the contribution guide:
|
||||
https://github.com/misskey-dev/misskey.js/blob/develop/docs/CONTRIBUTING.en.md
|
||||
-->
|
||||
|
||||
# What
|
||||
<!-- このPRで何をしたのか? どう変わるのか? -->
|
||||
<!-- What did you do with this PR? How will it change things? -->
|
||||
|
||||
# Why
|
||||
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
|
||||
<!-- Why do you do it? What are your intentions? What is the problem? -->
|
||||
|
||||
# Additional info (optional)
|
||||
<!-- テスト観点など -->
|
||||
<!-- Test perspective, etc -->
|
40
packages/ffm-js/.github/workflows/api.yml
vendored
Normal file
40
packages/ffm-js/.github/workflows/api.yml
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
name: API report
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
report:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.5.0
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check files
|
||||
run: ls built
|
||||
|
||||
- name: API report
|
||||
run: npm run api-prod
|
||||
|
||||
- name: Show report
|
||||
if: always()
|
||||
run: cat temp/mfm-js.api.md
|
30
packages/ffm-js/.github/workflows/lint.yml
vendored
Normal file
30
packages/ffm-js/.github/workflows/lint.yml
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
name: Lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.5.0
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
45
packages/ffm-js/.github/workflows/test.yml
vendored
Normal file
45
packages/ffm-js/.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,45 @@
|
|||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Test and coverage
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.5.0]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v1
|
13
packages/ffm-js/.gitignore
vendored
Normal file
13
packages/ffm-js/.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
# npm
|
||||
node_modules
|
||||
|
||||
# editor
|
||||
.vscode
|
||||
|
||||
# app dir
|
||||
built
|
||||
temp
|
||||
|
||||
coverage
|
||||
|
||||
src/internal/parser.js
|
104
packages/ffm-js/CHANGELOG.md
Normal file
104
packages/ffm-js/CHANGELOG.md
Normal file
|
@ -0,0 +1,104 @@
|
|||
<!--
|
||||
## 0.x.x (unreleased)
|
||||
|
||||
### Features
|
||||
|
||||
### Improvements
|
||||
|
||||
### Changes
|
||||
|
||||
### Bugfixes
|
||||
|
||||
-->
|
||||
|
||||
## 0.23.3
|
||||
- tweak fn parsing
|
||||
- fnNameList option removed
|
||||
- emojiCodeList option removed
|
||||
|
||||
## 0.23.2
|
||||
### Features
|
||||
- Supports whitelisting of emoji code names. (#130)
|
||||
|
||||
## 0.23.1
|
||||
### Improvements
|
||||
- improve emoji code parsing
|
||||
|
||||
## 0.23.0
|
||||
|
||||
### Features
|
||||
- Add Plain syntax (#101)
|
||||
|
||||
### Improvements
|
||||
- The parser is now implemented in TypeScript! 🎉 (#92)
|
||||
- Disable all syntax when nesting limited (#90)
|
||||
|
||||
### Changes
|
||||
- Rename existing plain series (#113):
|
||||
- parsePlain -> parseSimple
|
||||
- MfmPlainNode -> MfmSimpleNode
|
||||
|
||||
### Bugfixes
|
||||
- Fix a bug that allows line breaks in link label (#115)
|
||||
|
||||
## 0.22.1
|
||||
|
||||
### Improvements
|
||||
- Removes a unnecessary built file
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Features
|
||||
- Unicode emoji supports Unicode 14.0 emoji (#109)
|
||||
|
||||
### Improvements
|
||||
- `()` pair is available on outside the hashtag (#111)
|
||||
- Changes specs the center tag and strike (#108, 100fb0b)
|
||||
- Improves link label parsing (#107)
|
||||
|
||||
### Bugfixes
|
||||
- If there is a `[]` pair before the link, it will be mistakenly recognized as a part of link label. (#104)
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Features
|
||||
- Supports nestLimit option. (#87, #91)
|
||||
|
||||
### Improvements
|
||||
- Improve generation of brackets property of url node.
|
||||
|
||||
### Bugfixes
|
||||
- Fix the Link node of the enclosed in `<>`. (#84)
|
||||
- Fix parsing of the link label.
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Features
|
||||
- Add tag syntaxes of bold `<b></b>` and strikethrough `<s></s>`. (#76)
|
||||
- Supports whitelisting of MFM function names. (#77)
|
||||
|
||||
### Improvements
|
||||
- Mentions in the link label are parsed as text. (#66)
|
||||
- Add a property to the URL node indicating whether it was enclosed in `<>`. (#69)
|
||||
- Disallows `<` and `>` in hashtags. (#74)
|
||||
- Improves security.
|
||||
|
||||
### Changes
|
||||
- Abolished MFM function v1 syntax. (#79)
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Improvements
|
||||
- Ignores a blank line after quote lines. (#61)
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Improvements
|
||||
- Twemoji v13.1 is supported.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Improvements
|
||||
- Improves syntax of inline code.
|
||||
- Improves syntax of url.
|
||||
- Improves syntax of hashtag.
|
128
packages/ffm-js/CODE_OF_CONDUCT.md
Normal file
128
packages/ffm-js/CODE_OF_CONDUCT.md
Normal file
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
syuilotan@yahoo.co.jp.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
90
packages/ffm-js/CONTRIBUTING.md
Normal file
90
packages/ffm-js/CONTRIBUTING.md
Normal file
|
@ -0,0 +1,90 @@
|
|||
# Contribution guide
|
||||
**[✨ English version available](/docs/CONTRIBUTING.en.md)**
|
||||
|
||||
プロジェクトに興味を持っていただきありがとうございます!
|
||||
このドキュメントでは、プロジェクトに貢献する際に必要な情報をまとめています。
|
||||
|
||||
## 実装をする前に
|
||||
機能追加やバグ修正をしたいときは、まずIssueなどで設計、方針をレビューしてもらいましょう(無い場合は作ってください)。このステップがないと、せっかく実装してもPRがマージされない可能性が高くなります。
|
||||
|
||||
また、実装に取り掛かるときは当該Issueに自分をアサインしてください(自分でできない場合は他メンバーに自分をアサインしてもらうようお願いしてください)。
|
||||
自分が実装するという意思表示をすることで、作業がバッティングするのを防ぎます。
|
||||
|
||||
## Issues
|
||||
Issueを作成する前に、以下をご確認ください:
|
||||
- 重複を防ぐため、既に同様の内容のIssueが作成されていないか検索してから新しいIssueを作ってください。
|
||||
- Issueを質問に使わないでください。
|
||||
- Issueは、要望、提案、問題の報告にのみ使用してください。
|
||||
- 質問は、[Misskey Forum](https://forum.misskey.io/)や[Discord](https://discord.gg/Wp8gVStHW3)でお願いします。
|
||||
|
||||
## PRの作成
|
||||
PRを作成する前に、以下をご確認ください:
|
||||
- 可能であればタイトルに、以下で示すようなPRの種類が分かるキーワードをプリフィクスしてください。
|
||||
- fix / refactor / feat / enhance / perf / chore 等
|
||||
- また、PRの粒度が適切であることを確認してください。ひとつのPRに複数の種類の変更や関心を含めることは避けてください。
|
||||
- このPRによって解決されるIssueがある場合は、そのIssueへの参照を本文内に含めてください。
|
||||
- [`CHANGELOG.md`](/CHANGELOG.md)に変更点を追記してください。リファクタリングなど、利用者に影響を与えない変更についてはこの限りではありません。
|
||||
- この変更により新たに作成、もしくは更新すべきドキュメントがないか確認してください。
|
||||
- 機能追加やバグ修正をした場合は、可能であればテストケースを追加してください。
|
||||
- テスト、Lintが通っていることを予め確認してください。
|
||||
- `npm run test`、`npm run lint`でぞれぞれ実施可能です
|
||||
- `npm run api`を実行してAPIレポートを更新し、差分がある場合はコミットしてください。
|
||||
- APIレポートの詳細については[こちら](#api-extractor)
|
||||
|
||||
ご協力ありがとうございます🤗
|
||||
|
||||
## Tools
|
||||
### eslint
|
||||
このプロジェクトではコードのフォーマットチェック/整形に[eslint](https://eslint.org/)を導入しています。
|
||||
CI上でも自動でチェックされ、ルールに則っていないコードがあるとエラーになります。
|
||||
|
||||
### Jest
|
||||
このプロジェクトではテストフレームワークとして[Jest](https://jestjs.io/)を導入しています。
|
||||
テストは[`/test`ディレクトリ](/test)に置かれます。
|
||||
|
||||
テストはCIにより各コミット/各PRに対して自動で実施されます。
|
||||
ローカル環境でテストを実施するには、`npm run test`を実行してください。
|
||||
|
||||
### tsd
|
||||
このプロジェクトでは型のテストを行うために[tsd](https://github.com/SamVerschueren/tsd)を導入しています。
|
||||
Jestによるテストでは「型が期待したものか」というのはチェックすることができません。tsdを使うことで、型が意図したものであることを担保することができます。
|
||||
tsdによる型テストは[`/test-d`ディレクトリ](/test-d)に置かれます。
|
||||
|
||||
テストはCIにより各コミット/各PRに対して自動で実施されます。
|
||||
ローカル環境でテストを実施するには、`npm run test`を実行してください。
|
||||
|
||||
### API Extractor
|
||||
このプロジェクトでは[API Extractor](https://api-extractor.com/)を導入しています。API ExtractorはAPIレポートを生成する役割を持ちます。
|
||||
APIレポートはいわばAPIのスナップショットで、このライブラリが外部に公開(export)している各種関数や型の定義が含まれています。`npm run api`コマンドを実行すると、その時点でのレポートが[`/etc`ディレクトリ](/etc)に生成されるようになっています。
|
||||
|
||||
exportしているAPIに変更があると、当然生成されるレポートの内容も変わるので、例えばdevelopブランチで生成されたレポートとPRのブランチで生成されたレポートを比較することで、意図しない破壊的変更の検出や、破壊的変更の影響確認に用いることができます。
|
||||
また、各コミットや各PRで実行されるCI内部では、都度APIレポートを生成して既存のレポートと差分が無いかチェックしています。もし差分があるとエラーになります。
|
||||
|
||||
PRを作る際は、`npm run api`コマンドを実行してAPIレポートを生成し、差分がある場合はコミットしてください。
|
||||
レポートをコミットすることでその破壊的変更が意図したものであると示すことができるほか、上述したようにレポート間の差分が出ることで影響範囲をレビューしやすくなります。
|
||||
|
||||
### Codecov
|
||||
このプロジェクトではカバレッジの計測に[Codecov](https://about.codecov.io/)を導入しています。カバレッジは、コードがどれくらいテストでカバーされているかを表すものです。
|
||||
|
||||
カバレッジ計測はCIで自動的に行われ、特に操作は必要ありません。カバレッジは[ここ](https://codecov.io/gh/misskey-dev/mfm.js)から見ることができます。
|
||||
|
||||
また、各PRに対してもそのブランチのカバレッジが自動的に計算され、マージ先のカバレッジとの差分を含んだレポートがCodecovのbotによりコメントされます。これにより、そのPRをマージすることでどれくらいカバレッジが増加するのか/減少するのかを確認することができます。
|
||||
|
||||
## レビュイーの心得
|
||||
[PRのセクション](#PRの作成)をご一読ください。
|
||||
また、後述の「レビュー観点」も意識してみてください。
|
||||
|
||||
## レビュワーの心得
|
||||
- 直して欲しい点だけでなく、良い点も積極的にコメントしましょう。
|
||||
- 貢献するモチベーションアップに繋がります。
|
||||
|
||||
### レビュー観点
|
||||
- セキュリティ
|
||||
- このPRをマージすることで、脆弱性を生まないか?
|
||||
- パフォーマンス
|
||||
- このPRをマージすることで、予期せずパフォーマンスが悪化しないか?
|
||||
- もっと効率的な方法は無いか?
|
||||
- テスト
|
||||
- 期待する振る舞いがテストで担保されているか?
|
||||
- 抜けやモレは無いか?
|
||||
- 異常系のチェックは出来ているか?
|
21
packages/ffm-js/LICENSE
Normal file
21
packages/ffm-js/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020-2022 Marihachi and other contributors
|
||||
|
||||
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.
|
71
packages/ffm-js/README.md
Normal file
71
packages/ffm-js/README.md
Normal file
|
@ -0,0 +1,71 @@
|
|||
# mfm.js
|
||||
An MFM parser implementation with TypeScript.
|
||||
[Try it out!](https://runkit.com/npm/mfm-js)
|
||||
|
||||
[![Test](https://github.com/misskey-dev/mfm.js/actions/workflows/test.yml/badge.svg)](https://github.com/misskey-dev/mfm.js/actions/workflows/test.yml)
|
||||
[![codecov](https://codecov.io/gh/misskey-dev/mfm.js/branch/develop/graph/badge.svg?token=irAWFiHK8T)](https://codecov.io/gh/misskey-dev/mfm.js)
|
||||
|
||||
[![NPM](https://nodei.co/npm/mfm-js.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/mfm-js)
|
||||
|
||||
## Installation
|
||||
```
|
||||
npm i mfm-js
|
||||
```
|
||||
|
||||
## Usage
|
||||
Please see [docs](./docs/index.md) for the detail.
|
||||
|
||||
TypeScript:
|
||||
```ts
|
||||
import * as mfm from 'mfm-js';
|
||||
|
||||
const inputText =
|
||||
`<center>
|
||||
Hello $[tada everynyan! 🎉]
|
||||
|
||||
I'm @ai, A bot of misskey!
|
||||
|
||||
https://github.com/syuilo/ai
|
||||
</center>`;
|
||||
|
||||
// Generate a MFM tree from the full MFM text.
|
||||
const mfmTree = mfm.parse(inputText);
|
||||
|
||||
// Generate a MFM tree from the simple MFM text.
|
||||
const simpleMfmTree = mfm.parseSimple('I like the hot soup :soup:');
|
||||
|
||||
// Reverse to a MFM text from the MFM tree.
|
||||
const text = mfm.toString(mfmTree);
|
||||
|
||||
```
|
||||
|
||||
## Develop
|
||||
### 1. Clone
|
||||
```
|
||||
git clone https://github.com/misskey-dev/mfm.js.git
|
||||
```
|
||||
|
||||
### 2. Install packages
|
||||
```
|
||||
cd mfm.js
|
||||
npm i
|
||||
```
|
||||
|
||||
### 3. Build
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Use the interactive CLI parser
|
||||
full parser:
|
||||
```
|
||||
npm run parse
|
||||
```
|
||||
|
||||
simple parser:
|
||||
```
|
||||
npm run parse-simple
|
||||
```
|
||||
|
||||
## License
|
||||
This software is released under the [MIT License](LICENSE).
|
364
packages/ffm-js/api-extractor.json
Normal file
364
packages/ffm-js/api-extractor.json
Normal file
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
|
||||
*/
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
/**
|
||||
* Optionally specifies another JSON config file that this file extends from. This provides a way for
|
||||
* standard settings to be shared across multiple projects.
|
||||
*
|
||||
* If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
|
||||
* the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
|
||||
* resolved using NodeJS require().
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "extends": "./shared/api-extractor-base.json"
|
||||
// "extends": "my-package/include/api-extractor-base.json"
|
||||
|
||||
/**
|
||||
* Determines the "<projectFolder>" token that can be used with other config file settings. The project folder
|
||||
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting.
|
||||
*
|
||||
* The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing
|
||||
* parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
|
||||
* that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
|
||||
* will be reported.
|
||||
*
|
||||
* SUPPORTED TOKENS: <lookup>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "projectFolder": "..",
|
||||
|
||||
/**
|
||||
* (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
|
||||
* analyzes the symbols exported by this module.
|
||||
*
|
||||
* The file extension must be ".d.ts" and not ".ts".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
*/
|
||||
"mainEntryPointFilePath": "<projectFolder>/built/index.d.ts",
|
||||
|
||||
/**
|
||||
* A list of NPM package names whose exports should be treated as part of this package.
|
||||
*
|
||||
* For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
|
||||
* and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
|
||||
* of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
|
||||
* imports library2. To avoid this, we can specify:
|
||||
*
|
||||
* "bundledPackages": [ "library2" ],
|
||||
*
|
||||
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
|
||||
* local files for library1.
|
||||
*/
|
||||
"bundledPackages": [],
|
||||
|
||||
/**
|
||||
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
|
||||
*/
|
||||
"compiler": {
|
||||
/**
|
||||
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* Note: This setting will be ignored if "overrideTsconfig" is used.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/tsconfig.json"
|
||||
*/
|
||||
// "tsconfigFilePath": "<projectFolder>/tsconfig.json",
|
||||
/**
|
||||
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
|
||||
* The object must conform to the TypeScript tsconfig schema:
|
||||
*
|
||||
* http://json.schemastore.org/tsconfig
|
||||
*
|
||||
* If omitted, then the tsconfig.json file will be read from the "projectFolder".
|
||||
*
|
||||
* DEFAULT VALUE: no overrideTsconfig section
|
||||
*/
|
||||
// "overrideTsconfig": {
|
||||
// . . .
|
||||
// }
|
||||
/**
|
||||
* This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
|
||||
* and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
|
||||
* dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
|
||||
* for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "skipLibCheck": true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the API report file (*.api.md) will be generated.
|
||||
*/
|
||||
"apiReport": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate an API report.
|
||||
*/
|
||||
"enabled": true
|
||||
|
||||
/**
|
||||
* The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
|
||||
* a full file path.
|
||||
*
|
||||
* The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
|
||||
*
|
||||
* SUPPORTED TOKENS: <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<unscopedPackageName>.api.md"
|
||||
*/
|
||||
// "reportFileName": "<unscopedPackageName>.api.md",
|
||||
|
||||
/**
|
||||
* Specifies the folder where the API report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
|
||||
* e.g. for an API review.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/etc/"
|
||||
*/
|
||||
// "reportFolder": "<projectFolder>/etc/",
|
||||
|
||||
/**
|
||||
* Specifies the folder where the temporary report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* After the temporary file is written to disk, it is compared with the file in the "reportFolder".
|
||||
* If they are different, a production build will fail.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
// "reportTempFolder": "<projectFolder>/temp/"
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the doc model file (*.api.json) will be generated.
|
||||
*/
|
||||
"docModel": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate a doc model file.
|
||||
*/
|
||||
"enabled": true
|
||||
|
||||
/**
|
||||
* The output path for the doc model file. The file extension should be ".api.json".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json"
|
||||
*/
|
||||
// "apiJsonFilePath": "<projectFolder>/temp/<unscopedPackageName>.api.json"
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the .d.ts rollup file will be generated.
|
||||
*/
|
||||
"dtsRollup": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate the .d.ts rollup file.
|
||||
*/
|
||||
"enabled": false
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
|
||||
* This file will include all declarations that are exported by the main entry point.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"
|
||||
*/
|
||||
// "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts",
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
|
||||
* This file will include only declarations that are marked as "@public" or "@beta".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
|
||||
* This file will include only declarations that are marked as "@public".
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts",
|
||||
|
||||
/**
|
||||
* When a declaration is trimmed, by default it will be replaced by a code comment such as
|
||||
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
|
||||
* declaration completely.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "omitTrimmingComments": true
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the tsdoc-metadata.json file will be generated.
|
||||
*/
|
||||
"tsdocMetadata": {
|
||||
/**
|
||||
* Whether to generate the tsdoc-metadata.json file.
|
||||
*
|
||||
* DEFAULT VALUE: true
|
||||
*/
|
||||
// "enabled": true,
|
||||
/**
|
||||
* Specifies where the TSDoc metadata file should be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata",
|
||||
* "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
|
||||
* falls back to "tsdoc-metadata.json" in the package folder.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json"
|
||||
},
|
||||
|
||||
/**
|
||||
* Specifies what type of newlines API Extractor should use when writing output files. By default, the output files
|
||||
* will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead.
|
||||
* To use the OS's default newline kind, specify "os".
|
||||
*
|
||||
* DEFAULT VALUE: "crlf"
|
||||
*/
|
||||
// "newlineKind": "crlf",
|
||||
|
||||
/**
|
||||
* Configures how API Extractor reports error and warning messages produced during analysis.
|
||||
*
|
||||
* There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
|
||||
*/
|
||||
"messages": {
|
||||
/**
|
||||
* Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
|
||||
* the input .d.ts files.
|
||||
*
|
||||
* TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"compilerMessageReporting": {
|
||||
/**
|
||||
* Configures the default routing for messages that don't match an explicit rule in this table.
|
||||
*/
|
||||
"default": {
|
||||
/**
|
||||
* Specifies whether the message should be written to the the tool's output log. Note that
|
||||
* the "addToApiReportFile" property may supersede this option.
|
||||
*
|
||||
* Possible values: "error", "warning", "none"
|
||||
*
|
||||
* Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
|
||||
* and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
|
||||
* the "--local" option), the warning is displayed but the build will not fail.
|
||||
*
|
||||
* DEFAULT VALUE: "warning"
|
||||
*/
|
||||
"logLevel": "warning"
|
||||
|
||||
/**
|
||||
* When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
|
||||
* then the message will be written inside that file; otherwise, the message is instead logged according to
|
||||
* the "logLevel" option.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
|
||||
// "TS2551": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures handling of messages reported by API Extractor during its analysis.
|
||||
*
|
||||
* API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
|
||||
*
|
||||
* DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
|
||||
*/
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "none"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
|
||||
// "ae-extra-release-tag": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
|
||||
*
|
||||
* TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
|
||||
// "tsdoc-link-tag-unescaped-text": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
}
|
||||
}
|
||||
}
|
2
packages/ffm-js/codecov.yml
Normal file
2
packages/ffm-js/codecov.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
codecov:
|
||||
token: fcfdc680-8bb5-4185-ad31-22b1e1e4c207
|
30
packages/ffm-js/docs/CONTRIBUTING.en.md
Normal file
30
packages/ffm-js/docs/CONTRIBUTING.en.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Contribution guide
|
||||
:v: Thanks for your contributions :v:
|
||||
|
||||
**ℹ️ Important:** This project uses Japanese as its major language, **but you do not need to translate and write the Issues/PRs in Japanese.**
|
||||
Also, you might receive comments on your Issue/PR in Japanese, but you do not need to reply to them in Japanese as well.\
|
||||
The accuracy of translation into Japanese is not high, so it will be easier for us to understand if you write it in the original language.
|
||||
It will also allow the reader to use the translation tool of their preference if necessary.
|
||||
|
||||
## Issues
|
||||
Before creating an issue, please check the following:
|
||||
- To avoid duplication, please search for similar issues before creating a new issue.
|
||||
- Do not use Issues as a question.
|
||||
- Issues should only be used to feature requests, suggestions, and report problems.
|
||||
- Please ask questions in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3).
|
||||
|
||||
## Creating a PR
|
||||
Thank you for your PR! Before creating a PR, please check the following:
|
||||
- If possible, prefix the title with a keyword that identifies the type of this PR, as shown below.
|
||||
- fix / refactor / feat / enhance / perf / chore etc.
|
||||
- Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR.
|
||||
- If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text.
|
||||
- Please add the summary of the changes to [`CHANGELOG.md`](/CHANGELOG.md). However, this is not necessary for changes that do not affect the users, such as refactoring.
|
||||
- Check if there are any documents that need to be created or updated due to this change.
|
||||
- If you have added a feature or fixed a bug, please add a test case if possible.
|
||||
- Please make sure that tests and Lint are passed in advance.
|
||||
- You can run it with `npm run test` and `npm run lint`.
|
||||
- Run `npm run api` to update the API report and commit it if there are any diffs.
|
||||
|
||||
Thanks for your cooperation 🤗
|
||||
|
65
packages/ffm-js/docs/api.md
Normal file
65
packages/ffm-js/docs/api.md
Normal file
|
@ -0,0 +1,65 @@
|
|||
## parse API
|
||||
入力文字列からノードツリーを生成します。
|
||||
全てのMFM構文を利用可能です。
|
||||
|
||||
例:
|
||||
```ts
|
||||
const nodes = mfm.parse('hello $[tada world]');
|
||||
console.log(JSON.stringify(nodes));
|
||||
// => [{"type":"text","props":{"text":"hello "}},{"type":"fn","props":{"name":"tada","args":{}},"children":[{"type":"text","props":{"text":"world"}}]}]
|
||||
```
|
||||
|
||||
### 最大のネストの深さを変更する
|
||||
デフォルトで20に設定されています。
|
||||
|
||||
例:
|
||||
```ts
|
||||
const nodes = mfm.parse('**<s>cannot nest</s>**', { nestLimit: 1 });
|
||||
console.log(JSON.stringify(nodes));
|
||||
// => [{"type":"bold","children":[{"type":"text","props":{"text":"<s>cannot nest</s>"}}]}]
|
||||
```
|
||||
|
||||
## parseSimple API
|
||||
入力文字列からノードツリーを生成します。
|
||||
絵文字コードとUnicode絵文字を利用可能です。
|
||||
|
||||
例:
|
||||
```ts
|
||||
const nodes = mfm.parseSimple('Hello :surprised_ai:');
|
||||
console.log(JSON.stringify(nodes));
|
||||
// => [{"type":"text","props":{"text":"Hello "}},{"type":"emojiCode","props":{"name":"surprised_ai"}}]
|
||||
```
|
||||
|
||||
## toString API
|
||||
ノードツリーからMFM文字列を生成します。
|
||||
|
||||
例:
|
||||
```ts
|
||||
const nodes = mfm.parse('hello $[tada world]');
|
||||
const output = mfm.toString(nodes);
|
||||
console.log(output); // => "hello $[tada world]"
|
||||
```
|
||||
※元の文字列とtoString APIで出力される文字列の同一性は保障されません。
|
||||
|
||||
## inspect API
|
||||
ノードツリーの全ノードに指定された関数を適用します。
|
||||
|
||||
例:
|
||||
```ts
|
||||
mfm.inspect(nodes, node => {
|
||||
if (node.type == 'text') {
|
||||
node.props.text = node.props.text.replace(/Good morning/g, 'Hello');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## extract API
|
||||
ブール値を返す関数を渡してノードを抽出します。
|
||||
このAPIはノードツリーを再帰的に探索します。
|
||||
|
||||
例:
|
||||
```ts
|
||||
mfm.extract(nodes, (node) => {
|
||||
return (node.type === 'emojiCode');
|
||||
});
|
||||
```
|
7
packages/ffm-js/docs/index.md
Normal file
7
packages/ffm-js/docs/index.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
## 目次
|
||||
|
||||
### [API](api.md)
|
||||
ライブラリが提供している関数や型定義などについての説明です。
|
||||
|
||||
### [MFM構文とノード構造の仕様](syntax.md)
|
||||
サポートしているMFM構文やノード構造についての説明です。
|
672
packages/ffm-js/docs/syntax.md
Normal file
672
packages/ffm-js/docs/syntax.md
Normal file
|
@ -0,0 +1,672 @@
|
|||
<h1>目次</h2>
|
||||
|
||||
ブロック構文:
|
||||
- [引用ブロック](#quote)
|
||||
- [検索ブロック](#search)
|
||||
- [コードブロック](#code-block)
|
||||
- [数式ブロック](#math-block)
|
||||
- [中央寄せブロック](#center)
|
||||
|
||||
インライン構文:
|
||||
- [揺れる字](#big)
|
||||
- [太字](#bold)
|
||||
- [目立たない字](#small)
|
||||
- [イタリック](#italic)
|
||||
- [打ち消し線](#strike)
|
||||
- [インラインコード](#inline-code)
|
||||
- [インライン数式](#math-inline)
|
||||
- [メンション](#mention)
|
||||
- [ハッシュタグ](#hashtag)
|
||||
- [URL](#url)
|
||||
- [リンク](#link)
|
||||
- [絵文字コード(カスタム絵文字)](#emoji-code)
|
||||
- [MFM関数](#fn)
|
||||
- [Unicode絵文字](#unicode-emoji)
|
||||
- [テキスト](#text)
|
||||
|
||||
|
||||
|
||||
<h1 id="quote">Block: 引用ブロック</h1>
|
||||
|
||||
## 形式
|
||||
```
|
||||
> abc
|
||||
>abc
|
||||
>>nest
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 引用された内容には再度FullParserを適用する。
|
||||
- `>`の後に続く0~1文字のスペースを無視する。
|
||||
- 隣接する引用の行は一つになる。
|
||||
- 複数行の引用では空行も含めることができる。
|
||||
- 引用の後ろにある空行は無視される。([#61](https://github.com/misskey-dev/mfm.js/issues/61))
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'quote',
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'abc' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="search">Block: 検索ブロック</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
MFM 書き方 Search
|
||||
MFM 書き方 検索
|
||||
MFM 書き方 [Search]
|
||||
MFM 書き方 [検索]
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- Searchの大文字小文字は区別されない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'search',
|
||||
props: {
|
||||
query: 'MFM 書き方',
|
||||
content: 'MFM 書き方 Search'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="code-block">Block: コードブロック</h2>
|
||||
|
||||
## 形式
|
||||
<pre>
|
||||
```
|
||||
a
|
||||
|
||||
b```
|
||||
```c
|
||||
````
|
||||
```
|
||||
</pre>
|
||||
|
||||
<pre>
|
||||
```js
|
||||
abc
|
||||
````
|
||||
</pre>
|
||||
|
||||
## 詳細
|
||||
- langは指定されない場合はnullになる。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'blockCode',
|
||||
props: {
|
||||
code: 'abc',
|
||||
lang: 'js'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="math-block">Block: 数式ブロック</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
\[a = 1\]
|
||||
```
|
||||
|
||||
```
|
||||
\[
|
||||
a = 2
|
||||
\]
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- `\[`は行頭でなければならない。
|
||||
- `\]`は行末でなければならない。
|
||||
- 前後のスペースと改行はトリミングされる。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'mathBlock',
|
||||
props: {
|
||||
formula: 'a = 1'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="center">Block: 中央寄せブロック</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
<center>abc</center>
|
||||
```
|
||||
```
|
||||
<center>
|
||||
abc
|
||||
</center>
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- `<center>`は行頭でなければならない。
|
||||
- `</center>`は行末でなければならない。
|
||||
- 内容には再度InlineParserを適用する。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'center',
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'abc' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="big">Inline: 揺れる字</h2>
|
||||
|
||||
**廃止予定の構文。代替の構文が用意されています。**
|
||||
## 形式
|
||||
```
|
||||
***big!***
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容には再度InlineParserを適用する。
|
||||
- 内容にはすべての文字、改行が使用できる。
|
||||
- 内容を空にすることはできない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'fn',
|
||||
props: {
|
||||
name: 'tada',
|
||||
args: { }
|
||||
},
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'big!' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="bold">Inline: 太字</h2>
|
||||
|
||||
## 形式
|
||||
構文1:
|
||||
```
|
||||
**bold**
|
||||
```
|
||||
|
||||
構文2:
|
||||
```
|
||||
__bold__
|
||||
```
|
||||
|
||||
構文3:
|
||||
```
|
||||
<b>bold</b>
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容には再度InlineParserを適用する。
|
||||
- 内容を空にすることはできない。
|
||||
|
||||
構文1,3のみ:
|
||||
- 内容にはすべての文字、改行が使用できる。
|
||||
|
||||
構文2のみ:
|
||||
- 内容には`[a-z0-9 \t]i`にマッチする文字が使用できる。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'bold',
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'bold' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="small">Inline: 目立たない字</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
<small>small</small>
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容には再度InlineParserを適用する。
|
||||
- 内容を空にすることはできない。
|
||||
- 内容にはすべての文字、改行が使用できる。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'small',
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'small' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="italic">Inline: イタリック</h2>
|
||||
|
||||
## 形式
|
||||
構文1:
|
||||
```
|
||||
<i>italic</i>
|
||||
```
|
||||
|
||||
構文2:
|
||||
```
|
||||
*italic*
|
||||
```
|
||||
|
||||
構文3:
|
||||
```
|
||||
_italic_
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容には再度InlineParserを適用する。
|
||||
- 内容を空にすることはできない。
|
||||
|
||||
構文1のみ:
|
||||
- 内容にはすべての文字、改行が使用できる。
|
||||
|
||||
構文2,3のみ:
|
||||
※1つ目の`*`と`_`を開始記号と呼ぶ。
|
||||
- 内容には`[a-z0-9 \t]i`にマッチする文字が使用できる。
|
||||
- 開始記号の前の文字が`[a-z0-9]i`に一致しない時にイタリック文字として判定される。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'italic',
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'italic' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="strike">Inline: 打ち消し線</h2>
|
||||
|
||||
## 形式
|
||||
構文1:
|
||||
```
|
||||
~~strike~~
|
||||
```
|
||||
|
||||
構文2:
|
||||
```
|
||||
<s>strike</s>
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容には再度InlineParserを適用する。
|
||||
- 内容を空にすることはできない。
|
||||
|
||||
構文1のみ:
|
||||
- 内容には`~`、改行以外の文字を使用できる。
|
||||
|
||||
構文2のみ:
|
||||
- 内容にはすべての文字、改行が使用できる。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'strike',
|
||||
children: [
|
||||
{ type: 'text', props: { text: 'strike' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="inline-code">Inline: インラインコード</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
`$abc <- 1`
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容を空にすることはできない。
|
||||
- 内容には改行を含めることができない。
|
||||
- 内容には「´」を含めることができない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'inlineCode',
|
||||
props: {
|
||||
code: '$abc <- 1'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="math-inline">Inline: インライン数式</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
\(y = 2x\)
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容を空にすることはできない。
|
||||
- 内容には改行を含めることができない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'mathInline',
|
||||
props: {
|
||||
formula: 'y = 2x'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="mention">Inline: メンション</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
@user@misskey.io
|
||||
```
|
||||
```
|
||||
@user
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 最初の`@`の前の文字が`[a-z0-9]i`に一致しない場合にメンションとして認識する。
|
||||
|
||||
### ユーザ名
|
||||
- 1文字以上。
|
||||
- `A`~`Z` `0`~`9` `_` `-`が含められる。
|
||||
- 1文字目と最後の文字は`-`にできない。
|
||||
|
||||
### ホスト名
|
||||
- 1文字以上。
|
||||
- `A`~`Z` `0`~`9` `_` `-` `.`が含められる。
|
||||
- 1文字目と最後の文字は`-` `.`にできない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'mention',
|
||||
props: {
|
||||
username: 'user',
|
||||
host: 'misskey.io',
|
||||
acct: '@user@misskey.io'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
{
|
||||
type: 'mention',
|
||||
props: {
|
||||
username: 'user',
|
||||
host: null,
|
||||
acct: '@user'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="hashtag">Inline: ハッシュタグ</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
#abc
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容を空にすることはできない。
|
||||
- 内容には半角スペース、全角スペース、改行、タブ文字を含めることができない。
|
||||
- 内容には`.` `,` `!` `?` `'` `"` `#` `:` `/` `【` `】` `<` `>` `【` `】` `(` `)` `「` `」` `(` `)` を含めることができない。
|
||||
- 括弧は対になっている時のみ内容に含めることができる。対象: `()` `[]` `「」` `()`
|
||||
- `#`の前の文字が`[a-z0-9]i`に一致しない場合にハッシュタグとして認識する。
|
||||
- 内容が数字のみの場合はハッシュタグとして認識しない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'hashtag',
|
||||
props: {
|
||||
hashtag: 'abc'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="url">Inline: URL</h2>
|
||||
|
||||
## 形式
|
||||
構文1:
|
||||
```
|
||||
https://misskey.io/@ai
|
||||
```
|
||||
|
||||
```
|
||||
http://hoge.jp/abc
|
||||
```
|
||||
|
||||
構文2:
|
||||
```
|
||||
<https://misskey.io/@ai>
|
||||
```
|
||||
|
||||
```
|
||||
<http://藍.jp/abc>
|
||||
```
|
||||
|
||||
## 詳細
|
||||
構文1のみ:
|
||||
- 内容には`[.,a-z0-9_/:%#@$&?!~=+-]i`にマッチする文字を使用できる。
|
||||
- 内容には対になっている括弧を使用できる。対象: `( )` `[ ]`
|
||||
- `.`や`,`は最後の文字にできない。
|
||||
|
||||
構文2のみ:
|
||||
- 内容には改行、スペース以外の文字を使用できる。
|
||||
|
||||
## ノード
|
||||
構文1:
|
||||
```js
|
||||
{
|
||||
type: 'url',
|
||||
props: {
|
||||
url: 'https://misskey.io/@ai'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
または
|
||||
|
||||
```js
|
||||
{
|
||||
type: 'url',
|
||||
props: {
|
||||
url: 'https://misskey.io/@ai',
|
||||
brackets: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
構文2:
|
||||
```js
|
||||
{
|
||||
type: 'url',
|
||||
props: {
|
||||
url: 'https://misskey.io/@ai',
|
||||
brackets: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="link">Inline: リンク</h2>
|
||||
|
||||
## 形式
|
||||
silent=false
|
||||
```
|
||||
[Misskey.io](https://misskey.io/)
|
||||
```
|
||||
|
||||
silent=true
|
||||
```
|
||||
?[Misskey.io](https://misskey.io/)
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 表示テキストには再度InlineParserを適用する。ただし、表示テキストではURL、リンク、メンションは使用できない。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
[
|
||||
{
|
||||
type: 'link',
|
||||
props: {
|
||||
silent: false,
|
||||
url: 'https://misskey.io/'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
props: {
|
||||
text: 'Misskey.io'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="emoji-code">Inline: 絵文字コード(カスタム絵文字)</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
:thinking_ai:
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容を空にすることはできない。
|
||||
- 内容には[a-z0-9_+-]iにマッチする文字を使用できる。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'emojiCode',
|
||||
props: {
|
||||
name: 'thinking_ai'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="fn">Inline: 関数</h2>
|
||||
|
||||
## 形式
|
||||
構文1:
|
||||
```
|
||||
$[shake 🍮]
|
||||
```
|
||||
|
||||
```
|
||||
$[spin.alternate 🍮]
|
||||
```
|
||||
|
||||
```
|
||||
$[shake.speed=1s 🍮]
|
||||
```
|
||||
|
||||
```
|
||||
$[flip.h,v MisskeyでFediverseの世界が広がります]
|
||||
```
|
||||
|
||||
## 詳細
|
||||
- 内容には再度InlineParserを適用する。
|
||||
- 内容を空にすることはできない。
|
||||
- 内容には改行も含めることが可能です。
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'fn',
|
||||
props: {
|
||||
name: 'shake',
|
||||
args: { }
|
||||
},
|
||||
children: [
|
||||
{ type: 'unicodeEmoji', props: { emoji: '👍' } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="unicode-emoji">Inline: Unicode絵文字</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
😇
|
||||
```
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'unicodeEmoji',
|
||||
props: {
|
||||
emoji: '😇'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
<h1 id="text">Inline: テキスト</h2>
|
||||
|
||||
## 形式
|
||||
```
|
||||
abc
|
||||
```
|
||||
|
||||
## ノード
|
||||
```js
|
||||
{
|
||||
type: 'text',
|
||||
props:
|
||||
text: 'abc'
|
||||
}
|
||||
}
|
||||
```
|
281
packages/ffm-js/etc/mfm-js.api.md
Normal file
281
packages/ffm-js/etc/mfm-js.api.md
Normal file
|
@ -0,0 +1,281 @@
|
|||
## API Report File for "mfm-js"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
// @public (undocumented)
|
||||
export const BOLD: (children: MfmInline[]) => NodeType<'bold'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const CENTER: (children: MfmInline[]) => NodeType<'center'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const CODE_BLOCK: (code: string, lang: string | null) => NodeType<'blockCode'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EMOJI_CODE: (name: string) => NodeType<'emojiCode'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function extract(nodes: MfmNode[], predicate: (node: MfmNode) => boolean): MfmNode[];
|
||||
|
||||
// @public (undocumented)
|
||||
export const FN: (name: string, args: MfmFn['props']['args'], children: MfmFn['children']) => NodeType<'fn'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const HASHTAG: (value: string) => NodeType<'hashtag'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const INLINE_CODE: (code: string) => NodeType<'inlineCode'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function inspect(node: MfmNode, action: (node: MfmNode) => void): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export function inspect(nodes: MfmNode[], action: (node: MfmNode) => void): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export const ITALIC: (children: MfmInline[]) => NodeType<'italic'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const LINK: (silent: boolean, url: string, children: MfmInline[]) => NodeType<'link'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const MATH_BLOCK: (formula: string) => NodeType<'mathBlock'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const MATH_INLINE: (formula: string) => NodeType<'mathInline'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const MENTION: (username: string, host: string | null, acct: string) => NodeType<'mention'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmBlock = MfmQuote | MfmSearch | MfmCodeBlock | MfmMathBlock | MfmCenter;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmBold = {
|
||||
type: 'bold';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmCenter = {
|
||||
type: 'center';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmCodeBlock = {
|
||||
type: 'blockCode';
|
||||
props: {
|
||||
code: string;
|
||||
lang: string | null;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmEmojiCode = {
|
||||
type: 'emojiCode';
|
||||
props: {
|
||||
name: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmFn = {
|
||||
type: 'fn';
|
||||
props: {
|
||||
name: string;
|
||||
args: Record<string, string | true>;
|
||||
};
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmHashtag = {
|
||||
type: 'hashtag';
|
||||
props: {
|
||||
hashtag: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmInline = MfmUnicodeEmoji | MfmEmojiCode | MfmBold | MfmSmall | MfmItalic | MfmStrike | MfmInlineCode | MfmMathInline | MfmMention | MfmHashtag | MfmUrl | MfmLink | MfmFn | MfmPlain | MfmText;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmInlineCode = {
|
||||
type: 'inlineCode';
|
||||
props: {
|
||||
code: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmItalic = {
|
||||
type: 'italic';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmLink = {
|
||||
type: 'link';
|
||||
props: {
|
||||
silent: boolean;
|
||||
url: string;
|
||||
};
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmMathBlock = {
|
||||
type: 'mathBlock';
|
||||
props: {
|
||||
formula: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmMathInline = {
|
||||
type: 'mathInline';
|
||||
props: {
|
||||
formula: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmMention = {
|
||||
type: 'mention';
|
||||
props: {
|
||||
username: string;
|
||||
host: string | null;
|
||||
acct: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmNode = MfmBlock | MfmInline;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmPlain = {
|
||||
type: 'plain';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmText[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmQuote = {
|
||||
type: 'quote';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmNode[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmSearch = {
|
||||
type: 'search';
|
||||
props: {
|
||||
query: string;
|
||||
content: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmSimpleNode = MfmUnicodeEmoji | MfmEmojiCode | MfmText;
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmSmall = {
|
||||
type: 'small';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmStrike = {
|
||||
type: 'strike';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmText = {
|
||||
type: 'text';
|
||||
props: {
|
||||
text: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmUnicodeEmoji = {
|
||||
type: 'unicodeEmoji';
|
||||
props: {
|
||||
emoji: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type MfmUrl = {
|
||||
type: 'url';
|
||||
props: {
|
||||
url: string;
|
||||
brackets?: boolean;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const N_URL: (value: string, brackets?: boolean) => NodeType<'url'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type NodeType<T extends MfmNode['type']> = T extends 'quote' ? MfmQuote : T extends 'search' ? MfmSearch : T extends 'blockCode' ? MfmCodeBlock : T extends 'mathBlock' ? MfmMathBlock : T extends 'center' ? MfmCenter : T extends 'unicodeEmoji' ? MfmUnicodeEmoji : T extends 'emojiCode' ? MfmEmojiCode : T extends 'bold' ? MfmBold : T extends 'small' ? MfmSmall : T extends 'italic' ? MfmItalic : T extends 'strike' ? MfmStrike : T extends 'inlineCode' ? MfmInlineCode : T extends 'mathInline' ? MfmMathInline : T extends 'mention' ? MfmMention : T extends 'hashtag' ? MfmHashtag : T extends 'url' ? MfmUrl : T extends 'link' ? MfmLink : T extends 'fn' ? MfmFn : T extends 'plain' ? MfmPlain : T extends 'text' ? MfmText : never;
|
||||
|
||||
// @public (undocumented)
|
||||
export function parse(input: string, opts?: Partial<{
|
||||
nestLimit: number;
|
||||
}>): MfmNode[];
|
||||
|
||||
// @public (undocumented)
|
||||
export function parseSimple(input: string): MfmSimpleNode[];
|
||||
|
||||
// @public (undocumented)
|
||||
export const PLAIN: (text: string) => NodeType<'plain'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const QUOTE: (children: MfmNode[]) => NodeType<'quote'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const SEARCH: (query: string, content: string) => NodeType<'search'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const SMALL: (children: MfmInline[]) => NodeType<'small'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const STRIKE: (children: MfmInline[]) => NodeType<'strike'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const TEXT: (value: string) => NodeType<'text'>;
|
||||
|
||||
// @public (undocumented)
|
||||
function toString_2(tree: MfmNode[]): string;
|
||||
|
||||
// @public (undocumented)
|
||||
function toString_2(node: MfmNode): string;
|
||||
export { toString_2 as toString }
|
||||
|
||||
// @public (undocumented)
|
||||
export const UNI_EMOJI: (value: string) => NodeType<'unicodeEmoji'>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
197
packages/ffm-js/jest.config.ts
Normal file
197
packages/ffm-js/jest.config.ts
Normal file
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
export default {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/cli/**/*.ts'],
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: [
|
||||
"<rootDir>"
|
||||
],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"**/?(*.)+(spec|test).[tj]s?(x)",
|
||||
"<rootDir>/test/**/*"
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\",
|
||||
// "\\.pnp\\.[^\\\\]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
9544
packages/ffm-js/package-lock.json
generated
Normal file
9544
packages/ffm-js/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
packages/ffm-js/package.json
Normal file
45
packages/ffm-js/package.json
Normal file
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "mfm-js",
|
||||
"version": "0.23.3",
|
||||
"description": "An MFM parser implementation with TypeScript",
|
||||
"main": "./built/index.js",
|
||||
"types": "./built/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "npm run tsc",
|
||||
"tsc": "tsc",
|
||||
"tsd": "tsd",
|
||||
"parse": "node ./built/cli/parse",
|
||||
"parse-simple": "node ./built/cli/parseSimple",
|
||||
"api": "npx api-extractor run --local --verbose",
|
||||
"api-prod": "npx api-extractor run --verbose",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"jest": "jest --coverage",
|
||||
"test": "npm run jest && npm run tsd"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/misskey-dev/mfm.js.git"
|
||||
},
|
||||
"author": "Marihachi",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "^7.28.4",
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/node": "18.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.5",
|
||||
"@typescript-eslint/parser": "^5.30.5",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^28.1.2",
|
||||
"ts-jest": "^28.0.5",
|
||||
"ts-node": "10.8.2",
|
||||
"tsd": "^0.22.0",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"twemoji-parser": "14.0.0"
|
||||
},
|
||||
"files": [
|
||||
"built",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
4
packages/ffm-js/src/@types/twemoji.d.ts
vendored
Normal file
4
packages/ffm-js/src/@types/twemoji.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
declare module 'twemoji-parser/dist/lib/regex' {
|
||||
const regex: RegExp;
|
||||
export default regex;
|
||||
}
|
67
packages/ffm-js/src/api.ts
Normal file
67
packages/ffm-js/src/api.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { fullParser, simpleParser } from './internal';
|
||||
import { inspectOne, stringifyNode, stringifyTree } from './internal/util';
|
||||
import { MfmNode, MfmSimpleNode } from './node';
|
||||
|
||||
/**
|
||||
* Generates a MfmNode tree from the MFM string.
|
||||
*/
|
||||
export function parse(input: string, opts: Partial<{ nestLimit: number; }> = {}): MfmNode[] {
|
||||
const nodes = fullParser(input, {
|
||||
nestLimit: opts.nestLimit,
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a MfmSimpleNode tree from the MFM string.
|
||||
*/
|
||||
export function parseSimple(input: string): MfmSimpleNode[] {
|
||||
const nodes = simpleParser(input);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a MFM string from the MfmNode tree.
|
||||
*/
|
||||
export function toString(tree: MfmNode[]): string
|
||||
export function toString(node: MfmNode): string
|
||||
export function toString(node: MfmNode | MfmNode[]): string {
|
||||
if (Array.isArray(node)) {
|
||||
return stringifyTree(node);
|
||||
}
|
||||
else {
|
||||
return stringifyNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects the MfmNode tree.
|
||||
*/
|
||||
export function inspect(node: MfmNode, action: (node: MfmNode) => void): void
|
||||
export function inspect(nodes: MfmNode[], action: (node: MfmNode) => void): void
|
||||
export function inspect(node: (MfmNode | MfmNode[]), action: (node: MfmNode) => void): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const n of node) {
|
||||
inspectOne(n, action);
|
||||
}
|
||||
}
|
||||
else {
|
||||
inspectOne(node, action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects the MfmNode tree and returns as an array the nodes that match the conditions
|
||||
* of the predicate function.
|
||||
*/
|
||||
export function extract(nodes: MfmNode[], predicate: (node: MfmNode) => boolean): MfmNode[] {
|
||||
const dest = [] as MfmNode[];
|
||||
|
||||
inspect(nodes, (node) => {
|
||||
if (predicate(node)) {
|
||||
dest.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return dest;
|
||||
}
|
22
packages/ffm-js/src/cli/misc/inputLine.ts
Normal file
22
packages/ffm-js/src/cli/misc/inputLine.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import readLine from 'readline';
|
||||
|
||||
export class InputCanceledError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export default function(message: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const rl = readLine.createInterface(process.stdin, process.stdout);
|
||||
rl.question(message, (ans) => {
|
||||
rl.close();
|
||||
resolve(ans);
|
||||
});
|
||||
rl.on('SIGINT', () => {
|
||||
console.log('');
|
||||
rl.close();
|
||||
reject(new InputCanceledError('SIGINT interrupted'));
|
||||
});
|
||||
});
|
||||
}
|
47
packages/ffm-js/src/cli/parse.ts
Normal file
47
packages/ffm-js/src/cli/parse.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { performance } from 'perf_hooks';
|
||||
import inputLine, { InputCanceledError } from './misc/inputLine';
|
||||
import { parse } from '..';
|
||||
|
||||
async function entryPoint() {
|
||||
console.log('intaractive parser');
|
||||
|
||||
while (true) {
|
||||
let input: string;
|
||||
try {
|
||||
input = await inputLine('> ');
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof InputCanceledError) {
|
||||
console.log('bye.');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// replace special chars
|
||||
input = input
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\u00a0/g, '\u00a0');
|
||||
|
||||
try {
|
||||
const parseTimeStart = performance.now();
|
||||
const result = parse(input);
|
||||
const parseTimeEnd = performance.now();
|
||||
console.log(JSON.stringify(result));
|
||||
const parseTime = (parseTimeEnd - parseTimeStart).toFixed(3);
|
||||
console.log(`parsing time: ${parseTime}ms`);
|
||||
}
|
||||
catch (err) {
|
||||
console.log('parsing error:');
|
||||
console.log(err);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
entryPoint()
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
process.exit(1);
|
||||
});
|
47
packages/ffm-js/src/cli/parseSimple.ts
Normal file
47
packages/ffm-js/src/cli/parseSimple.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { performance } from 'perf_hooks';
|
||||
import inputLine, { InputCanceledError } from './misc/inputLine';
|
||||
import { parseSimple } from '..';
|
||||
|
||||
async function entryPoint() {
|
||||
console.log('intaractive simple parser');
|
||||
|
||||
while (true) {
|
||||
let input: string;
|
||||
try {
|
||||
input = await inputLine('> ');
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof InputCanceledError) {
|
||||
console.log('bye.');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// replace special chars
|
||||
input = input
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\u00a0/g, '\u00a0');
|
||||
|
||||
try {
|
||||
const parseTimeStart = performance.now();
|
||||
const result = parseSimple(input);
|
||||
const parseTimeEnd = performance.now();
|
||||
console.log(JSON.stringify(result));
|
||||
const parseTime = (parseTimeEnd - parseTimeStart).toFixed(3);
|
||||
console.log(`parsing time: ${parseTime}ms`);
|
||||
}
|
||||
catch (err) {
|
||||
console.log('parsing error:');
|
||||
console.log(err);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
entryPoint()
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
process.exit(1);
|
||||
});
|
67
packages/ffm-js/src/index.ts
Normal file
67
packages/ffm-js/src/index.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
export {
|
||||
parse,
|
||||
parseSimple,
|
||||
toString,
|
||||
inspect,
|
||||
extract,
|
||||
} from './api';
|
||||
|
||||
export {
|
||||
NodeType,
|
||||
MfmNode,
|
||||
MfmSimpleNode,
|
||||
MfmBlock,
|
||||
MfmInline,
|
||||
} from './node';
|
||||
|
||||
export {
|
||||
// block
|
||||
MfmQuote,
|
||||
MfmSearch,
|
||||
MfmCodeBlock,
|
||||
MfmMathBlock,
|
||||
MfmCenter,
|
||||
|
||||
// inline
|
||||
MfmUnicodeEmoji,
|
||||
MfmEmojiCode,
|
||||
MfmBold,
|
||||
MfmSmall,
|
||||
MfmItalic,
|
||||
MfmStrike,
|
||||
MfmInlineCode,
|
||||
MfmMathInline,
|
||||
MfmMention,
|
||||
MfmHashtag,
|
||||
MfmUrl,
|
||||
MfmLink,
|
||||
MfmFn,
|
||||
MfmPlain,
|
||||
MfmText,
|
||||
} from './node';
|
||||
|
||||
export {
|
||||
// block
|
||||
QUOTE,
|
||||
SEARCH,
|
||||
CODE_BLOCK,
|
||||
MATH_BLOCK,
|
||||
CENTER,
|
||||
|
||||
// inline
|
||||
UNI_EMOJI,
|
||||
EMOJI_CODE,
|
||||
BOLD,
|
||||
SMALL,
|
||||
ITALIC,
|
||||
STRIKE,
|
||||
INLINE_CODE,
|
||||
MATH_INLINE,
|
||||
MENTION,
|
||||
HASHTAG,
|
||||
N_URL,
|
||||
LINK,
|
||||
FN,
|
||||
PLAIN,
|
||||
TEXT,
|
||||
} from './node';
|
249
packages/ffm-js/src/internal/core/index.ts
Normal file
249
packages/ffm-js/src/internal/core/index.ts
Normal file
|
@ -0,0 +1,249 @@
|
|||
//
|
||||
// Parsimmon-like stateful parser combinators
|
||||
//
|
||||
|
||||
export type Success<T> = {
|
||||
success: true;
|
||||
value: T;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type Failure = { success: false };
|
||||
|
||||
export type Result<T> = Success<T> | Failure;
|
||||
|
||||
export type ParserHandler<T> = (input: string, index: number, state: any) => Result<T>
|
||||
|
||||
export function success<T>(index: number, value: T): Success<T> {
|
||||
return {
|
||||
success: true,
|
||||
value: value,
|
||||
index: index,
|
||||
};
|
||||
}
|
||||
|
||||
export function failure(): Failure {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
export class Parser<T> {
|
||||
public name?: string;
|
||||
public handler: ParserHandler<T>;
|
||||
|
||||
constructor(handler: ParserHandler<T>, name?: string) {
|
||||
this.handler = (input, index, state) => {
|
||||
if (state.trace && this.name != null) {
|
||||
const pos = `${index}`;
|
||||
console.log(`${pos.padEnd(6, ' ')}enter ${this.name}`);
|
||||
const result = handler(input, index, state);
|
||||
if (result.success) {
|
||||
const pos = `${index}:${result.index}`;
|
||||
console.log(`${pos.padEnd(6, ' ')}match ${this.name}`);
|
||||
} else {
|
||||
const pos = `${index}`;
|
||||
console.log(`${pos.padEnd(6, ' ')}fail ${this.name}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return handler(input, index, state);
|
||||
};
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
map<U>(fn: (value: T) => U): Parser<U> {
|
||||
return new Parser((input, index, state) => {
|
||||
const result = this.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
return success(result.index, fn(result.value));
|
||||
});
|
||||
}
|
||||
|
||||
text(): Parser<string> {
|
||||
return new Parser((input, index, state) => {
|
||||
const result = this.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
const text = input.slice(index, result.index);
|
||||
return success(result.index, text);
|
||||
});
|
||||
}
|
||||
|
||||
many(min: number): Parser<T[]> {
|
||||
return new Parser((input, index, state) => {
|
||||
let result;
|
||||
let latestIndex = index;
|
||||
const accum: T[] = [];
|
||||
while (latestIndex < input.length) {
|
||||
result = this.handler(input, latestIndex, state);
|
||||
if (!result.success) {
|
||||
break;
|
||||
}
|
||||
latestIndex = result.index;
|
||||
accum.push(result.value);
|
||||
}
|
||||
if (accum.length < min) {
|
||||
return failure();
|
||||
}
|
||||
return success(latestIndex, accum);
|
||||
});
|
||||
}
|
||||
|
||||
sep(separator: Parser<any>, min: number): Parser<T[]> {
|
||||
if (min < 1) {
|
||||
throw new Error('"min" must be a value greater than or equal to 1.');
|
||||
}
|
||||
return seq([
|
||||
this,
|
||||
seq([
|
||||
separator,
|
||||
this,
|
||||
], 1).many(min - 1),
|
||||
]).map(result => [result[0], ...result[1]]);
|
||||
}
|
||||
|
||||
option<T>(): Parser<T | null> {
|
||||
return alt([
|
||||
this,
|
||||
succeeded(null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export function str<T extends string>(value: T): Parser<T> {
|
||||
return new Parser((input, index, _state) => {
|
||||
if ((input.length - index) < value.length) {
|
||||
return failure();
|
||||
}
|
||||
if (input.substr(index, value.length) !== value) {
|
||||
return failure();
|
||||
}
|
||||
return success(index + value.length, value);
|
||||
});
|
||||
}
|
||||
|
||||
export function regexp<T extends RegExp>(pattern: T): Parser<string> {
|
||||
const re = RegExp(`^(?:${pattern.source})`, pattern.flags);
|
||||
return new Parser((input, index, _state) => {
|
||||
const text = input.slice(index);
|
||||
const result = re.exec(text);
|
||||
if (result == null) {
|
||||
return failure();
|
||||
}
|
||||
return success(index + result[0].length, result[0]);
|
||||
});
|
||||
}
|
||||
|
||||
export function seq(parsers: Parser<any>[], select?: number): Parser<any> {
|
||||
return new Parser((input, index, state) => {
|
||||
let result;
|
||||
let latestIndex = index;
|
||||
const accum = [];
|
||||
for (let i = 0; i < parsers.length; i++) {
|
||||
result = parsers[i].handler(input, latestIndex, state);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
latestIndex = result.index;
|
||||
accum.push(result.value);
|
||||
}
|
||||
return success(latestIndex, (select != null ? accum[select] : accum));
|
||||
});
|
||||
}
|
||||
|
||||
export function alt(parsers: Parser<any>[]): Parser<any> {
|
||||
return new Parser((input, index, state) => {
|
||||
let result;
|
||||
for (let i = 0; i < parsers.length; i++) {
|
||||
result = parsers[i].handler(input, index, state);
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return failure();
|
||||
});
|
||||
}
|
||||
|
||||
function succeeded<T>(value: T): Parser<T> {
|
||||
return new Parser((_input, index, _state) => {
|
||||
return success(index, value);
|
||||
});
|
||||
}
|
||||
|
||||
export function notMatch(parser: Parser<any>): Parser<null> {
|
||||
return new Parser((input, index, state) => {
|
||||
const result = parser.handler(input, index, state);
|
||||
return !result.success
|
||||
? success(index, null)
|
||||
: failure();
|
||||
});
|
||||
}
|
||||
|
||||
export const cr = str('\r');
|
||||
export const lf = str('\n');
|
||||
export const crlf = str('\r\n');
|
||||
export const newline = alt([crlf, cr, lf]);
|
||||
|
||||
export const char = new Parser((input, index, _state) => {
|
||||
if ((input.length - index) < 1) {
|
||||
return failure();
|
||||
}
|
||||
const value = input.charAt(index);
|
||||
return success(index + 1, value);
|
||||
});
|
||||
|
||||
export const lineBegin = new Parser((input, index, state) => {
|
||||
if (index === 0) {
|
||||
return success(index, null);
|
||||
}
|
||||
if (cr.handler(input, index - 1, state).success) {
|
||||
return success(index, null);
|
||||
}
|
||||
if (lf.handler(input, index - 1, state).success) {
|
||||
return success(index, null);
|
||||
}
|
||||
return failure();
|
||||
});
|
||||
|
||||
export const lineEnd = new Parser((input, index, state) => {
|
||||
if (index === input.length) {
|
||||
return success(index, null);
|
||||
}
|
||||
if (cr.handler(input, index, state).success) {
|
||||
return success(index, null);
|
||||
}
|
||||
if (lf.handler(input, index, state).success) {
|
||||
return success(index, null);
|
||||
}
|
||||
return failure();
|
||||
});
|
||||
|
||||
export function lazy<T>(fn: () => Parser<T>): Parser<T> {
|
||||
const parser: Parser<T> = new Parser((input, index, state) => {
|
||||
parser.handler = fn().handler;
|
||||
return parser.handler(input, index, state);
|
||||
});
|
||||
return parser;
|
||||
}
|
||||
|
||||
//type Syntax<T> = (rules: Record<string, Parser<T>>) => Parser<T>;
|
||||
//type SyntaxReturn<T> = T extends (rules: Record<string, Parser<any>>) => infer R ? R : never;
|
||||
//export function createLanguage2<T extends Record<string, Syntax<any>>>(syntaxes: T): { [K in keyof T]: SyntaxReturn<T[K]> } {
|
||||
|
||||
// TODO: 関数の型宣言をいい感じにしたい
|
||||
export function createLanguage<T>(syntaxes: { [K in keyof T]: (r: Record<string, Parser<any>>) => T[K] }): T {
|
||||
const rules: Record<string, Parser<any>> = {};
|
||||
for (const key of Object.keys(syntaxes)) {
|
||||
rules[key] = lazy(() => {
|
||||
const parser = (syntaxes as any)[key](rules);
|
||||
if (parser == null) {
|
||||
throw new Error('syntax must return a parser.');
|
||||
}
|
||||
parser.name = key;
|
||||
return parser;
|
||||
});
|
||||
}
|
||||
return rules as any;
|
||||
}
|
23
packages/ffm-js/src/internal/index.ts
Normal file
23
packages/ffm-js/src/internal/index.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import * as M from '..';
|
||||
import { language } from './parser';
|
||||
import { mergeText } from './util';
|
||||
import * as P from './core';
|
||||
|
||||
export type FullParserOpts = {
|
||||
nestLimit?: number;
|
||||
};
|
||||
|
||||
export function fullParser(input: string, opts: FullParserOpts): M.MfmNode[] {
|
||||
const result = language.fullParser.handler(input, 0, {
|
||||
nestLimit: (opts.nestLimit != null) ? opts.nestLimit : 20,
|
||||
depth: 0,
|
||||
linkLabel: false,
|
||||
trace: false,
|
||||
}) as P.Success<any>;
|
||||
return mergeText(result.value);
|
||||
}
|
||||
|
||||
export function simpleParser(input: string): M.MfmSimpleNode[] {
|
||||
const result = language.simpleParser.handler(input, 0, { }) as P.Success<any>;
|
||||
return mergeText(result.value);
|
||||
}
|
749
packages/ffm-js/src/internal/parser.ts
Normal file
749
packages/ffm-js/src/internal/parser.ts
Normal file
|
@ -0,0 +1,749 @@
|
|||
import * as M from '..';
|
||||
import * as P from './core';
|
||||
import { mergeText } from './util';
|
||||
|
||||
// NOTE:
|
||||
// tsdのテストでファイルを追加しているにも関わらず「twemoji-parser/dist/lib/regex」の型定義ファイルがないとエラーが出るため、
|
||||
// このエラーを無視する。
|
||||
/* eslint @typescript-eslint/ban-ts-comment: 1 */
|
||||
// @ts-ignore
|
||||
import twemojiRegex from 'twemoji-parser/dist/lib/regex';
|
||||
|
||||
type ArgPair = { k: string, v: string | true };
|
||||
type Args = Record<string, string | true>;
|
||||
|
||||
const space = P.regexp(/[\u0020\u3000\t]/);
|
||||
const alphaAndNum = P.regexp(/[a-z0-9]/i);
|
||||
const newLine = P.alt([P.crlf, P.cr, P.lf]);
|
||||
|
||||
function seqOrText(parsers: P.Parser<any>[]): P.Parser<any[] | string> {
|
||||
return new P.Parser<any[] | string>((input, index, state) => {
|
||||
const accum: any[] = [];
|
||||
let latestIndex = index;
|
||||
for (let i = 0 ; i < parsers.length; i++) {
|
||||
const result = parsers[i].handler(input, latestIndex, state);
|
||||
if (!result.success) {
|
||||
if (latestIndex === index) {
|
||||
return P.failure();
|
||||
} else {
|
||||
return P.success(latestIndex, input.slice(index, latestIndex));
|
||||
}
|
||||
}
|
||||
accum.push(result.value);
|
||||
latestIndex = result.index;
|
||||
}
|
||||
return P.success(latestIndex, accum);
|
||||
});
|
||||
}
|
||||
|
||||
const notLinkLabel = new P.Parser((_input, index, state) => {
|
||||
return (!state.linkLabel)
|
||||
? P.success(index, null)
|
||||
: P.failure();
|
||||
});
|
||||
|
||||
const nestable = new P.Parser((_input, index, state) => {
|
||||
return (state.depth < state.nestLimit)
|
||||
? P.success(index, null)
|
||||
: P.failure();
|
||||
});
|
||||
|
||||
function nest<T>(parser: P.Parser<T>, fallback?: P.Parser<string>): P.Parser<T | string> {
|
||||
// nesting limited? -> No: specified parser, Yes: fallback parser (default = P.char)
|
||||
const inner = P.alt([
|
||||
P.seq([nestable, parser], 1),
|
||||
(fallback != null) ? fallback : P.char,
|
||||
]);
|
||||
return new P.Parser<T | string>((input, index, state) => {
|
||||
state.depth++;
|
||||
const result = inner.handler(input, index, state);
|
||||
state.depth--;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export const language = P.createLanguage({
|
||||
fullParser: r => {
|
||||
return r.full.many(0);
|
||||
},
|
||||
|
||||
simpleParser: r => {
|
||||
return r.simple.many(0);
|
||||
},
|
||||
|
||||
full: r => {
|
||||
return P.alt([
|
||||
// Regexp
|
||||
r.unicodeEmoji,
|
||||
// "<center>" block
|
||||
r.centerTag,
|
||||
// "<small>"
|
||||
r.smallTag,
|
||||
// "<plain>"
|
||||
r.plainTag,
|
||||
// "<b>"
|
||||
r.boldTag,
|
||||
// "<i>"
|
||||
r.italicTag,
|
||||
// "<s>"
|
||||
r.strikeTag,
|
||||
// "<http"
|
||||
r.urlAlt,
|
||||
// "***"
|
||||
r.big,
|
||||
// "**"
|
||||
r.boldAsta,
|
||||
// "*"
|
||||
r.italicAsta,
|
||||
// "__"
|
||||
r.boldUnder,
|
||||
// "_"
|
||||
r.italicUnder,
|
||||
// "```" block
|
||||
r.codeBlock,
|
||||
// "`"
|
||||
r.inlineCode,
|
||||
// ">" block
|
||||
r.quote,
|
||||
// "\\[" block
|
||||
r.mathBlock,
|
||||
// "\\("
|
||||
r.mathInline,
|
||||
// "~~"
|
||||
r.strikeWave,
|
||||
// "$[""
|
||||
r.fn,
|
||||
// "@"
|
||||
r.mention,
|
||||
// "#"
|
||||
r.hashtag,
|
||||
// ":"
|
||||
r.emojiCode,
|
||||
// "?[" or "["
|
||||
r.link,
|
||||
// http
|
||||
r.url,
|
||||
// block
|
||||
r.search,
|
||||
r.text,
|
||||
]);
|
||||
},
|
||||
|
||||
simple: r => {
|
||||
return P.alt([
|
||||
r.unicodeEmoji, // Regexp
|
||||
r.emojiCode, // ":"
|
||||
r.text,
|
||||
]);
|
||||
},
|
||||
|
||||
inline: r => {
|
||||
return P.alt([
|
||||
// Regexp
|
||||
r.unicodeEmoji,
|
||||
// "<small>"
|
||||
r.smallTag,
|
||||
// "<plain>"
|
||||
r.plainTag,
|
||||
// "<b>"
|
||||
r.boldTag,
|
||||
// "<i>"
|
||||
r.italicTag,
|
||||
// "<s>"
|
||||
r.strikeTag,
|
||||
// <http
|
||||
r.urlAlt,
|
||||
// "***"
|
||||
r.big,
|
||||
// "**"
|
||||
r.boldAsta,
|
||||
// "*"
|
||||
r.italicAsta,
|
||||
// "__"
|
||||
r.boldUnder,
|
||||
// "_"
|
||||
r.italicUnder,
|
||||
// "`"
|
||||
r.inlineCode,
|
||||
// "\\("
|
||||
r.mathInline,
|
||||
// "~~"
|
||||
r.strikeWave,
|
||||
// "$[""
|
||||
r.fn,
|
||||
// "@"
|
||||
r.mention,
|
||||
// "#"
|
||||
r.hashtag,
|
||||
// ":"
|
||||
r.emojiCode,
|
||||
// "?[" or "["
|
||||
r.link,
|
||||
// http
|
||||
r.url,
|
||||
r.text,
|
||||
]);
|
||||
},
|
||||
|
||||
quote: r => {
|
||||
const lines: P.Parser<string[]> = P.seq([
|
||||
P.str('>'),
|
||||
space.option(),
|
||||
P.seq([P.notMatch(newLine), P.char], 1).many(0).text(),
|
||||
], 2).sep(newLine, 1);
|
||||
const parser = P.seq([
|
||||
newLine.option(),
|
||||
newLine.option(),
|
||||
P.lineBegin,
|
||||
lines,
|
||||
newLine.option(),
|
||||
newLine.option(),
|
||||
], 3);
|
||||
return new P.Parser((input, index, state) => {
|
||||
let result;
|
||||
// parse quote
|
||||
result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
const contents = result.value;
|
||||
const quoteIndex = result.index;
|
||||
// disallow empty content if single line
|
||||
if (contents.length === 1 && contents[0].length === 0) {
|
||||
return P.failure();
|
||||
}
|
||||
// parse inner content
|
||||
const contentParser = nest(r.fullParser).many(0);
|
||||
result = contentParser.handler(contents.join('\n'), 0, state);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
return P.success(quoteIndex, M.QUOTE(mergeText(result.value)));
|
||||
});
|
||||
},
|
||||
|
||||
codeBlock: r => {
|
||||
const mark = P.str('```');
|
||||
return P.seq([
|
||||
newLine.option(),
|
||||
P.lineBegin,
|
||||
mark,
|
||||
P.seq([P.notMatch(newLine), P.char], 1).many(0),
|
||||
newLine,
|
||||
P.seq([P.notMatch(P.seq([newLine, mark, P.lineEnd])), P.char], 1).many(1),
|
||||
newLine,
|
||||
mark,
|
||||
P.lineEnd,
|
||||
newLine.option(),
|
||||
]).map(result => {
|
||||
const lang = (result[3] as string[]).join('').trim();
|
||||
const code = (result[5] as string[]).join('');
|
||||
return M.CODE_BLOCK(code, (lang.length > 0 ? lang : null));
|
||||
});
|
||||
},
|
||||
|
||||
mathBlock: r => {
|
||||
const open = P.str('\\[');
|
||||
const close = P.str('\\]');
|
||||
return P.seq([
|
||||
newLine.option(),
|
||||
P.lineBegin,
|
||||
open,
|
||||
newLine.option(),
|
||||
P.seq([P.notMatch(P.seq([newLine.option(), close])), P.char], 1).many(1),
|
||||
newLine.option(),
|
||||
close,
|
||||
P.lineEnd,
|
||||
newLine.option(),
|
||||
]).map(result => {
|
||||
const formula = (result[4] as string[]).join('');
|
||||
return M.MATH_BLOCK(formula);
|
||||
});
|
||||
},
|
||||
|
||||
centerTag: r => {
|
||||
const open = P.str('<center>');
|
||||
const close = P.str('</center>');
|
||||
return P.seq([
|
||||
newLine.option(),
|
||||
P.lineBegin,
|
||||
open,
|
||||
newLine.option(),
|
||||
P.seq([P.notMatch(P.seq([newLine.option(), close])), nest(r.inline)], 1).many(1),
|
||||
newLine.option(),
|
||||
close,
|
||||
P.lineEnd,
|
||||
newLine.option(),
|
||||
]).map(result => {
|
||||
return M.CENTER(mergeText(result[4]));
|
||||
});
|
||||
},
|
||||
|
||||
big: r => {
|
||||
const mark = P.str('***');
|
||||
return seqOrText([
|
||||
mark,
|
||||
P.seq([P.notMatch(mark), nest(r.inline)], 1).many(1),
|
||||
mark,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.FN('tada', {}, mergeText(result[1]));
|
||||
});
|
||||
},
|
||||
|
||||
boldAsta: r => {
|
||||
const mark = P.str('**');
|
||||
return seqOrText([
|
||||
mark,
|
||||
P.seq([P.notMatch(mark), nest(r.inline)], 1).many(1),
|
||||
mark,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.BOLD(mergeText(result[1] as (M.MfmInline | string)[]));
|
||||
});
|
||||
},
|
||||
|
||||
boldTag: r => {
|
||||
const open = P.str('<b>');
|
||||
const close = P.str('</b>');
|
||||
return seqOrText([
|
||||
open,
|
||||
P.seq([P.notMatch(close), nest(r.inline)], 1).many(1),
|
||||
close,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.BOLD(mergeText(result[1] as (M.MfmInline | string)[]));
|
||||
});
|
||||
},
|
||||
|
||||
boldUnder: r => {
|
||||
const mark = P.str('__');
|
||||
return P.seq([
|
||||
mark,
|
||||
P.alt([alphaAndNum, space]).many(1),
|
||||
mark,
|
||||
]).map(result => M.BOLD(mergeText(result[1] as string[])));
|
||||
},
|
||||
|
||||
smallTag: r => {
|
||||
const open = P.str('<small>');
|
||||
const close = P.str('</small>');
|
||||
return seqOrText([
|
||||
open,
|
||||
P.seq([P.notMatch(close), nest(r.inline)], 1).many(1),
|
||||
close,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.SMALL(mergeText(result[1] as (M.MfmInline | string)[]));
|
||||
});
|
||||
},
|
||||
|
||||
italicTag: r => {
|
||||
const open = P.str('<i>');
|
||||
const close = P.str('</i>');
|
||||
return seqOrText([
|
||||
open,
|
||||
P.seq([P.notMatch(close), nest(r.inline)], 1).many(1),
|
||||
close,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.ITALIC(mergeText(result[1] as (M.MfmInline | string)[]));
|
||||
});
|
||||
},
|
||||
|
||||
italicAsta: r => {
|
||||
const mark = P.str('*');
|
||||
const parser = P.seq([
|
||||
mark,
|
||||
P.alt([alphaAndNum, space]).many(1),
|
||||
mark,
|
||||
]);
|
||||
return new P.Parser((input, index, state) => {
|
||||
const result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return P.failure();
|
||||
}
|
||||
// check before
|
||||
const beforeStr = input.slice(0, index);
|
||||
if (/[a-z0-9]$/i.test(beforeStr)) {
|
||||
return P.failure();
|
||||
}
|
||||
return P.success(result.index, M.ITALIC(mergeText(result.value[1] as string[])));
|
||||
});
|
||||
},
|
||||
|
||||
italicUnder: r => {
|
||||
const mark = P.str('_');
|
||||
const parser = P.seq([
|
||||
mark,
|
||||
P.alt([alphaAndNum, space]).many(1),
|
||||
mark,
|
||||
]);
|
||||
return new P.Parser((input, index, state) => {
|
||||
const result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return P.failure();
|
||||
}
|
||||
// check before
|
||||
const beforeStr = input.slice(0, index);
|
||||
if (/[a-z0-9]$/i.test(beforeStr)) {
|
||||
return P.failure();
|
||||
}
|
||||
return P.success(result.index, M.ITALIC(mergeText(result.value[1] as string[])));
|
||||
});
|
||||
},
|
||||
|
||||
strikeTag: r => {
|
||||
const open = P.str('<s>');
|
||||
const close = P.str('</s>');
|
||||
return seqOrText([
|
||||
open,
|
||||
P.seq([P.notMatch(close), nest(r.inline)], 1).many(1),
|
||||
close,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.STRIKE(mergeText(result[1] as (M.MfmInline | string)[]));
|
||||
});
|
||||
},
|
||||
|
||||
strikeWave: r => {
|
||||
const mark = P.str('~~');
|
||||
return seqOrText([
|
||||
mark,
|
||||
P.seq([P.notMatch(P.alt([mark, newLine])), nest(r.inline)], 1).many(1),
|
||||
mark,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
return M.STRIKE(mergeText(result[1] as (M.MfmInline | string)[]));
|
||||
});
|
||||
},
|
||||
|
||||
unicodeEmoji: r => {
|
||||
const emoji = RegExp(twemojiRegex.source);
|
||||
return P.regexp(emoji).map(content => M.UNI_EMOJI(content));
|
||||
},
|
||||
|
||||
plainTag: r => {
|
||||
const open = P.str('<plain>');
|
||||
const close = P.str('</plain>');
|
||||
return P.seq([
|
||||
open,
|
||||
newLine.option(),
|
||||
P.seq([
|
||||
P.notMatch(P.seq([newLine.option(), close])),
|
||||
P.char,
|
||||
], 1).many(1).text(),
|
||||
newLine.option(),
|
||||
close,
|
||||
], 2).map(result => M.PLAIN(result));
|
||||
},
|
||||
|
||||
fn: r => {
|
||||
const fnName = new P.Parser((input, index, state) => {
|
||||
const result = P.regexp(/[a-z0-9_]+/i).handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
return P.success(result.index, result.value);
|
||||
});
|
||||
const arg: P.Parser<ArgPair> = P.seq([
|
||||
P.regexp(/[a-z0-9_]+/i),
|
||||
P.seq([
|
||||
P.str('='),
|
||||
P.regexp(/[a-z0-9_.-]+/i),
|
||||
], 1).option(),
|
||||
]).map(result => {
|
||||
return {
|
||||
k: result[0],
|
||||
v: (result[1] != null) ? result[1] : true,
|
||||
};
|
||||
});
|
||||
const args = P.seq([
|
||||
P.str('.'),
|
||||
arg.sep(P.str(','), 1),
|
||||
], 1).map(pairs => {
|
||||
const result: Args = { };
|
||||
for (const pair of pairs) {
|
||||
result[pair.k] = pair.v;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const fnClose = P.str(']');
|
||||
return seqOrText([
|
||||
P.str('$['),
|
||||
fnName,
|
||||
args.option(),
|
||||
P.str(' '),
|
||||
P.seq([P.notMatch(fnClose), nest(r.inline)], 1).many(1),
|
||||
fnClose,
|
||||
]).map(result => {
|
||||
if (typeof result === 'string') return result;
|
||||
const name = result[1];
|
||||
const args = result[2] || {};
|
||||
const content = result[4];
|
||||
return M.FN(name, args, mergeText(content));
|
||||
});
|
||||
},
|
||||
|
||||
inlineCode: r => {
|
||||
const mark = P.str('`');
|
||||
return P.seq([
|
||||
mark,
|
||||
P.seq([
|
||||
P.notMatch(P.alt([mark, P.str('´'), newLine])),
|
||||
P.char,
|
||||
], 1).many(1),
|
||||
mark,
|
||||
]).map(result => M.INLINE_CODE(result[1].join('')));
|
||||
},
|
||||
|
||||
mathInline: r => {
|
||||
const open = P.str('\\(');
|
||||
const close = P.str('\\)');
|
||||
return P.seq([
|
||||
open,
|
||||
P.seq([
|
||||
P.notMatch(P.alt([close, newLine])),
|
||||
P.char,
|
||||
], 1).many(1),
|
||||
close,
|
||||
]).map(result => M.MATH_INLINE(result[1].join('')));
|
||||
},
|
||||
|
||||
mention: r => {
|
||||
const parser = P.seq([
|
||||
notLinkLabel,
|
||||
P.str('@'),
|
||||
P.regexp(/[a-z0-9_-]+/i),
|
||||
P.seq([
|
||||
P.str('@'),
|
||||
P.regexp(/[a-z0-9_.-]+/i),
|
||||
], 1).option(),
|
||||
]);
|
||||
return new P.Parser<M.MfmMention | string>((input, index, state) => {
|
||||
let result;
|
||||
result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return P.failure();
|
||||
}
|
||||
// check before (not mention)
|
||||
const beforeStr = input.slice(0, index);
|
||||
if (/[a-z0-9]$/i.test(beforeStr)) {
|
||||
return P.failure();
|
||||
}
|
||||
let invalidMention = false;
|
||||
const resultIndex = result.index;
|
||||
const username: string = result.value[2];
|
||||
const hostname: string | null = result.value[3];
|
||||
// remove [.-] of tail of hostname
|
||||
let modifiedHost = hostname;
|
||||
if (hostname != null) {
|
||||
result = /[.-]+$/.exec(hostname);
|
||||
if (result != null) {
|
||||
modifiedHost = hostname.slice(0, (-1 * result[0].length));
|
||||
if (modifiedHost.length === 0) {
|
||||
// disallow invalid char only hostname
|
||||
invalidMention = true;
|
||||
modifiedHost = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// remove "-" of tail of username
|
||||
let modifiedName = username;
|
||||
result = /-+$/.exec(username);
|
||||
if (result != null) {
|
||||
if (modifiedHost == null) {
|
||||
modifiedName = username.slice(0, (-1 * result[0].length));
|
||||
} else {
|
||||
// cannnot to remove tail of username if exist hostname
|
||||
invalidMention = true;
|
||||
}
|
||||
}
|
||||
// disallow "-" of head of username
|
||||
if (modifiedName.length === 0 || modifiedName[0] === '-') {
|
||||
invalidMention = true;
|
||||
}
|
||||
// disallow [.-] of head of hostname
|
||||
if (modifiedHost != null && /^[.-]/.test(modifiedHost)) {
|
||||
invalidMention = true;
|
||||
}
|
||||
// generate a text if mention is invalid
|
||||
if (invalidMention) {
|
||||
return P.success(resultIndex, input.slice(index, resultIndex));
|
||||
}
|
||||
const acct = modifiedHost != null ? `@${modifiedName}@${modifiedHost}` : `@${modifiedName}`;
|
||||
return P.success(index + acct.length, M.MENTION(modifiedName, modifiedHost, acct));
|
||||
});
|
||||
},
|
||||
|
||||
hashtag: r => {
|
||||
const mark = P.str('#');
|
||||
const hashTagChar = P.seq([
|
||||
P.notMatch(P.alt([P.regexp(/[ \u3000\t.,!?'"#:/[\]【】()「」()<>]/), space, newLine])),
|
||||
P.char,
|
||||
], 1);
|
||||
const innerItem: P.Parser<any> = P.lazy(() => P.alt([
|
||||
P.seq([
|
||||
P.str('('), nest(innerItem, hashTagChar).many(0), P.str(')'),
|
||||
]),
|
||||
P.seq([
|
||||
P.str('['), nest(innerItem, hashTagChar).many(0), P.str(']'),
|
||||
]),
|
||||
P.seq([
|
||||
P.str('「'), nest(innerItem, hashTagChar).many(0), P.str('」'),
|
||||
]),
|
||||
P.seq([
|
||||
P.str('('), nest(innerItem, hashTagChar).many(0), P.str(')'),
|
||||
]),
|
||||
hashTagChar,
|
||||
]));
|
||||
const parser = P.seq([
|
||||
notLinkLabel,
|
||||
mark,
|
||||
innerItem.many(1).text(),
|
||||
], 2);
|
||||
return new P.Parser((input, index, state) => {
|
||||
const result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return P.failure();
|
||||
}
|
||||
// check before
|
||||
const beforeStr = input.slice(0, index);
|
||||
if (/[a-z0-9]$/i.test(beforeStr)) {
|
||||
return P.failure();
|
||||
}
|
||||
const resultIndex = result.index;
|
||||
const resultValue = result.value;
|
||||
// disallow number only
|
||||
if (/^[0-9]+$/.test(resultValue)) {
|
||||
return P.failure();
|
||||
}
|
||||
return P.success(resultIndex, M.HASHTAG(resultValue));
|
||||
});
|
||||
},
|
||||
|
||||
emojiCode: r => {
|
||||
const side = P.notMatch(P.regexp(/[a-z0-9]/i));
|
||||
const mark = P.str(':');
|
||||
return P.seq([
|
||||
P.alt([P.lineBegin, side]),
|
||||
mark,
|
||||
P.regexp(/[a-z0-9_+-]+/i),
|
||||
mark,
|
||||
P.alt([P.lineEnd, side]),
|
||||
], 2).map(name => M.EMOJI_CODE(name as string));
|
||||
},
|
||||
|
||||
link: r => {
|
||||
const labelInline = new P.Parser((input, index, state) => {
|
||||
state.linkLabel = true;
|
||||
const result = r.inline.handler(input, index, state);
|
||||
state.linkLabel = false;
|
||||
return result;
|
||||
});
|
||||
const closeLabel = P.str(']');
|
||||
return P.seq([
|
||||
notLinkLabel,
|
||||
P.alt([P.str('?['), P.str('[')]),
|
||||
P.seq([
|
||||
P.notMatch(P.alt([closeLabel, newLine])),
|
||||
nest(labelInline),
|
||||
], 1).many(1),
|
||||
closeLabel,
|
||||
P.str('('),
|
||||
P.alt([r.urlAlt, r.url]),
|
||||
P.str(')'),
|
||||
]).map(result => {
|
||||
const silent = (result[1] === '?[');
|
||||
const label = result[2];
|
||||
const url: M.MfmUrl = result[5];
|
||||
return M.LINK(silent, url.props.url, mergeText(label));
|
||||
});
|
||||
},
|
||||
|
||||
url: r => {
|
||||
const urlChar = P.regexp(/[.,a-z0-9_/:%#@$&?!~=+-]/i);
|
||||
const innerItem: P.Parser<any> = P.lazy(() => P.alt([
|
||||
P.seq([
|
||||
P.str('('), nest(innerItem, urlChar).many(0), P.str(')'),
|
||||
]),
|
||||
P.seq([
|
||||
P.str('['), nest(innerItem, urlChar).many(0), P.str(']'),
|
||||
]),
|
||||
urlChar,
|
||||
]));
|
||||
const parser = P.seq([
|
||||
notLinkLabel,
|
||||
P.regexp(/https?:\/\//),
|
||||
innerItem.many(1).text(),
|
||||
]);
|
||||
return new P.Parser<M.MfmUrl | string>((input, index, state) => {
|
||||
let result;
|
||||
result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return P.failure();
|
||||
}
|
||||
const resultIndex = result.index;
|
||||
let modifiedIndex = resultIndex;
|
||||
const schema: string = result.value[1];
|
||||
let content: string = result.value[2];
|
||||
// remove the ".," at the right end
|
||||
result = /[.,]+$/.exec(content);
|
||||
if (result != null) {
|
||||
modifiedIndex -= result[0].length;
|
||||
content = content.slice(0, (-1 * result[0].length));
|
||||
if (content.length === 0) {
|
||||
return P.success(resultIndex, input.slice(index, resultIndex));
|
||||
}
|
||||
}
|
||||
return P.success(modifiedIndex, M.N_URL(schema + content, false));
|
||||
});
|
||||
},
|
||||
|
||||
urlAlt: r => {
|
||||
const open = P.str('<');
|
||||
const close = P.str('>');
|
||||
const parser = P.seq([
|
||||
notLinkLabel,
|
||||
open,
|
||||
P.regexp(/https?:\/\//),
|
||||
P.seq([P.notMatch(P.alt([close, space])), P.char], 1).many(1),
|
||||
close,
|
||||
]).text();
|
||||
return new P.Parser((input, index, state) => {
|
||||
const result = parser.handler(input, index, state);
|
||||
if (!result.success) {
|
||||
return P.failure();
|
||||
}
|
||||
const text = result.value.slice(1, (result.value.length - 1));
|
||||
return P.success(result.index, M.N_URL(text, true));
|
||||
});
|
||||
},
|
||||
|
||||
search: r => {
|
||||
const button = P.alt([
|
||||
P.regexp(/\[(検索|search)\]/i),
|
||||
P.regexp(/(検索|search)/i),
|
||||
]);
|
||||
return P.seq([
|
||||
newLine.option(),
|
||||
P.lineBegin,
|
||||
P.seq([
|
||||
P.notMatch(P.alt([
|
||||
newLine,
|
||||
P.seq([space, button, P.lineEnd]),
|
||||
])),
|
||||
P.char,
|
||||
], 1).many(1),
|
||||
space,
|
||||
button,
|
||||
P.lineEnd,
|
||||
newLine.option(),
|
||||
]).map(result => {
|
||||
const query = result[2].join('');
|
||||
return M.SEARCH(query, `${query}${result[3]}${result[4]}`);
|
||||
});
|
||||
},
|
||||
|
||||
text: r => P.char,
|
||||
});
|
169
packages/ffm-js/src/internal/util.ts
Normal file
169
packages/ffm-js/src/internal/util.ts
Normal file
|
@ -0,0 +1,169 @@
|
|||
import { isMfmBlock, MfmInline, MfmNode, MfmText, TEXT } from '../node';
|
||||
|
||||
export function mergeText<T extends MfmNode>(nodes: ((T extends MfmInline ? MfmInline : MfmNode) | string)[]): (T | MfmText)[] {
|
||||
const dest: (T | MfmText)[] = [];
|
||||
const storedChars: string[] = [];
|
||||
|
||||
/**
|
||||
* Generate a text node from the stored chars, And push it.
|
||||
*/
|
||||
function generateText() {
|
||||
if (storedChars.length > 0) {
|
||||
dest.push(TEXT(storedChars.join('')));
|
||||
storedChars.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const flatten = nodes.flat(1) as (string | T)[];
|
||||
for (const node of flatten) {
|
||||
if (typeof node === 'string') {
|
||||
// Store the char.
|
||||
storedChars.push(node);
|
||||
}
|
||||
else if (!Array.isArray(node) && node.type === 'text') {
|
||||
storedChars.push((node as MfmText).props.text);
|
||||
}
|
||||
else {
|
||||
generateText();
|
||||
dest.push(node);
|
||||
}
|
||||
}
|
||||
generateText();
|
||||
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function stringifyNode(node: MfmNode): string {
|
||||
switch (node.type) {
|
||||
// block
|
||||
case 'quote': {
|
||||
return stringifyTree(node.children).split('\n').map(line => `> ${line}`).join('\n');
|
||||
}
|
||||
case 'search': {
|
||||
return node.props.content;
|
||||
}
|
||||
case 'blockCode': {
|
||||
return `\`\`\`${ node.props.lang ?? '' }\n${ node.props.code }\n\`\`\``;
|
||||
}
|
||||
case 'mathBlock': {
|
||||
return `\\[\n${ node.props.formula }\n\\]`;
|
||||
}
|
||||
case 'center': {
|
||||
return `<center>\n${ stringifyTree(node.children) }\n</center>`;
|
||||
}
|
||||
// inline
|
||||
case 'emojiCode': {
|
||||
return `:${ node.props.name }:`;
|
||||
}
|
||||
case 'unicodeEmoji': {
|
||||
return node.props.emoji;
|
||||
}
|
||||
case 'bold': {
|
||||
return `**${ stringifyTree(node.children) }**`;
|
||||
}
|
||||
case 'small': {
|
||||
return `<small>${ stringifyTree(node.children) }</small>`;
|
||||
}
|
||||
case 'italic': {
|
||||
return `<i>${ stringifyTree(node.children) }</i>`;
|
||||
}
|
||||
case 'strike': {
|
||||
return `~~${ stringifyTree(node.children) }~~`;
|
||||
}
|
||||
case 'inlineCode': {
|
||||
return `\`${ node.props.code }\``;
|
||||
}
|
||||
case 'mathInline': {
|
||||
return `\\(${ node.props.formula }\\)`;
|
||||
}
|
||||
case 'mention': {
|
||||
return node.props.acct;
|
||||
}
|
||||
case 'hashtag': {
|
||||
return `#${ node.props.hashtag }`;
|
||||
}
|
||||
case 'url': {
|
||||
if (node.props.brackets) {
|
||||
return `<${ node.props.url }>`;
|
||||
}
|
||||
else {
|
||||
return node.props.url;
|
||||
}
|
||||
}
|
||||
case 'link': {
|
||||
const prefix = node.props.silent ? '?' : '';
|
||||
return `${ prefix }[${ stringifyTree(node.children) }](${ node.props.url })`;
|
||||
}
|
||||
case 'fn': {
|
||||
const argFields = Object.keys(node.props.args).map(key => {
|
||||
const value = node.props.args[key];
|
||||
if (value === true) {
|
||||
return key;
|
||||
}
|
||||
else {
|
||||
return `${ key }=${ value }`;
|
||||
}
|
||||
});
|
||||
const args = (argFields.length > 0) ? '.' + argFields.join(',') : '';
|
||||
return `$[${ node.props.name }${ args } ${ stringifyTree(node.children) }]`;
|
||||
}
|
||||
case 'plain': {
|
||||
return `<plain>\n${ stringifyTree(node.children) }\n</plain>`;
|
||||
}
|
||||
case 'text': {
|
||||
return node.props.text;
|
||||
}
|
||||
}
|
||||
throw new Error('unknown mfm node');
|
||||
}
|
||||
|
||||
enum stringifyState {
|
||||
none = 0,
|
||||
inline,
|
||||
block
|
||||
}
|
||||
|
||||
export function stringifyTree(nodes: MfmNode[]): string {
|
||||
const dest: MfmNode[] = [];
|
||||
let state: stringifyState = stringifyState.none;
|
||||
|
||||
for (const node of nodes) {
|
||||
// 文脈に合わせて改行を追加する。
|
||||
// none -> inline : No
|
||||
// none -> block : No
|
||||
// inline -> inline : No
|
||||
// inline -> block : Yes
|
||||
// block -> inline : Yes
|
||||
// block -> block : Yes
|
||||
|
||||
let pushLf: boolean = true;
|
||||
if (isMfmBlock(node)) {
|
||||
if (state === stringifyState.none) {
|
||||
pushLf = false;
|
||||
}
|
||||
state = stringifyState.block;
|
||||
}
|
||||
else {
|
||||
if (state === stringifyState.none || state === stringifyState.inline) {
|
||||
pushLf = false;
|
||||
}
|
||||
state = stringifyState.inline;
|
||||
}
|
||||
if (pushLf) {
|
||||
dest.push(TEXT('\n'));
|
||||
}
|
||||
|
||||
dest.push(node);
|
||||
}
|
||||
|
||||
return dest.map(n => stringifyNode(n)).join('');
|
||||
}
|
||||
|
||||
export function inspectOne(node: MfmNode, action: (node: MfmNode) => void) {
|
||||
action(node);
|
||||
if (node.children != null) {
|
||||
for (const child of node.children) {
|
||||
inspectOne(child, action);
|
||||
}
|
||||
}
|
||||
}
|
213
packages/ffm-js/src/node.ts
Normal file
213
packages/ffm-js/src/node.ts
Normal file
|
@ -0,0 +1,213 @@
|
|||
export type MfmNode = MfmBlock | MfmInline;
|
||||
|
||||
export type MfmSimpleNode = MfmUnicodeEmoji | MfmEmojiCode | MfmText;
|
||||
|
||||
export type MfmBlock = MfmQuote | MfmSearch | MfmCodeBlock | MfmMathBlock | MfmCenter;
|
||||
|
||||
const blockTypes: MfmNode['type'][] = [ 'quote', 'search', 'blockCode', 'mathBlock', 'center' ];
|
||||
export function isMfmBlock(node: MfmNode): node is MfmBlock {
|
||||
return blockTypes.includes(node.type);
|
||||
}
|
||||
|
||||
export type MfmQuote = {
|
||||
type: 'quote';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmNode[];
|
||||
};
|
||||
export const QUOTE = (children: MfmNode[]): NodeType<'quote'> => { return { type: 'quote', children }; };
|
||||
|
||||
export type MfmSearch = {
|
||||
type: 'search';
|
||||
props: {
|
||||
query: string;
|
||||
content: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const SEARCH = (query: string, content: string): NodeType<'search'> => { return { type: 'search', props: { query, content } }; };
|
||||
|
||||
export type MfmCodeBlock = {
|
||||
type: 'blockCode';
|
||||
props: {
|
||||
code: string;
|
||||
lang: string | null;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const CODE_BLOCK = (code: string, lang: string | null): NodeType<'blockCode'> => { return { type: 'blockCode', props: { code, lang } }; };
|
||||
|
||||
export type MfmMathBlock = {
|
||||
type: 'mathBlock';
|
||||
props: {
|
||||
formula: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const MATH_BLOCK = (formula: string): NodeType<'mathBlock'> => { return { type: 'mathBlock', props: { formula } }; };
|
||||
|
||||
export type MfmCenter = {
|
||||
type: 'center';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const CENTER = (children: MfmInline[]): NodeType<'center'> => { return { type: 'center', children }; };
|
||||
|
||||
export type MfmInline = MfmUnicodeEmoji | MfmEmojiCode | MfmBold | MfmSmall | MfmItalic | MfmStrike |
|
||||
MfmInlineCode | MfmMathInline | MfmMention | MfmHashtag | MfmUrl | MfmLink | MfmFn | MfmPlain | MfmText;
|
||||
|
||||
export type MfmUnicodeEmoji = {
|
||||
type: 'unicodeEmoji';
|
||||
props: {
|
||||
emoji: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const UNI_EMOJI = (value: string): NodeType<'unicodeEmoji'> => { return { type: 'unicodeEmoji', props: { emoji: value } }; };
|
||||
|
||||
export type MfmEmojiCode = {
|
||||
type: 'emojiCode';
|
||||
props: {
|
||||
name: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const EMOJI_CODE = (name: string): NodeType<'emojiCode'> => { return { type: 'emojiCode', props: { name: name } }; };
|
||||
|
||||
export type MfmBold = {
|
||||
type: 'bold';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const BOLD = (children: MfmInline[]): NodeType<'bold'> => { return { type: 'bold', children }; };
|
||||
|
||||
export type MfmSmall = {
|
||||
type: 'small';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const SMALL = (children: MfmInline[]): NodeType<'small'> => { return { type: 'small', children }; };
|
||||
|
||||
export type MfmItalic = {
|
||||
type: 'italic';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const ITALIC = (children: MfmInline[]): NodeType<'italic'> => { return { type: 'italic', children }; };
|
||||
|
||||
export type MfmStrike = {
|
||||
type: 'strike';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const STRIKE = (children: MfmInline[]): NodeType<'strike'> => { return { type: 'strike', children }; };
|
||||
|
||||
export type MfmInlineCode = {
|
||||
type: 'inlineCode';
|
||||
props: {
|
||||
code: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const INLINE_CODE = (code: string): NodeType<'inlineCode'> => { return { type: 'inlineCode', props: { code } }; };
|
||||
|
||||
export type MfmMathInline = {
|
||||
type: 'mathInline';
|
||||
props: {
|
||||
formula: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const MATH_INLINE = (formula: string): NodeType<'mathInline'> => { return { type: 'mathInline', props: { formula } }; };
|
||||
|
||||
export type MfmMention = {
|
||||
type: 'mention';
|
||||
props: {
|
||||
username: string;
|
||||
host: string | null;
|
||||
acct: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const MENTION = (username: string, host: string | null, acct: string): NodeType<'mention'> => { return { type: 'mention', props: { username, host, acct } }; };
|
||||
|
||||
export type MfmHashtag = {
|
||||
type: 'hashtag';
|
||||
props: {
|
||||
hashtag: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const HASHTAG = (value: string): NodeType<'hashtag'> => { return { type: 'hashtag', props: { hashtag: value } }; };
|
||||
|
||||
export type MfmUrl = {
|
||||
type: 'url';
|
||||
props: {
|
||||
url: string;
|
||||
brackets?: boolean;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const N_URL = (value: string, brackets?: boolean): NodeType<'url'> => {
|
||||
const node: MfmUrl = { type: 'url', props: { url: value } };
|
||||
if (brackets) node.props.brackets = brackets;
|
||||
return node;
|
||||
};
|
||||
|
||||
export type MfmLink = {
|
||||
type: 'link';
|
||||
props: {
|
||||
silent: boolean;
|
||||
url: string;
|
||||
};
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const LINK = (silent: boolean, url: string, children: MfmInline[]): NodeType<'link'> => { return { type: 'link', props: { silent, url }, children }; };
|
||||
|
||||
export type MfmFn = {
|
||||
type: 'fn';
|
||||
props: {
|
||||
name: string;
|
||||
args: Record<string, string | true>;
|
||||
};
|
||||
children: MfmInline[];
|
||||
};
|
||||
export const FN = (name: string, args: MfmFn['props']['args'], children: MfmFn['children']): NodeType<'fn'> => { return { type: 'fn', props: { name, args }, children }; };
|
||||
|
||||
export type MfmPlain = {
|
||||
type: 'plain';
|
||||
props?: Record<string, unknown>;
|
||||
children: MfmText[];
|
||||
};
|
||||
export const PLAIN = (text: string): NodeType<'plain'> => { return { type: 'plain', children: [TEXT(text)] }; };
|
||||
|
||||
export type MfmText = {
|
||||
type: 'text';
|
||||
props: {
|
||||
text: string;
|
||||
};
|
||||
children?: [];
|
||||
};
|
||||
export const TEXT = (value: string): NodeType<'text'> => { return { type: 'text', props: { text: value } }; };
|
||||
|
||||
export type NodeType<T extends MfmNode['type']> =
|
||||
T extends 'quote' ? MfmQuote :
|
||||
T extends 'search' ? MfmSearch :
|
||||
T extends 'blockCode' ? MfmCodeBlock :
|
||||
T extends 'mathBlock' ? MfmMathBlock :
|
||||
T extends 'center' ? MfmCenter :
|
||||
T extends 'unicodeEmoji' ? MfmUnicodeEmoji :
|
||||
T extends 'emojiCode' ? MfmEmojiCode :
|
||||
T extends 'bold' ? MfmBold :
|
||||
T extends 'small' ? MfmSmall :
|
||||
T extends 'italic' ? MfmItalic :
|
||||
T extends 'strike' ? MfmStrike :
|
||||
T extends 'inlineCode' ? MfmInlineCode :
|
||||
T extends 'mathInline' ? MfmMathInline :
|
||||
T extends 'mention' ? MfmMention :
|
||||
T extends 'hashtag' ? MfmHashtag :
|
||||
T extends 'url' ? MfmUrl :
|
||||
T extends 'link' ? MfmLink :
|
||||
T extends 'fn' ? MfmFn :
|
||||
T extends 'plain' ? MfmPlain :
|
||||
T extends 'text' ? MfmText :
|
||||
never;
|
14
packages/ffm-js/test-d/index.ts
Normal file
14
packages/ffm-js/test-d/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Unit testing TypeScript types.
|
||||
* with https://github.com/SamVerschueren/tsd
|
||||
*/
|
||||
|
||||
import { expectType } from 'tsd';
|
||||
import { NodeType, MfmUrl } from '../src';
|
||||
|
||||
describe('#NodeType', () => {
|
||||
test('returns node that has sprcified type', () => {
|
||||
const x = null as unknown as NodeType<'url'>;
|
||||
expectType<MfmUrl>(x);
|
||||
});
|
||||
});
|
214
packages/ffm-js/test/api.ts
Normal file
214
packages/ffm-js/test/api.ts
Normal file
|
@ -0,0 +1,214 @@
|
|||
import assert from 'assert';
|
||||
import * as mfm from '../src/index';
|
||||
import {
|
||||
TEXT, CENTER, FN, UNI_EMOJI, MENTION, EMOJI_CODE, HASHTAG, N_URL, BOLD, SMALL, ITALIC, STRIKE, QUOTE, MATH_BLOCK, SEARCH, CODE_BLOCK, LINK
|
||||
} from '../src/index';
|
||||
|
||||
describe('API', () => {
|
||||
describe('toString', () => {
|
||||
test('basic', () => {
|
||||
const input =
|
||||
`before
|
||||
<center>
|
||||
Hello $[tada everynyan! 🎉]
|
||||
|
||||
I'm @ai, A bot of misskey!
|
||||
|
||||
https://github.com/syuilo/ai
|
||||
</center>
|
||||
after`;
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('single node', () => {
|
||||
const input = '$[tada Hello]';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)[0]), '$[tada Hello]');
|
||||
});
|
||||
|
||||
test('quote', () => {
|
||||
const input = `
|
||||
> abc
|
||||
>
|
||||
> 123
|
||||
`;
|
||||
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), '> abc\n> \n> 123');
|
||||
});
|
||||
|
||||
|
||||
test('search', () => {
|
||||
const input = 'MFM 書き方 123 Search';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('block code', () => {
|
||||
const input = '```\nabc\n```';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('math block', () => {
|
||||
const input = '\\[\ny = 2x + 1\n\\]';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('center', () => {
|
||||
const input = '<center>\nabc\n</center>';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
// test('center (single line)', () => {
|
||||
// const input = '<center>abc</center>';
|
||||
// assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
// });
|
||||
|
||||
test('emoji code', () => {
|
||||
const input = ':abc:';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('unicode emoji', () => {
|
||||
const input = '今起きた😇';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('big', () => {
|
||||
const input = '***abc***';
|
||||
const output = '$[tada abc]';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), output);
|
||||
});
|
||||
|
||||
test('bold', () => {
|
||||
const input = '**abc**';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
// test('bold tag', () => {
|
||||
// const input = '<b>abc</b>';
|
||||
// assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
// });
|
||||
|
||||
test('small', () => {
|
||||
const input = '<small>abc</small>';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
// test('italic', () => {
|
||||
// const input = '*abc*';
|
||||
// assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
// });
|
||||
|
||||
test('italic tag', () => {
|
||||
const input = '<i>abc</i>';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
test('strike', () => {
|
||||
const input = '~~foo~~';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
});
|
||||
|
||||
// test('strike tag', () => {
|
||||
// const input = '<s>foo</s>';
|
||||
// assert.strictEqual(mfm.toString(mfm.parse(input)), input);
|
||||
// });
|
||||
|
||||
test('inline code', () => {
|
||||
const input = 'AiScript: `#abc = 2`';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), 'AiScript: `#abc = 2`');
|
||||
});
|
||||
|
||||
test('math inline', () => {
|
||||
const input = '\\(y = 2x + 3\\)';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), '\\(y = 2x + 3\\)');
|
||||
});
|
||||
|
||||
test('hashtag', () => {
|
||||
const input = 'a #misskey b';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), 'a #misskey b');
|
||||
});
|
||||
|
||||
test('link', () => {
|
||||
const input = '[Ai](https://github.com/syuilo/ai)';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), '[Ai](https://github.com/syuilo/ai)');
|
||||
});
|
||||
|
||||
test('silent link', () => {
|
||||
const input = '?[Ai](https://github.com/syuilo/ai)';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), '?[Ai](https://github.com/syuilo/ai)');
|
||||
});
|
||||
|
||||
test('fn', () => {
|
||||
const input = '$[tada Hello]';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), '$[tada Hello]');
|
||||
});
|
||||
|
||||
test('fn with arguments', () => {
|
||||
const input = '$[spin.speed=1s,alternate Hello]';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), '$[spin.speed=1s,alternate Hello]');
|
||||
});
|
||||
|
||||
test('plain', () => {
|
||||
const input = 'a\n<plain>\nHello\nworld\n</plain>\nb';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), 'a\n<plain>\nHello\nworld\n</plain>\nb');
|
||||
});
|
||||
|
||||
test('1 line plain', () => {
|
||||
const input = 'a\n<plain>Hello</plain>\nb';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input)), 'a\n<plain>\nHello\n</plain>\nb');
|
||||
});
|
||||
|
||||
test('preserve url brackets', () => {
|
||||
const input1 = 'https://github.com/syuilo/ai';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input1)), input1);
|
||||
|
||||
const input2 = '<https://github.com/syuilo/ai>';
|
||||
assert.strictEqual(mfm.toString(mfm.parse(input2)), input2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
test('replace text', () => {
|
||||
const input = 'good morning $[tada everynyan!]';
|
||||
const result = mfm.parse(input);
|
||||
mfm.inspect(result, node => {
|
||||
if (node.type == 'text') {
|
||||
node.props.text = node.props.text.replace(/good morning/g, 'hello');
|
||||
}
|
||||
});
|
||||
assert.strictEqual(mfm.toString(result), 'hello $[tada everynyan!]');
|
||||
});
|
||||
|
||||
test('replace text (one item)', () => {
|
||||
const input = 'good morning $[tada everyone!]';
|
||||
const result = mfm.parse(input);
|
||||
mfm.inspect(result[1], node => {
|
||||
if (node.type == 'text') {
|
||||
node.props.text = node.props.text.replace(/one/g, 'nyan');
|
||||
}
|
||||
});
|
||||
assert.strictEqual(mfm.toString(result), 'good morning $[tada everynyan!]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract', () => {
|
||||
test('basic', () => {
|
||||
const nodes = mfm.parse('@hoge @piyo @bebeyo');
|
||||
const expect = [
|
||||
MENTION('hoge', null, '@hoge'),
|
||||
MENTION('piyo', null, '@piyo'),
|
||||
MENTION('bebeyo', null, '@bebeyo')
|
||||
];
|
||||
assert.deepStrictEqual(mfm.extract(nodes, node => node.type == 'mention'), expect);
|
||||
});
|
||||
|
||||
test('nested', () => {
|
||||
const nodes = mfm.parse('abc:hoge:$[tada 123 @hoge :foo:]:piyo:');
|
||||
const expect = [
|
||||
EMOJI_CODE('hoge'),
|
||||
EMOJI_CODE('foo'),
|
||||
EMOJI_CODE('piyo')
|
||||
];
|
||||
assert.deepStrictEqual(mfm.extract(nodes, node => node.type == 'emojiCode'), expect);
|
||||
});
|
||||
});
|
||||
});
|
1507
packages/ffm-js/test/parser.ts
Normal file
1507
packages/ffm-js/test/parser.ts
Normal file
File diff suppressed because it is too large
Load diff
26
packages/ffm-js/tsconfig.json
Normal file
26
packages/ffm-js/tsconfig.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "./built/",
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitReturns": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"src/@types",
|
||||
],
|
||||
"include": [
|
||||
"src/**/*",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"test/**/*",
|
||||
]
|
||||
}
|
Loading…
Reference in a new issue