Added ffm-js

This commit is contained in:
Sal Rahman 2023-08-14 01:35:38 -07:00
parent 29bb1453fc
commit 8c4b386a99
39 changed files with 15271 additions and 0 deletions

View 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

View file

@ -0,0 +1,8 @@
node_modules
/built
/coverage
/.eslintrc.js
/jest.config.ts
parser.js
/test
/test-d

View 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,
}],
},
};

View 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

View 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 -->

View 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

View 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

View 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
View file

@ -0,0 +1,13 @@
# npm
node_modules
# editor
.vscode
# app dir
built
temp
coverage
src/internal/parser.js

View 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.

View 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.

View 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
View 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
View 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).

View 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
// },
//
// . . .
}
}
}

View file

@ -0,0 +1,2 @@
codecov:
token: fcfdc680-8bb5-4185-ad31-22b1e1e4c207

View 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 🤗

View 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');
});
```

View file

@ -0,0 +1,7 @@
## 目次
### [API](api.md)
ライブラリが提供している関数や型定義などについての説明です。
### [MFM構文とード構造の仕様](syntax.md)
サポートしているMFM構文やード構造についての説明です。

View 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を適用する。
- `>`の後に続く01文字のスペースを無視する。
- 隣接する引用の行は一つになる。
- 複数行の引用では空行も含めることができる。
- 引用の後ろにある空行は無視される。([#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'
}
}
```

View 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)
```

View 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

File diff suppressed because it is too large Load diff

View 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"
]
}

View file

@ -0,0 +1,4 @@
declare module 'twemoji-parser/dist/lib/regex' {
const regex: RegExp;
export default regex;
}

View 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;
}

View 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'));
});
});
}

View 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);
});

View 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);
});

View 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';

View 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;
}

View 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);
}

View 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,
});

View 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
View 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;

View 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
View 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);
});
});
});

File diff suppressed because it is too large Load diff

View 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/**/*",
]
}