@traq-markdown-engine/traq-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 traP
4
+ Copyright (c) 2026 東京工業大学デジタル創作同好会traP
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # traq-plugin: traP Markdown extensions
2
+
3
+ traP 固有の Markdown 拡張部品を提供します。traQ 向けの組み合わせと配布は
4
+ [SDK](../../sdk/README.md) が所有します。
5
+
6
+ | crate | 責務 |
7
+ | -------------------------- | ----------------------------------------- |
8
+ | `markdown-trap-contracts` | スタンプ、参照、spoiler、空行のノード契約 |
9
+ | `markdown-trap-syntax` | traP 拡張の構文解析 |
10
+ | `markdown-trap-text` | traP ノードのテキスト描画 |
11
+ | `markdown-trap-extraction` | 参照などの抽出 |
12
+
13
+ 各ルールの Plugin を、利用側の GrammarBuilder / PresetBuilder に追加して使います。
14
+ 文法の選択と順序、通知 URL の表示方針、処理結果の組み合わせは利用側で決めます。
15
+ [SDK の構成・実行例](../../sdk/crates/processing)
16
+ を参照してください。
17
+
18
+ ## 開発
19
+
20
+ 共通の環境準備と検証は [ルートの CONTRIBUTING](../../../CONTRIBUTING.md) に従います。依存する
21
+ [core](../../core/README.md) と
22
+ [commonmark-plugin](../commonmark/README.md) は同じ workspace のローカルパスから参照します。SDK の構成・配布には依存しません。
23
+
24
+ ## TypeScript / HTML rendering
25
+
26
+ TypeScript の実装もこのリポジトリの責務に合わせて配置しています。
27
+
28
+ | TypeScript package | 責務 |
29
+ | ----------------------------------------- | ---------------------------------------------------------------------- |
30
+ | `@traq-markdown-engine/core` | 共通 AST 型、HTML handler・Plugin・PresetBuilder、契約検証と生成の基盤 |
31
+ | `@traq-markdown-engine/commonmark-plugin` | CommonMark・汎用拡張の生成ノード型と HTML 描画 |
32
+ | `@traq-markdown-engine/traq-plugin` | traP の生成ノード型・参照・スタンプ等の HTML 描画 |
33
+ | `@traq-markdown-engine/sdk` | Wasm / Go / TypeScript 配布、traQ の描画構成・preview・CSS |
34
+
35
+ AST の共通形は core の `typescript/ast.ts` に一度だけ定義し、SDK の生成 bindings はそれを構文の union で特殊化します。構文の payload は Rust を正として生成し、commonmark-plugin と traq-plugin の `bun run generate:bindings` でそれぞれの契約 crate から再生成できます。
36
+
37
+ HTML API は `/renderer` サブパスです。traQ は `@traq-markdown-engine/sdk/renderer` の `messageRenderers`、CSS は `@traq-markdown-engine/sdk/index.css` を利用します。
38
+
39
+ ## Go contracts
40
+
41
+ The Go module is `github.com/uni-kakurenbo/traq-markdown-engine/packages/plugins/traq/go`. Payloads and node factories are generated from this repository's Rust contracts by `bun run generate:bindings`. The canonical tree and Wasm runtime belong to the core Go module.
@@ -0,0 +1,49 @@
1
+ export type BlankLineData = Record<symbol, never>;
2
+ export type EmbeddingData = {
3
+ type: EmbeddingKind;
4
+ id: string;
5
+ label: string;
6
+ /**
7
+ * Original JSON notation, available to renderers that display it as text.
8
+ */
9
+ literal: string;
10
+ };
11
+ export type EmbeddingKind = 'file' | 'message';
12
+ export type ReferenceData = {
13
+ type: ReferenceKind;
14
+ id: string;
15
+ label: string;
16
+ };
17
+ export type ReferenceKind = 'user' | 'group' | 'channel';
18
+ export type SpoilerData = Record<symbol, never>;
19
+ export type StampData = {
20
+ literal: string;
21
+ };
22
+ export type NodeKind = {
23
+ kind: 'markdown_trap_contracts::compat::BlankLineData';
24
+ data: BlankLineData;
25
+ } | {
26
+ kind: 'markdown_trap_contracts::embedding::EmbeddingData';
27
+ data: EmbeddingData;
28
+ } | {
29
+ kind: 'markdown_trap_contracts::reference::ReferenceData';
30
+ data: ReferenceData;
31
+ } | {
32
+ kind: 'markdown_trap_contracts::spoiler::SpoilerData';
33
+ data: SpoilerData;
34
+ } | {
35
+ kind: 'markdown_trap_contracts::stamp::StampData';
36
+ data: StampData;
37
+ };
38
+ export declare const names: Readonly<{
39
+ readonly BlankLine: 'markdown_trap_contracts::compat::BlankLineData';
40
+ readonly Embedding: 'markdown_trap_contracts::embedding::EmbeddingData';
41
+ readonly Reference: 'markdown_trap_contracts::reference::ReferenceData';
42
+ readonly Spoiler: 'markdown_trap_contracts::spoiler::SpoilerData';
43
+ readonly Stamp: 'markdown_trap_contracts::stamp::StampData';
44
+ }>;
45
+ export declare const nodes: ReadonlyMap<string, (data: unknown) => boolean>;
46
+ export declare function isKnownNode<T extends {
47
+ kind: string;
48
+ data: unknown;
49
+ }>(node: T): node is T & NodeKind;
@@ -0,0 +1,41 @@
1
+ // Generated from Rust node payload types. Do not edit.
2
+ import { fields, oneOf, string } from '@traq-markdown-engine/core/validation';
3
+ export const names = Object.freeze({
4
+ BlankLine: 'markdown_trap_contracts::compat::BlankLineData',
5
+ Embedding: 'markdown_trap_contracts::embedding::EmbeddingData',
6
+ Reference: 'markdown_trap_contracts::reference::ReferenceData',
7
+ Spoiler: 'markdown_trap_contracts::spoiler::SpoilerData',
8
+ Stamp: 'markdown_trap_contracts::stamp::StampData'
9
+ });
10
+ const validators = new Map([
11
+ [
12
+ 'markdown_trap_contracts::compat::BlankLineData',
13
+ value => fields(value, {}, {})
14
+ ],
15
+ [
16
+ 'markdown_trap_contracts::embedding::EmbeddingData',
17
+ value => fields(value, {
18
+ id: string,
19
+ label: string,
20
+ literal: string,
21
+ type: oneOf('file', 'message')
22
+ }, {})
23
+ ],
24
+ [
25
+ 'markdown_trap_contracts::reference::ReferenceData',
26
+ value => fields(value, { id: string, label: string, type: oneOf('user', 'group', 'channel') }, {})
27
+ ],
28
+ [
29
+ 'markdown_trap_contracts::spoiler::SpoilerData',
30
+ value => fields(value, {}, {})
31
+ ],
32
+ [
33
+ 'markdown_trap_contracts::stamp::StampData',
34
+ value => fields(value, { literal: string }, {})
35
+ ]
36
+ ]);
37
+ export const nodes = new Map(validators);
38
+ // Check this payload only; children can still contain unknown nodes.
39
+ export function isKnownNode(node) {
40
+ return validators.get(node.kind)?.(node.data) ?? false;
41
+ }
@@ -0,0 +1,4 @@
1
+ import { Plugin } from '@traq-markdown-engine/core/renderer';
2
+ import type { Options } from './types.js';
3
+ export type { Options, Store } from './types.js';
4
+ export declare function plugin({ store, baseUrl, validateLink }?: Options): Plugin;
@@ -0,0 +1,18 @@
1
+ import { validateLink as defaultPolicy } from '@traq-markdown-engine/commonmark-plugin/policy';
2
+ import { Plugin as Declaration } from '@traq-markdown-engine/core/definitions';
3
+ import { checked, escapeHtml } from '@traq-markdown-engine/core/html';
4
+ import { Plugin } from '@traq-markdown-engine/core/renderer';
5
+ import { isKnownNode, names } from '@traq-markdown-engine/traq-plugin/nodes';
6
+ import { renderReference } from './reference.js';
7
+ import { stampRenderer } from './stamp.js';
8
+ const declaration = Declaration.group('trap').new('presentation');
9
+ export function plugin({ store, baseUrl, validateLink = defaultPolicy } = {}) {
10
+ const result = new Plugin(declaration);
11
+ result.on(names.Reference, checked(names.Reference, isKnownNode, node => renderReference(node, store, validateLink)));
12
+ result.on(names.Spoiler, checked(names.Spoiler, isKnownNode, (n, ctx) => '<span class="spoiler">' + ctx.render(n.children) + '</span>'));
13
+ result.on(names.BlankLine, checked(names.BlankLine, isKnownNode, () => '<br>\n'));
14
+ result.on(names.Embedding, checked(names.Embedding, isKnownNode, node => escapeHtml(node.data.literal)));
15
+ const stamp = stampRenderer({ store, baseUrl });
16
+ result.on(names.Stamp, checked(names.Stamp, isKnownNode, node => stamp(node.data.literal)));
17
+ return result;
18
+ }
@@ -0,0 +1,6 @@
1
+ import type { Node } from '@traq-markdown-engine/core/renderer';
2
+ import type { ReferenceData } from '@traq-markdown-engine/traq-plugin/nodes';
3
+ import type { Options } from './types.js';
4
+ export declare function renderReference(node: Node & {
5
+ data: ReferenceData;
6
+ }, store: Options['store'], validateLink: (destination: string) => boolean): string;
@@ -0,0 +1,27 @@
1
+ import { attributes, escapeHtml } from '@traq-markdown-engine/core/html';
2
+ export function renderReference(node, store, validateLink) {
3
+ const { type, id, label } = node.data;
4
+ if (!store)
5
+ return escapeHtml(label);
6
+ const me = store.getMe?.();
7
+ const href = type === 'user'
8
+ ? store.generateUserHref?.(id)
9
+ : type === 'group'
10
+ ? store.generateUserGroupHref?.(id)
11
+ : store.generateChannelHref?.(id);
12
+ if (!href || !validateLink(href))
13
+ return escapeHtml(label);
14
+ const highlight = type === 'user'
15
+ ? id === me?.id
16
+ : type === 'group' &&
17
+ (store.getUserGroup?.(id)?.members?.some(u => u.id === me?.id) ?? false);
18
+ const cls = `message-${type}-link`;
19
+ return ('<a' +
20
+ attributes([
21
+ ['href', href],
22
+ ['class', highlight ? `${cls}-highlight ${cls}` : cls]
23
+ ]) +
24
+ '>' +
25
+ escapeHtml(label) +
26
+ '</a>');
27
+ }
@@ -0,0 +1,4 @@
1
+ export declare const animeEffects: readonly ['rotate', 'rotate-inv', 'wiggle', 'parrot', 'zoom', 'inversion', 'turn', 'turn-v', 'happa', 'pyon', 'flashy', 'pull', 'atsumori', 'stretch', 'stretch-v', 'conga', 'conga-inv', 'marquee', 'marquee-inv', 'rainbow', 'ascension', 'shake', 'party', 'attract'];
2
+ export declare const sizeEffects: readonly ['ex-large', 'large', 'small'];
3
+ export type AnimeEffect = (typeof animeEffects)[number];
4
+ export type SizeEffect = (typeof sizeEffects)[number];
@@ -0,0 +1,27 @@
1
+ export const animeEffects = [
2
+ 'rotate',
3
+ 'rotate-inv',
4
+ 'wiggle',
5
+ 'parrot',
6
+ 'zoom',
7
+ 'inversion',
8
+ 'turn',
9
+ 'turn-v',
10
+ 'happa',
11
+ 'pyon',
12
+ 'flashy',
13
+ 'pull',
14
+ 'atsumori',
15
+ 'stretch',
16
+ 'stretch-v',
17
+ 'conga',
18
+ 'conga-inv',
19
+ 'marquee',
20
+ 'marquee-inv',
21
+ 'rainbow',
22
+ 'ascension',
23
+ 'shake',
24
+ 'party',
25
+ 'attract'
26
+ ];
27
+ export const sizeEffects = ['ex-large', 'large', 'small'];
@@ -0,0 +1,2 @@
1
+ import type { Options } from './types.js';
2
+ export declare function stampRenderer({ store, baseUrl }?: Options): (raw: string) => string;
@@ -0,0 +1,106 @@
1
+ import { validateLink } from '@traq-markdown-engine/commonmark-plugin/policy';
2
+ import { escapeHtml } from '@traq-markdown-engine/core/html';
3
+ import { animeEffects, sizeEffects } from './stamp-effects.js';
4
+ const animeEffectSet = new Set(animeEffects);
5
+ const sizeEffectSet = new Set(sizeEffects);
6
+ const animeEffectAliasMap = new Map([
7
+ ['marquee', 'conga'],
8
+ ['marquee-inv', 'conga-inv']
9
+ ]);
10
+ const maxEffectCount = 5;
11
+ const wrapWithEffect = (stampHtml, animeEffects, sizeEffect) => {
12
+ const filterOpenTag = animeEffects
13
+ .map((e, i) => `<span class="emoji-effect ${e}${i === 0 && sizeEffect ? ` ${sizeEffect}` : ''}">`)
14
+ .join('');
15
+ const filterCloseTag = '</span>'.repeat(animeEffects.length);
16
+ return filterOpenTag + stampHtml + filterCloseTag;
17
+ };
18
+ const isSizeEffect = (e) => sizeEffectSet.has(e);
19
+ const isAnimeEffect = (e) => animeEffectSet.has(e);
20
+ const renderStampDomWithStyle = (rawMatch, stampName, imgTitle, style, effects) => {
21
+ const escapedTitle = escapeHtml(imgTitle);
22
+ const escapedStyle = escapeHtml(style);
23
+ const escapedName = escapeHtml(stampName);
24
+ const sizeEffects = effects.filter(isSizeEffect);
25
+ const animeEffects = effects.filter(isAnimeEffect);
26
+ // 知らないエフェクトはダメ
27
+ if (sizeEffects.length + animeEffects.length < effects.length) {
28
+ return escapeHtml(rawMatch);
29
+ }
30
+ // アニメーション系エフェクトは5つまで
31
+ if (animeEffects.length > maxEffectCount) {
32
+ return escapeHtml(rawMatch);
33
+ }
34
+ // aliasの置き換え
35
+ const replacedAnimeEffects = animeEffects.map(e => animeEffectAliasMap.get(e) ?? e);
36
+ // 複数サイズ指定が合った場合は最後のものを適用
37
+ const sizeEffectClass = sizeEffects[sizeEffects.length - 1] || '';
38
+ const stampHtml = `<i class="emoji message-emoji ${sizeEffectClass}" title=":${escapedTitle}:" style="${escapedStyle};">:${escapedName}:</i>`;
39
+ return wrapWithEffect(stampHtml, replacedAnimeEffects, sizeEffectClass);
40
+ };
41
+ const renderStampDom = (rawMatch, stampName, imgTitle, imgUrl, effects) => {
42
+ if (!validateLink(imgUrl) || /^(?:mailto|ftp):/i.test(imgUrl)) {
43
+ return escapeHtml(rawMatch);
44
+ }
45
+ const safeUrl = imgUrl.replace(/[\s"'()\\]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
46
+ return renderStampDomWithStyle(rawMatch, stampName, imgTitle, 'background-image: url(' + safeUrl + ')', effects);
47
+ };
48
+ const stampReg = /^[a-zA-Z0-9+_-]{1,32}$/;
49
+ const hslReg = /(?<color>hsl\(\d+,\s*[\d]+(?:\.[\d]+)?%,\s*[\d]+(?:\.[\d]+)?%\))(?<effects>.*)/;
50
+ const hexReg = /0x(?<color>[0-9a-fA-F]{6})(?<effects>.*)/;
51
+ const renderHslStamp = (match) => {
52
+ // HSL: hsl(..., ...%, ...%)
53
+ const { color = '', effects = '' } = match.groups ?? {};
54
+ return renderStampDomWithStyle(`:${match[0]}:`, color, color, `background-color: ${color}`, effects === '' ? [] : effects.split('.').slice(1));
55
+ };
56
+ const renderHexStamp = (match) => {
57
+ // Hex: 0x......
58
+ const { color = '', effects = '' } = match.groups ?? {};
59
+ return renderStampDomWithStyle(`:${match[0]}:`, `0x${color}`, `0x${color}`, `background-color: #${color}`, effects === '' ? [] : effects.split('.').slice(1));
60
+ };
61
+ export function stampRenderer({ store, baseUrl = '' } = {}) {
62
+ const getStampImageUrl = (fileId) => {
63
+ if (store?.generateStampHref) {
64
+ return store.generateStampHref(fileId);
65
+ }
66
+ return `${baseUrl}/api/v3/files/${encodeURIComponent(fileId)}`;
67
+ };
68
+ const renderUserStamp = (stampName, raw, effects) => {
69
+ // 先頭の@を除いたものがユーザー名
70
+ const userName = stampName.slice(1);
71
+ const user = store?.getUserByName?.(userName);
72
+ if (!user) {
73
+ return escapeHtml(raw);
74
+ }
75
+ return renderStampDom(raw, stampName, stampName, getStampImageUrl(user.iconFileId), effects);
76
+ };
77
+ const renderNormalStamp = (stampName, raw, effects) => {
78
+ const stamp = store?.getStampByName?.(stampName);
79
+ if (!stamp) {
80
+ return escapeHtml(raw);
81
+ }
82
+ return renderStampDom(raw, stampName, stamp.name, getStampImageUrl(stamp.fileId), effects);
83
+ };
84
+ const renderStamp = (raw) => {
85
+ const inner = raw.slice(1, -1);
86
+ const hexMatch = hexReg.exec(inner);
87
+ if (hexMatch) {
88
+ return renderHexStamp(hexMatch);
89
+ }
90
+ const hslMatch = hslReg.exec(inner);
91
+ if (hslMatch) {
92
+ return renderHslStamp(hslMatch);
93
+ }
94
+ const [stampName, ...effects] = inner.split('.');
95
+ // ユーザーアイコン
96
+ if (stampName.startsWith('@')) {
97
+ return renderUserStamp(stampName, raw, effects);
98
+ }
99
+ if (!stampReg.exec(stampName)) {
100
+ return escapeHtml(raw);
101
+ }
102
+ // 通常スタンプ
103
+ return renderNormalStamp(stampName, raw, effects);
104
+ };
105
+ return renderStamp;
106
+ }
@@ -0,0 +1,26 @@
1
+ export interface Store {
2
+ getMe?(): Readonly<{
3
+ id: string;
4
+ }> | undefined;
5
+ getUserGroup?(id: string): Readonly<{
6
+ members: readonly Readonly<{
7
+ id: string;
8
+ }>[];
9
+ }> | undefined;
10
+ getStampByName?(name: string): Readonly<{
11
+ name: string;
12
+ fileId: string;
13
+ }> | undefined;
14
+ getUserByName?(name: string): Readonly<{
15
+ iconFileId: string;
16
+ }> | undefined;
17
+ generateUserHref?(id: string): string;
18
+ generateUserGroupHref?(id: string): string;
19
+ generateChannelHref?(id: string): string;
20
+ generateStampHref?(fileId: string): string;
21
+ }
22
+ export interface Options {
23
+ store?: Readonly<Store>;
24
+ baseUrl?: string;
25
+ validateLink?(destination: string): boolean;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@traq-markdown-engine/traq-plugin",
3
+ "version": "0.1.0",
4
+ "description": "traQ-specific Markdown contracts and HTML rendering.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/uni-kakurenbo/traq-markdown-engine.git",
8
+ "directory": "packages/plugins/traq"
9
+ },
10
+ "license": "MIT",
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "exports": {
18
+ "./nodes": {
19
+ "types": "./dist/generated/trap.d.ts",
20
+ "import": "./dist/generated/trap.js"
21
+ },
22
+ "./renderer": {
23
+ "types": "./dist/renderer/index.d.ts",
24
+ "import": "./dist/renderer/index.js"
25
+ },
26
+ "./stamp-effects": {
27
+ "types": "./dist/renderer/stamp-effects.d.ts",
28
+ "import": "./dist/renderer/stamp-effects.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "build": "bun run ../../../scripts/tsc.ts -p typescript/tsconfig.build.json",
37
+ "generate:bindings": "bun run ../../../scripts/generate-bindings.ts traq-plugin",
38
+ "typecheck": "bun run ../../../scripts/tsc.ts -p typescript/tsconfig.json"
39
+ },
40
+ "devDependencies": {
41
+ "@traq-markdown-engine/commonmark-plugin": "workspace:*",
42
+ "@traq-markdown-engine/core": "workspace:*"
43
+ },
44
+ "peerDependencies": {
45
+ "@traq-markdown-engine/commonmark-plugin": "0.1.0",
46
+ "@traq-markdown-engine/core": "0.1.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=24"
50
+ }
51
+ }