From d4f9d729d3d64883e9e62242a1432402212cdbf8 Mon Sep 17 00:00:00 2001 From: Luke Hagar Date: Fri, 31 Oct 2025 21:48:44 -0500 Subject: [PATCH] chore: update dependencies and full integrate markdown formatting with official prettier implementations --- bun.lock | 110 +- package.json | 20 +- src/index.ts | 1305 ++++++++++------- src/prettier-markdown/README.md | 54 + .../adapter-document-builders.js | 55 + .../adapter-document-constants.js | 33 + .../adapter-document-utils.js | 39 + src/prettier-markdown/adapter-pragma.js | 48 + .../adapter-prettier-internals.js | 130 ++ .../adapter-prettier-utils.js | 72 + src/prettier-markdown/clean.js | 91 ++ src/prettier-markdown/constants.evaluate.js | 86 ++ src/prettier-markdown/embed.js | 87 ++ src/prettier-markdown/format-markdown.js | 123 ++ src/prettier-markdown/format-markdown.ts | 110 ++ src/prettier-markdown/get-visitor-keys.js | 6 + src/prettier-markdown/index.js | 8 + src/prettier-markdown/languages.evaluate.js | 20 + src/prettier-markdown/loc.js | 4 + src/prettier-markdown/mdx.js | 83 ++ src/prettier-markdown/options.js | 20 + src/prettier-markdown/parser-markdown.js | 57 + src/prettier-markdown/pragma.js | 25 + src/prettier-markdown/print-paragraph.js | 55 + src/prettier-markdown/print-preprocess.js | 256 ++++ src/prettier-markdown/print-sentence.js | 37 + src/prettier-markdown/print-whitespace.js | 266 ++++ src/prettier-markdown/print/table.js | 81 + src/prettier-markdown/printer-markdown.js | 810 ++++++++++ .../unified-plugins/front-matter.js | 23 + .../unified-plugins/html-to-jsx.js | 19 + .../unified-plugins/liquid.js | 27 + .../unified-plugins/wiki-link.js | 32 + src/prettier-markdown/utils.js | 268 ++++ src/prettier-markdown/visitor-keys.js | 41 + test/markdown-formatting.test.ts | 176 +++ 36 files changed, 4054 insertions(+), 623 deletions(-) create mode 100644 src/prettier-markdown/README.md create mode 100644 src/prettier-markdown/adapter-document-builders.js create mode 100644 src/prettier-markdown/adapter-document-constants.js create mode 100644 src/prettier-markdown/adapter-document-utils.js create mode 100644 src/prettier-markdown/adapter-pragma.js create mode 100644 src/prettier-markdown/adapter-prettier-internals.js create mode 100644 src/prettier-markdown/adapter-prettier-utils.js create mode 100644 src/prettier-markdown/clean.js create mode 100644 src/prettier-markdown/constants.evaluate.js create mode 100644 src/prettier-markdown/embed.js create mode 100644 src/prettier-markdown/format-markdown.js create mode 100644 src/prettier-markdown/format-markdown.ts create mode 100644 src/prettier-markdown/get-visitor-keys.js create mode 100644 src/prettier-markdown/index.js create mode 100644 src/prettier-markdown/languages.evaluate.js create mode 100644 src/prettier-markdown/loc.js create mode 100644 src/prettier-markdown/mdx.js create mode 100644 src/prettier-markdown/options.js create mode 100644 src/prettier-markdown/parser-markdown.js create mode 100644 src/prettier-markdown/pragma.js create mode 100644 src/prettier-markdown/print-paragraph.js create mode 100644 src/prettier-markdown/print-preprocess.js create mode 100644 src/prettier-markdown/print-sentence.js create mode 100644 src/prettier-markdown/print-whitespace.js create mode 100644 src/prettier-markdown/print/table.js create mode 100644 src/prettier-markdown/printer-markdown.js create mode 100644 src/prettier-markdown/unified-plugins/front-matter.js create mode 100644 src/prettier-markdown/unified-plugins/html-to-jsx.js create mode 100644 src/prettier-markdown/unified-plugins/liquid.js create mode 100644 src/prettier-markdown/unified-plugins/wiki-link.js create mode 100644 src/prettier-markdown/utils.js create mode 100644 src/prettier-markdown/visitor-keys.js create mode 100644 test/markdown-formatting.test.ts diff --git a/bun.lock b/bun.lock index 5e946e9..d3872db 100644 --- a/bun.lock +++ b/bun.lock @@ -4,20 +4,20 @@ "": { "name": "prettier-plugin-openapi", "dependencies": { - "@types/js-yaml": "^4.0.0", + "@types/js-yaml": "^4.0.9", "js-yaml": "^4.1.0", }, "devDependencies": { - "@biomejs/biome": "^2.2.4", - "@rollup/plugin-alias": "^5.1.1", - "@rollup/plugin-commonjs": "^28.0.6", - "@rollup/plugin-node-resolve": "^16.0.1", - "@types/bun": "latest", - "@types/node": "^20.0.0", - "prettier": "^3.0.0", - "rollup": "^4.52.2", + "@biomejs/biome": "^2.3.2", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/bun": "^1.3.1", + "@types/node": "^24.9.2", + "prettier": "^3.6.2", + "rollup": "^4.52.5", "rollup-plugin-typescript": "^1.0.1", - "typescript": "^5.0.0", + "typescript": "^5.9.3", }, "peerDependencies": { "prettier": "^3.0.0", @@ -25,93 +25,93 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="], + "@biomejs/biome": ["@biomejs/biome@2.3.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.2", "@biomejs/cli-darwin-x64": "2.3.2", "@biomejs/cli-linux-arm64": "2.3.2", "@biomejs/cli-linux-arm64-musl": "2.3.2", "@biomejs/cli-linux-x64": "2.3.2", "@biomejs/cli-linux-x64-musl": "2.3.2", "@biomejs/cli-win32-arm64": "2.3.2", "@biomejs/cli-win32-x64": "2.3.2" }, "bin": { "biome": "bin/biome" } }, "sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@rollup/plugin-alias": ["@rollup/plugin-alias@5.1.1", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ=="], + "@rollup/plugin-alias": ["@rollup/plugin-alias@6.0.0", "", { "peerDependencies": { "rollup": ">=4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g=="], - "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.6", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw=="], + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="], - "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA=="], + "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.2", "", { "os": "android", "cpu": "arm" }, "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.2", "", { "os": "linux", "cpu": "arm" }, "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.2", "", { "os": "linux", "cpu": "none" }, "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.2", "", { "os": "linux", "cpu": "none" }, "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.2", "", { "os": "linux", "cpu": "none" }, "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.5", "", { "os": "linux", "cpu": "none" }, "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.2", "", { "os": "linux", "cpu": "x64" }, "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.2", "", { "os": "linux", "cpu": "x64" }, "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.5", "", { "os": "linux", "cpu": "x64" }, "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.2", "", { "os": "none", "cpu": "arm64" }, "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.5", "", { "os": "none", "cpu": "arm64" }, "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], - "@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], - "@types/node": ["@types/node@20.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ=="], + "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], - "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -137,7 +137,7 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], - "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -145,9 +145,9 @@ "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], - "rollup": ["rollup@4.52.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.2", "@rollup/rollup-android-arm64": "4.52.2", "@rollup/rollup-darwin-arm64": "4.52.2", "@rollup/rollup-darwin-x64": "4.52.2", "@rollup/rollup-freebsd-arm64": "4.52.2", "@rollup/rollup-freebsd-x64": "4.52.2", "@rollup/rollup-linux-arm-gnueabihf": "4.52.2", "@rollup/rollup-linux-arm-musleabihf": "4.52.2", "@rollup/rollup-linux-arm64-gnu": "4.52.2", "@rollup/rollup-linux-arm64-musl": "4.52.2", "@rollup/rollup-linux-loong64-gnu": "4.52.2", "@rollup/rollup-linux-ppc64-gnu": "4.52.2", "@rollup/rollup-linux-riscv64-gnu": "4.52.2", "@rollup/rollup-linux-riscv64-musl": "4.52.2", "@rollup/rollup-linux-s390x-gnu": "4.52.2", "@rollup/rollup-linux-x64-gnu": "4.52.2", "@rollup/rollup-linux-x64-musl": "4.52.2", "@rollup/rollup-openharmony-arm64": "4.52.2", "@rollup/rollup-win32-arm64-msvc": "4.52.2", "@rollup/rollup-win32-ia32-msvc": "4.52.2", "@rollup/rollup-win32-x64-gnu": "4.52.2", "@rollup/rollup-win32-x64-msvc": "4.52.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA=="], + "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], "rollup-plugin-typescript": ["rollup-plugin-typescript@1.0.1", "", { "dependencies": { "resolve": "^1.10.0", "rollup-pluginutils": "^2.5.0" }, "peerDependencies": { "tslib": "*", "typescript": ">=2.1.0" } }, "sha512-rwJDNn9jv/NsKZuyBb/h0jsclP4CJ58qbvZt2Q9zDIGILF2LtdtvCqMOL+Gq9IVq5MTrTlHZNrn8h7VjQgd8tw=="], @@ -157,14 +157,10 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "bun-types/@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], } } diff --git a/package.json b/package.json index 0e50615..51ec3c6 100644 --- a/package.json +++ b/package.json @@ -71,19 +71,19 @@ "prettier": "^3.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.2.4", - "@rollup/plugin-alias": "^5.1.1", - "@rollup/plugin-commonjs": "^28.0.6", - "@rollup/plugin-node-resolve": "^16.0.1", - "@types/bun": "latest", - "@types/node": "^20.0.0", - "prettier": "^3.0.0", - "rollup": "^4.52.2", + "@biomejs/biome": "^2.3.2", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@types/bun": "^1.3.1", + "@types/node": "^24.9.2", + "prettier": "^3.6.2", + "rollup": "^4.52.5", "rollup-plugin-typescript": "^1.0.1", - "typescript": "^5.0.0" + "typescript": "^5.9.3" }, "dependencies": { - "@types/js-yaml": "^4.0.0", + "@types/js-yaml": "^4.0.9", "js-yaml": "^4.1.0" } } diff --git a/src/index.ts b/src/index.ts index 2326328..cd40fb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,47 +1,48 @@ -import * as yaml from 'js-yaml'; -import type { AstPath, Doc, Parser, ParserOptions, Printer, SupportLanguage } from 'prettier'; -import { getVendorExtensions } from './extensions/vendor-loader.js'; +import * as yaml from "js-yaml"; +import type { AstPath, Doc, Parser, ParserOptions, Printer, SupportLanguage } from "prettier"; +import { getVendorExtensions } from "./extensions/vendor-loader.js"; export type PrintFn = (path: AstPath) => Doc; import { - ComponentsKeys, - ContactKeys, - DiscriminatorKeys, - EncodingKeys, - ExampleKeys, - ExternalDocsKeys, - HeaderKeys, - InfoKeys, - LicenseKeys, - LinkKeys, - MediaTypeKeys, - OAuthFlowKeys, - OperationKeys, - ParameterKeys, - PathItemKeys, - RequestBodyKeys, - ResponseKeys, - RootKeys, - SchemaKeys, - SecuritySchemeKeys, - ServerKeys, - ServerVariableKeys, - TagKeys, - WebhookKeys, - XMLKeys, -} from './keys.js'; + ComponentsKeys, + ContactKeys, + DiscriminatorKeys, + EncodingKeys, + ExampleKeys, + ExternalDocsKeys, + HeaderKeys, + InfoKeys, + LicenseKeys, + LinkKeys, + MediaTypeKeys, + OAuthFlowKeys, + OperationKeys, + ParameterKeys, + PathItemKeys, + RequestBodyKeys, + ResponseKeys, + RootKeys, + SchemaKeys, + SecuritySchemeKeys, + ServerKeys, + ServerVariableKeys, + TagKeys, + WebhookKeys, + XMLKeys, +} from "./keys.js"; // Type definitions for better type safety interface OpenAPINode { - isOpenAPI: boolean; - content?: any; - format?: 'json' | 'yaml'; + isOpenAPI: boolean; + content?: any; + format?: "json" | "yaml"; } interface OpenAPIPluginOptions { - tabWidth?: number; - printWidth?: number; + tabWidth?: number; + printWidth?: number; + formatMarkdown?: boolean; // Option to enable/disable markdown formatting (default: true) } // Load vendor extensions @@ -51,70 +52,70 @@ const vendorExtensions = getVendorExtensions(); * Unified parser that can handle both JSON and YAML OpenAPI files */ function parseOpenAPIFile(text: string, options?: any): OpenAPINode { - let format: 'json' | 'yaml' | undefined; + let format: "json" | "yaml" | undefined; - if (options?.filepath) { - switch (true) { - case options?.filepath.endsWith('.yaml') || options?.filepath.endsWith('.yml'): - format = 'yaml'; - break; - case options?.filepath.endsWith('.json'): - format = 'json'; - break; - } + if (options?.filepath) { + switch (true) { + case options?.filepath.endsWith(".yaml") || options?.filepath.endsWith(".yml"): + format = "yaml"; + break; + case options?.filepath.endsWith(".json"): + format = "json"; + break; } + } - if (!format) { - // Try to detect format from content - const trimmedText = text.trim(); - if (trimmedText.startsWith('{') || trimmedText.startsWith('[')) { - format = 'json'; - } else { - format = 'yaml'; - } + if (!format) { + // Try to detect format from content + const trimmedText = text.trim(); + if (trimmedText.startsWith("{") || trimmedText.startsWith("[")) { + format = "json"; + } else { + format = "yaml"; } + } - let parsed: any; + let parsed: any; - try { - switch (format) { - case 'yaml': - parsed = yaml.load(text, { - schema: yaml.DEFAULT_SCHEMA, - }); - break; - case 'json': - parsed = JSON.parse(text); - break; - } - } catch (error) { - return { - isOpenAPI: false, - } + try { + switch (format) { + case "yaml": + parsed = yaml.load(text, { + schema: yaml.DEFAULT_SCHEMA, + }); + break; + case "json": + parsed = JSON.parse(text); + break; } + } catch (error) { + return { + isOpenAPI: false, + }; + } - let isOpenAPI: boolean; + let isOpenAPI: boolean; - try { - isOpenAPI = isOpenAPIFile(parsed, options?.filepath); - } catch (error) { - return { - isOpenAPI: false, - } - } + try { + isOpenAPI = isOpenAPIFile(parsed, options?.filepath); + } catch (error) { + return { + isOpenAPI: false, + }; + } - switch (isOpenAPI) { - case true: - return { - isOpenAPI: true, - content: parsed, - format: format, - } - case false: - return { - isOpenAPI: false, - } - } + switch (isOpenAPI) { + case true: + return { + isOpenAPI: true, + content: parsed, + format: format, + }; + case false: + return { + isOpenAPI: false, + }; + } } // ============================================================================ @@ -125,456 +126,616 @@ function parseOpenAPIFile(text: string, options?: any): OpenAPINode { * Detects if a file is an OpenAPI-related file based on content and structure */ function isOpenAPIFile(content: any, filePath?: string): boolean { - if (!content || typeof content !== 'object') { - return false; - } - - // Check for root-level OpenAPI indicators (most important) - if (content.openapi || content.swagger) { - return true; - } - - // Check file path patterns for common OpenAPI file structures - // Only accept files in OpenAPI-related directories - if (filePath) { - const path = filePath.toLowerCase(); - - // Check for component directory patterns - if (path.includes('/components/') || - path.includes('/schemas/') || - path.includes('/parameters/') || - path.includes('/responses/') || - path.includes('/requestbodies/') || - path.includes('/headers/') || - path.includes('/examples/') || - path.includes('/securityschemes/') || - path.includes('/links/') || - path.includes('/callbacks/') || - path.includes('/webhooks/') || - path.includes('/paths/')) { - return true; - } - } - - // Check for component-like structures (only if we have strong indicators) - if (content.components || content.definitions || content.parameters || content.responses || content.securityDefinitions) { - return true; - } - - // Check for path-like structures (operations) - if (content.paths || isPathObject(content)) { - return true; - } - - // Check for schema-like structures (but be more strict) - // Only accept if we have strong schema indicators - if (isSchemaObject(content) && (content.$ref || content.allOf || content.oneOf || content.anyOf || content.not || content.properties || content.items)) { - return true; - } - - // Check for parameter-like structures - if (isParameterObject(content)) { - return true; - } - - // Check for response-like structures - if (isResponseObject(content)) { - return true; - } - - // Check for header-like structures (OpenAPI 3.0+) - if (isHeaderObject(content)) { - return true; - } - - // Check for link-like structures (OpenAPI 3.0+) - if (isLinkObject(content)) { - return true; - } - - // Check for request body-like structures (OpenAPI 3.0+) - if (isRequestBodyObject(content)) { - return true; - } - - // Check for security scheme-like structures - if (isSecuritySchemeObject(content)) { - return true; - } - - // Check for server-like structures - if (isServerObject(content)) { - return true; - } - - // Check for tag-like structures - if (isTagObject(content)) { - return true; - } - - // Check for external docs-like structures - if (isExternalDocsObject(content)) { - return true; - } - - // Check for webhook-like structures - if (isWebhookObject(content)) { - return true; - } - - // Additional strict check: reject objects that look like generic data - // If an object only has simple properties like name, age, etc. without any OpenAPI structure, reject it - const keys = Object.keys(content); - const hasOnlyGenericProperties = keys.every(key => - !key.startsWith('x-') && // Not a custom extension - !['openapi', 'swagger', 'info', 'paths', 'components', 'definitions', 'parameters', 'responses', 'securityDefinitions', 'tags', 'servers', 'webhooks'].includes(key) - ); - - if (hasOnlyGenericProperties) { - return false; - } - - // If none of the above conditions are met, it's not an OpenAPI file + if (!content || typeof content !== "object") { return false; + } + + // Check for root-level OpenAPI indicators (most important) + if (content.openapi || content.swagger) { + return true; + } + + // Check file path patterns for common OpenAPI file structures + // Only accept files in OpenAPI-related directories + if (filePath) { + const path = filePath.toLowerCase(); + + // Check for component directory patterns + if ( + path.includes("/components/") || + path.includes("/schemas/") || + path.includes("/parameters/") || + path.includes("/responses/") || + path.includes("/requestbodies/") || + path.includes("/headers/") || + path.includes("/examples/") || + path.includes("/securityschemes/") || + path.includes("/links/") || + path.includes("/callbacks/") || + path.includes("/webhooks/") || + path.includes("/paths/") + ) { + return true; + } + } + + // Check for component-like structures (only if we have strong indicators) + if ( + content.components || + content.definitions || + content.parameters || + content.responses || + content.securityDefinitions + ) { + return true; + } + + // Check for path-like structures (operations) + if (content.paths || isPathObject(content)) { + return true; + } + + // Check for schema-like structures (but be more strict) + // Only accept if we have strong schema indicators + if ( + isSchemaObject(content) && + (content.$ref || + content.allOf || + content.oneOf || + content.anyOf || + content.not || + content.properties || + content.items) + ) { + return true; + } + + // Check for parameter-like structures + if (isParameterObject(content)) { + return true; + } + + // Check for response-like structures + if (isResponseObject(content)) { + return true; + } + + // Check for header-like structures (OpenAPI 3.0+) + if (isHeaderObject(content)) { + return true; + } + + // Check for link-like structures (OpenAPI 3.0+) + if (isLinkObject(content)) { + return true; + } + + // Check for request body-like structures (OpenAPI 3.0+) + if (isRequestBodyObject(content)) { + return true; + } + + // Check for security scheme-like structures + if (isSecuritySchemeObject(content)) { + return true; + } + + // Check for server-like structures + if (isServerObject(content)) { + return true; + } + + // Check for tag-like structures + if (isTagObject(content)) { + return true; + } + + // Check for external docs-like structures + if (isExternalDocsObject(content)) { + return true; + } + + // Check for webhook-like structures + if (isWebhookObject(content)) { + return true; + } + + // Additional strict check: reject objects that look like generic data + // If an object only has simple properties like name, age, etc. without any OpenAPI structure, reject it + const keys = Object.keys(content); + const hasOnlyGenericProperties = keys.every( + (key) => + !key.startsWith("x-") && // Not a custom extension + ![ + "openapi", + "swagger", + "info", + "paths", + "components", + "definitions", + "parameters", + "responses", + "securityDefinitions", + "tags", + "servers", + "webhooks", + ].includes(key) + ); + + if (hasOnlyGenericProperties) { + return false; + } + + // If none of the above conditions are met, it's not an OpenAPI file + return false; } /** * Detects if an object represents a path with operations */ function isPathObject(obj: any): boolean { - if (!obj || typeof obj !== 'object') { - return false; - } + if (!obj || typeof obj !== "object") { + return false; + } - const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; - return Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); + const httpMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"]; + return Object.keys(obj).some((key) => httpMethods.includes(key.toLowerCase())); } export const languages: SupportLanguage[] = [ - { - name: 'openapi', - extensions: [ - // Accepting all JSON and YAML files so that component files used by $ref work - '.json', '.yaml', '.yml' - ], - parsers: ['openapi-parser'], - }, + { + name: "openapi", + extensions: [ + // Accepting all JSON and YAML files so that component files used by $ref work + ".json", + ".yaml", + ".yml", + ], + parsers: ["openapi-parser"], + }, ] as const; export const parsers: Record = { - 'openapi-parser': { - parse: (text: string, options?: any): OpenAPINode => { - return parseOpenAPIFile(text, options); - }, - astFormat: 'openapi-ast', - locStart: (node: OpenAPINode) => 0, - locEnd: (node: OpenAPINode) => node.content?.length || 0, + "openapi-parser": { + parse: (text: string, options?: any): OpenAPINode => { + return parseOpenAPIFile(text, options); }, -} + astFormat: "openapi-ast", + locStart: (node: OpenAPINode) => 0, + locEnd: (node: OpenAPINode) => node.content?.length || 0, + }, +}; export const printers: Record = { - 'openapi-ast': { - print: (path: AstPath, options: ParserOptions, print: PrintFn): string => { - const node = path.getNode(); - if (!node.isOpenAPI || node.isOpenAPI === false) { - // Return original text unchanged - return options.originalText; - } - return formatOpenAPI(node.content, node.format, options); - }, + "openapi-ast": { + print: (path: AstPath, options: ParserOptions, print: PrintFn): string => { + const node = path.getNode(); + if (!node.isOpenAPI || node.isOpenAPI === false) { + // Return original text unchanged + return options.originalText; + } + return formatOpenAPI(node.content, node.format, options); }, + }, +}; + +/** + * Formats markdown strings using Prettier's markdown parser and printer + * + * This uses Prettier's actual markdown formatting implementation, ensuring + * that markdown in OpenAPI descriptions is formatted exactly as Prettier would format it. + */ +function formatMarkdownSync(markdown: string, options?: OpenAPIPluginOptions): string { + if (!markdown || typeof markdown !== "string") { + return markdown; + } + + // Skip formatting if disabled + if (options?.formatMarkdown === false) { + return markdown; + } + + // Trim to avoid formatting whitespace-only strings + const trimmed = markdown.trim(); + if (trimmed.length === 0) { + return markdown; + } + + try { + // Use Prettier's markdown formatter + // Dynamic require to avoid issues during build + const formatModule = require("./prettier-markdown/format-markdown.js"); + const formatted = formatModule.formatMarkdown(trimmed, { + printWidth: options?.printWidth || 80, + tabWidth: options?.tabWidth || 2, + proseWrap: "preserve", + singleQuote: false, + }); + + return formatted; + } catch (error) { + // If Prettier's formatter fails, fall back to basic normalization + // This ensures we always return valid markdown + return trimmed; + } +} + +/** + * Recursively formats all description and summary fields that may contain markdown + */ +function formatMarkdownFields(obj: any, options?: OpenAPIPluginOptions): any { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => formatMarkdownFields(item, options)); + } + + const formatted: any = {}; + + for (const [key, value] of Object.entries(obj)) { + // Fields that commonly contain markdown in OpenAPI + const markdownFields = ["description", "summary"]; + + if (markdownFields.includes(key) && typeof value === "string" && value.trim().length > 0) { + // Format markdown synchronously + formatted[key] = formatMarkdownSync(value, options); + } else if (typeof value === "object" && value !== null) { + // Recursively process nested objects + formatted[key] = formatMarkdownFields(value, options); + } else { + // Keep other values as-is + formatted[key] = value; + } + } + + return formatted; } /** * Unified formatter that outputs in the detected format */ -function formatOpenAPI(content: any, format: 'json' | 'yaml', options?: OpenAPIPluginOptions): string { - // Sort keys for better organization - const sortedContent = sortOpenAPIKeys(content); +function formatOpenAPI( + content: any, + format: "json" | "yaml", + options?: OpenAPIPluginOptions +): string { + // Sort keys for better organization + const sortedContent = sortOpenAPIKeys(content); - switch (format) { - case 'json': - return JSON.stringify(sortedContent, null, options?.tabWidth || 2); - case 'yaml': - // Format YAML with proper indentation and line breaks - return yaml.dump(sortedContent, { - indent: options?.tabWidth || 2, - lineWidth: options?.printWidth || 80, - noRefs: true, - quotingType: '"', - forceQuotes: false, - }); - } + // Format markdown in description and summary fields + const contentWithFormattedMarkdown = formatMarkdownFields(sortedContent, options); + + switch (format) { + case "json": + return JSON.stringify(contentWithFormattedMarkdown, null, options?.tabWidth || 2); + case "yaml": + // Format YAML with proper indentation and line breaks + // Use lineWidth: -1 to disable automatic line wrapping for better markdown preservation + return yaml.dump(contentWithFormattedMarkdown, { + indent: options?.tabWidth || 2, + lineWidth: -1, // Disable line width to preserve markdown formatting + noRefs: true, + quotingType: '"', + forceQuotes: false, + }); + } } function sortOpenAPIKeys(obj: any): any { - // Special handling: if root object is a referenced OpenAPI object (for referenced files) - // Check for ref-able object types before checking for root-level keys - let contextKey = 'top-level'; - - // Skip detection if it's a full OpenAPI spec (has openapi/swagger) - if (!('openapi' in obj) && !('swagger' in obj)) { - // Check for all ref-able object types in priority order - // Check more specific types first to avoid false positives - if (isLinkObject(obj)) { - contextKey = 'link'; - } else if (isOperationObject(obj)) { - contextKey = 'operation'; - } else if (isSchemaObject(obj)) { - contextKey = 'schema'; - } else if (isParameterObject(obj)) { - contextKey = 'parameter'; - } else if (isResponseObject(obj)) { - contextKey = 'response'; - } else if (isHeaderObject(obj)) { - contextKey = 'header'; - } else if (isPathItemObject(obj)) { - contextKey = 'pathItem'; - } else if (isRequestBodyObject(obj)) { - contextKey = 'requestBody'; - } else { - // Fall back to standard context detection - contextKey = getContextKey("", obj); - } + // Special handling: if root object is a referenced OpenAPI object (for referenced files) + // Check for ref-able object types before checking for root-level keys + let contextKey = "top-level"; + + // Skip detection if it's a full OpenAPI spec (has openapi/swagger) + if (!("openapi" in obj) && !("swagger" in obj)) { + // Check for all ref-able object types in priority order + // Check more specific types first to avoid false positives + if (isLinkObject(obj)) { + contextKey = "link"; + } else if (isOperationObject(obj)) { + contextKey = "operation"; + } else if (isSchemaObject(obj)) { + contextKey = "schema"; + } else if (isParameterObject(obj)) { + contextKey = "parameter"; + } else if (isResponseObject(obj)) { + contextKey = "response"; + } else if (isHeaderObject(obj)) { + contextKey = "header"; + } else if (isPathItemObject(obj)) { + contextKey = "pathItem"; + } else if (isRequestBodyObject(obj)) { + contextKey = "requestBody"; } else { - // Determine what class of OpenAPI schema this is for full specs - contextKey = getContextKey("", obj); + // Fall back to standard context detection + contextKey = getContextKey("", obj); } + } else { + // Determine what class of OpenAPI schema this is for full specs + contextKey = getContextKey("", obj); + } - const standardKeys = getStandardKeysForContext(contextKey); - const customExtensions = vendorExtensions[contextKey] || {}; + const standardKeys = getStandardKeysForContext(contextKey); + const customExtensions = vendorExtensions[contextKey] || {}; - const sortedKeys = Object.keys(obj).sort((a, b) => { - // Use the unified sorting function - return sortKeys(a, b, standardKeys, customExtensions); - }); + const sortedKeys = Object.keys(obj).sort((a, b) => { + // Use the unified sorting function + return sortKeys(a, b, standardKeys, customExtensions); + }); - const sortedObj: any = {}; - for (const key of sortedKeys) { - sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], key); - } + const sortedObj: any = {}; + for (const key of sortedKeys) { + sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], key); + } - return sortedObj; + return sortedObj; } // Enhanced sorting for nested OpenAPI structures -function sortOpenAPIKeysEnhanced(obj: any, path: string = ''): any { - if (typeof obj !== 'object' || obj === null) { - return obj; +function sortOpenAPIKeysEnhanced(obj: any, path: string = ""): any { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + // Handle arrays by recursively sorting each element + if (Array.isArray(obj)) { + const sortedObjs = []; + + for (let i = 0; i < obj.length; i++) { + sortedObjs.push(sortOpenAPIKeysEnhanced(obj[i], `${path}[${i}]`)); } - // Handle arrays by recursively sorting each element - if (Array.isArray(obj)) { - const sortedObjs = [] - - for (let i = 0; i < obj.length; i++) { - sortedObjs.push(sortOpenAPIKeysEnhanced(obj[i], `${path}[${i}]`)); - } - - if (path === 'tags') { - return sortedObjs.sort((a, b) => sortTags(a, b)); - } - - // Sort parameter arrays so $ref items come first - // Check if this array is a parameters array (path ends with '.parameters' or is 'parameters') - if (path.endsWith('.parameters') || path === 'parameters') { - return sortedObjs.sort((a, b) => { - const aHasRef = a && typeof a === 'object' && '$ref' in a; - const bHasRef = b && typeof b === 'object' && '$ref' in b; - - if (aHasRef && !bHasRef) return -1; // $ref comes first - if (!aHasRef && bHasRef) return 1; // $ref comes first - return 0; // Keep original order for items of same type - }); - } - - return sortedObjs; + if (path === "tags") { + return sortedObjs.sort((a, b) => sortTags(a, b)); } - const contextKey = getContextKey(path, obj); - const standardKeys = getStandardKeysForContext(contextKey); - const customExtensions = vendorExtensions[contextKey] || {}; + // Sort parameter arrays so $ref items come first + // Check if this array is a parameters array (path ends with '.parameters' or is 'parameters') + if (path.endsWith(".parameters") || path === "parameters") { + return sortedObjs.sort((a, b) => { + const aHasRef = a && typeof a === "object" && "$ref" in a; + const bHasRef = b && typeof b === "object" && "$ref" in b; - const sortedKeys = Object.keys(obj).sort((a, b) => { - switch (path) { - case 'paths': - return sortPathKeys(a, b); - case 'responses': - return sortResponseCodes(a, b); - default: - return sortKeys(a, b, standardKeys, customExtensions); - } - }); - - const sortedObj: any = {}; - for (const key of sortedKeys) { - const newPath = path ? `${path}.${key}` : key; - sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], newPath); + if (aHasRef && !bHasRef) return -1; // $ref comes first + if (!aHasRef && bHasRef) return 1; // $ref comes first + return 0; // Keep original order for items of same type + }); } - return sortedObj; + return sortedObjs; + } + + const contextKey = getContextKey(path, obj); + const standardKeys = getStandardKeysForContext(contextKey); + const customExtensions = vendorExtensions[contextKey] || {}; + + const sortedKeys = Object.keys(obj).sort((a, b) => { + switch (path) { + case "paths": + return sortPathKeys(a, b); + case "responses": + return sortResponseCodes(a, b); + default: + return sortKeys(a, b, standardKeys, customExtensions); + } + }); + + const sortedObj: any = {}; + for (const key of sortedKeys) { + const newPath = path ? `${path}.${key}` : key; + sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], newPath); + } + + return sortedObj; } function sortPathKeys(a: string, b: string): number { - // Sort paths by specificity (more specific paths first) - const aSpecificity = (a.match(/\{/g) || []).length; - const bSpecificity = (b.match(/\{/g) || []).length; + // Sort paths by specificity (more specific paths first) + const aSpecificity = (a.match(/\{/g) || []).length; + const bSpecificity = (b.match(/\{/g) || []).length; - if (aSpecificity !== bSpecificity) { - return aSpecificity - bSpecificity; - } + if (aSpecificity !== bSpecificity) { + return aSpecificity - bSpecificity; + } - return a.localeCompare(b); + return a.localeCompare(b); } type Tag = { - name: string; -} + name: string; +}; function sortTags(a: Tag, b: Tag): number { - // Sort tags by name - return a.name.localeCompare(b.name); + // Sort tags by name + return a.name.localeCompare(b.name); } function sortResponseCodes(a: string, b: string): number { - // Sort response codes numerically - const aNum = parseInt(a, 10); - const bNum = parseInt(b, 10); + // Sort response codes numerically + const aNum = parseInt(a, 10); + const bNum = parseInt(b, 10); - if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) { - return aNum - bNum; - } + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) { + return aNum - bNum; + } - return a.localeCompare(b); + return a.localeCompare(b); } //#region Object type detection functions function isOperationObject(obj: any): boolean { - if (!obj || typeof obj !== 'object') { - return false; - } - - // An operation object must have strong operation indicators - // HTTP methods indicate a path item, not an operation - // Strong indicators: operationId or responses (required fields in operations) - // Secondary indicators: requestBody, callbacks (operation-specific) - if ('operationId' in obj || 'responses' in obj) { - return true; - } - - // If it has both requestBody or callbacks (operation-specific) AND other operation keys - if (('requestBody' in obj || 'callbacks' in obj) && - ('parameters' in obj || 'security' in obj || 'servers' in obj)) { - return true; - } - + if (!obj || typeof obj !== "object") { return false; + } + + // An operation object must have strong operation indicators + // HTTP methods indicate a path item, not an operation + // Strong indicators: operationId or responses (required fields in operations) + // Secondary indicators: requestBody, callbacks (operation-specific) + if ("operationId" in obj || "responses" in obj) { + return true; + } + + // If it has both requestBody or callbacks (operation-specific) AND other operation keys + if ( + ("requestBody" in obj || "callbacks" in obj) && + ("parameters" in obj || "security" in obj || "servers" in obj) + ) { + return true; + } + + return false; } function isParameterObject(obj: any): boolean { - return obj && typeof obj === 'object' && 'name' in obj && 'in' in obj; + return obj && typeof obj === "object" && "name" in obj && "in" in obj; } function isSchemaObject(obj: any): boolean { - if (!obj || typeof obj !== 'object') { - return false; - } + if (!obj || typeof obj !== "object") { + return false; + } - // Check for JSON Schema keywords - be very strict - const hasSchemaKeywords = '$ref' in obj || 'allOf' in obj || 'oneOf' in obj || 'anyOf' in obj || 'not' in obj; - const hasValidType = 'type' in obj && obj.type && ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(obj.type); + // Check for JSON Schema keywords - be very strict + const hasSchemaKeywords = + "$ref" in obj || "allOf" in obj || "oneOf" in obj || "anyOf" in obj || "not" in obj; + const hasValidType = + "type" in obj && + obj.type && + ["object", "array", "string", "number", "integer", "boolean", "null"].includes(obj.type); - // Only return true if we have clear schema indicators - // Must have either schema keywords OR valid type with schema properties - // Also require additional schema-specific properties to be more strict - return hasSchemaKeywords || (hasValidType && ('properties' in obj || 'items' in obj || 'enum' in obj || 'format' in obj || 'pattern' in obj)); + // Only return true if we have clear schema indicators + // Must have either schema keywords OR valid type with schema properties + // Also require additional schema-specific properties to be more strict + return ( + hasSchemaKeywords || + (hasValidType && + ("properties" in obj || + "items" in obj || + "enum" in obj || + "format" in obj || + "pattern" in obj)) + ); } function isResponseObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('description' in obj || 'content' in obj); + return obj && typeof obj === "object" && ("description" in obj || "content" in obj); } function isSecuritySchemeObject(obj: any): boolean { - return obj && typeof obj === 'object' && 'type' in obj && - ['apiKey', 'http', 'oauth2', 'openIdConnect'].includes(obj.type); + return ( + obj && + typeof obj === "object" && + "type" in obj && + ["apiKey", "http", "oauth2", "openIdConnect"].includes(obj.type) + ); } function isServerObject(obj: any): boolean { - return obj && typeof obj === 'object' && 'url' in obj; + return obj && typeof obj === "object" && "url" in obj; } function isTagObject(obj: any): boolean { - return obj && typeof obj === 'object' && 'name' in obj && typeof obj.name === 'string' && - (Object.keys(obj).length === 1 || // Only name - 'description' in obj || // name + description - 'externalDocs' in obj); // name + externalDocs + return ( + obj && + typeof obj === "object" && + "name" in obj && + typeof obj.name === "string" && + (Object.keys(obj).length === 1 || // Only name + "description" in obj || // name + description + "externalDocs" in obj) + ); // name + externalDocs } function isExternalDocsObject(obj: any): boolean { - return obj && typeof obj === 'object' && 'url' in obj; + return obj && typeof obj === "object" && "url" in obj; } function isWebhookObject(obj: any): boolean { - const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; - return obj && typeof obj === 'object' && Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); + const httpMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"]; + return ( + obj && + typeof obj === "object" && + Object.keys(obj).some((key) => httpMethods.includes(key.toLowerCase())) + ); } function isPathItemObject(obj: any): boolean { - const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; - return obj && typeof obj === 'object' && Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); + const httpMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"]; + return ( + obj && + typeof obj === "object" && + Object.keys(obj).some((key) => httpMethods.includes(key.toLowerCase())) + ); } function isRequestBodyObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('content' in obj || 'description' in obj); + return obj && typeof obj === "object" && ("content" in obj || "description" in obj); } function isMediaTypeObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('schema' in obj || 'example' in obj || 'examples' in obj); + return ( + obj && typeof obj === "object" && ("schema" in obj || "example" in obj || "examples" in obj) + ); } function isEncodingObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('contentType' in obj || 'style' in obj || 'explode' in obj); + return ( + obj && typeof obj === "object" && ("contentType" in obj || "style" in obj || "explode" in obj) + ); } function isHeaderObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('description' in obj || 'schema' in obj || 'required' in obj); + return ( + obj && typeof obj === "object" && ("description" in obj || "schema" in obj || "required" in obj) + ); } function isLinkObject(obj: any): boolean { - if (!obj || typeof obj !== 'object') { - return false; - } - // Link objects have operationRef OR operationId, but NOT responses (which indicates an operation) - return ('operationRef' in obj || ('operationId' in obj && !('responses' in obj))); + if (!obj || typeof obj !== "object") { + return false; + } + // Link objects have operationRef OR operationId, but NOT responses (which indicates an operation) + return "operationRef" in obj || ("operationId" in obj && !("responses" in obj)); } function isExampleObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('summary' in obj || 'value' in obj || 'externalValue' in obj); + return ( + obj && typeof obj === "object" && ("summary" in obj || "value" in obj || "externalValue" in obj) + ); } function isDiscriminatorObject(obj: any): boolean { - return obj && typeof obj === 'object' && 'propertyName' in obj; + return obj && typeof obj === "object" && "propertyName" in obj; } function isXMLObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('name' in obj || 'namespace' in obj || 'attribute' in obj); + return ( + obj && typeof obj === "object" && ("name" in obj || "namespace" in obj || "attribute" in obj) + ); } function isContactObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('name' in obj || 'url' in obj || 'email' in obj); + return obj && typeof obj === "object" && ("name" in obj || "url" in obj || "email" in obj); } function isLicenseObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('name' in obj || 'identifier' in obj || 'url' in obj); + return obj && typeof obj === "object" && ("name" in obj || "identifier" in obj || "url" in obj); } function isOAuthFlowObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('authorizationUrl' in obj || 'tokenUrl' in obj || 'scopes' in obj); + return ( + obj && + typeof obj === "object" && + ("authorizationUrl" in obj || "tokenUrl" in obj || "scopes" in obj) + ); } function isServerVariableObject(obj: any): boolean { - return obj && typeof obj === 'object' && ('enum' in obj || 'default' in obj); + return obj && typeof obj === "object" && ("enum" in obj || "default" in obj); } //#endregion @@ -588,175 +749,207 @@ function isServerVariableObject(obj: any): boolean { * @param customExtensions Custom extension positions * @returns Comparison result */ -function sortKeys(a: string, b: string, standardKeys: readonly string[], customExtensions: Record = {}): number { - const aCustomPos = customExtensions[a]; - const bCustomPos = customExtensions[b]; +function sortKeys( + a: string, + b: string, + standardKeys: readonly string[], + customExtensions: Record = {} +): number { + const aCustomPos = customExtensions[a]; + const bCustomPos = customExtensions[b]; - // Get standard key positions - const aStandardIndex = standardKeys.indexOf(a); - const bStandardIndex = standardKeys.indexOf(b); + // Get standard key positions + const aStandardIndex = standardKeys.indexOf(a); + const bStandardIndex = standardKeys.indexOf(b); - // Both are custom extensions - if (aCustomPos !== undefined && bCustomPos !== undefined) { - return aCustomPos - bCustomPos; + // Both are custom extensions + if (aCustomPos !== undefined && bCustomPos !== undefined) { + return aCustomPos - bCustomPos; + } + + // Both are standard keys + if (aStandardIndex !== -1 && bStandardIndex !== -1) { + return aStandardIndex - bStandardIndex; + } + + // One is custom, one is standard + if (aCustomPos !== undefined && bStandardIndex !== -1) { + // Custom key should be positioned relative to standard keys + if (aCustomPos < bStandardIndex) { + return -1; // Custom key comes before this standard key + } else if (aCustomPos > bStandardIndex) { + return 1; // Custom key comes after this standard key + } else { + return -1; // Custom key comes before standard key at same position } + } - // Both are standard keys - if (aStandardIndex !== -1 && bStandardIndex !== -1) { - return aStandardIndex - bStandardIndex; + if (bCustomPos !== undefined && aStandardIndex !== -1) { + // Custom key should be positioned relative to standard keys + if (bCustomPos < aStandardIndex) { + return 1; // Custom key comes before this standard key + } else if (bCustomPos > aStandardIndex) { + return -1; // Custom key comes after this standard key + } else { + return 1; // Standard key comes after custom key at same position } + } - // One is custom, one is standard - if (aCustomPos !== undefined && bStandardIndex !== -1) { - // Custom key should be positioned relative to standard keys - if (aCustomPos < bStandardIndex) { - return -1; // Custom key comes before this standard key - } else if (aCustomPos > bStandardIndex) { - return 1; // Custom key comes after this standard key - } else { - return -1; // Custom key comes before standard key at same position - } - } + // One is standard, one is unknown + if (aStandardIndex !== -1) return -1; // Standard key comes first + if (bStandardIndex !== -1) return 1; // Standard key comes first - if (bCustomPos !== undefined && aStandardIndex !== -1) { - // Custom key should be positioned relative to standard keys - if (bCustomPos < aStandardIndex) { - return 1; // Custom key comes before this standard key - } else if (bCustomPos > aStandardIndex) { - return -1; // Custom key comes after this standard key - } else { - return 1; // Standard key comes after custom key at same position - } - } + // One is custom, one is unknown + if (aCustomPos !== undefined) return -1; // Custom key comes before unknown + if (bCustomPos !== undefined) return 1; // Custom key comes before unknown - // One is standard, one is unknown - if (aStandardIndex !== -1) return -1; // Standard key comes first - if (bStandardIndex !== -1) return 1; // Standard key comes first - - // One is custom, one is unknown - if (aCustomPos !== undefined) return -1; // Custom key comes before unknown - if (bCustomPos !== undefined) return 1; // Custom key comes before unknown - - // Both are unknown - sort alphabetically - return a.localeCompare(b); + // Both are unknown - sort alphabetically + return a.localeCompare(b); } //#endregion //#region Helper functions for custom extensions function getContextKey(path: string, obj: any): string { - // Determine the context based on path and object properties - if (path === 'info') return 'info'; - if (path === 'components') return 'components'; - if (path === 'servers' || path.startsWith('servers[')) return 'server'; - if (path === 'tags' || path.startsWith('tags[')) return 'tag'; - if (path === 'externalDocs') return 'externalDocs'; - if (path === 'webhooks') return 'webhook'; - if (path === 'definitions') return 'definitions'; - if (path === 'securityDefinitions') return 'securityDefinitions'; + // Determine the context based on path and object properties + if (path === "info") return "info"; + if (path === "components") return "components"; + if (path === "servers" || path.startsWith("servers[")) return "server"; + if (path === "tags" || path.startsWith("tags[")) return "tag"; + if (path === "externalDocs") return "externalDocs"; + if (path === "webhooks") return "webhook"; + if (path === "definitions") return "definitions"; + if (path === "securityDefinitions") return "securityDefinitions"; - // Check if this is a path operation (e.g., "paths./users.get") - if (path.includes('.') && path.split('.').length >= 3) { - const pathParts = path.split('.'); - const lastPart = pathParts[pathParts.length - 1]; - const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; - if (httpMethods.includes(lastPart.toLowerCase())) { - return 'operation'; - } + // Check if this is a path operation (e.g., "paths./users.get") + if (path.includes(".") && path.split(".").length >= 3) { + const pathParts = path.split("."); + const lastPart = pathParts[pathParts.length - 1]; + const httpMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"]; + if (httpMethods.includes(lastPart.toLowerCase())) { + return "operation"; } + } - // Handle nested paths for components - if (path.startsWith('components.')) { - if (path.includes('schemas.')) return 'schema'; - if (path.includes('parameters.')) return 'parameter'; - if (path.includes('responses.')) return 'response'; - if (path.includes('securitySchemes.')) return 'securityScheme'; - if (path.includes('requestBodies.')) return 'requestBody'; - if (path.includes('headers.')) return 'header'; - if (path.includes('examples.')) return 'example'; - if (path.includes('links.')) return 'link'; - if (path.includes('callbacks.')) return 'callback'; - if (path.includes('pathItems.')) return 'pathItem'; - } + // Handle nested paths for components + if (path.startsWith("components.")) { + if (path.includes("schemas.")) return "schema"; + if (path.includes("parameters.")) return "parameter"; + if (path.includes("responses.")) return "response"; + if (path.includes("securitySchemes.")) return "securityScheme"; + if (path.includes("requestBodies.")) return "requestBody"; + if (path.includes("headers.")) return "header"; + if (path.includes("examples.")) return "example"; + if (path.includes("links.")) return "link"; + if (path.includes("callbacks.")) return "callback"; + if (path.includes("pathItems.")) return "pathItem"; + } - // Handle nested paths for Swagger 2.0 - if (path.startsWith('definitions.')) return 'definitions'; - if (path.startsWith('securityDefinitions.')) return 'securityDefinitions'; + // Handle nested paths for Swagger 2.0 + if (path.startsWith("definitions.")) return "definitions"; + if (path.startsWith("securityDefinitions.")) return "securityDefinitions"; - // Handle nested paths for operations (parameters, responses, etc.) - if (path.includes('.parameters.') && path.split('.').length > 3) return 'parameter'; - if (path.includes('.responses.') && path.split('.').length > 3) return 'response'; - if (path.includes('.requestBody.')) return 'requestBody'; - if (path.includes('.headers.')) return 'header'; - if (path.includes('.examples.')) return 'example'; - if (path.includes('.links.')) return 'link'; - if (path.includes('.content.')) return 'mediaType'; - if (path.includes('.encoding.')) return 'encoding'; - if (path.includes('.discriminator.')) return 'discriminator'; - if (path.includes('.xml.')) return 'xml'; - if (path.includes('.contact.')) return 'contact'; - if (path.includes('.license.')) return 'license'; - if (path.includes('.flows.')) return 'oauthFlow'; - if (path.includes('.variables.')) return 'serverVariable'; + // Handle nested paths for operations (parameters, responses, etc.) + if (path.includes(".parameters.") && path.split(".").length > 3) return "parameter"; + if (path.includes(".responses.") && path.split(".").length > 3) return "response"; + if (path.includes(".requestBody.")) return "requestBody"; + if (path.includes(".headers.")) return "header"; + if (path.includes(".examples.")) return "example"; + if (path.includes(".links.")) return "link"; + if (path.includes(".content.")) return "mediaType"; + if (path.includes(".encoding.")) return "encoding"; + if (path.includes(".discriminator.")) return "discriminator"; + if (path.includes(".xml.")) return "xml"; + if (path.includes(".contact.")) return "contact"; + if (path.includes(".license.")) return "license"; + if (path.includes(".flows.")) return "oauthFlow"; + if (path.includes(".variables.")) return "serverVariable"; - // Check object types as fallback - // Only check for operation if path is not empty (not at root level) - // Root-level objects should not be detected as operations unless they're truly operations - // (handled separately in sortOpenAPIKeys for referenced operation files) - if (path && isOperationObject(obj)) return 'operation'; - if (isParameterObject(obj)) return 'parameter'; - if (isSchemaObject(obj)) return 'schema'; - if (isResponseObject(obj)) return 'response'; - if (isSecuritySchemeObject(obj)) return 'securityScheme'; - if (isServerObject(obj)) return 'server'; - if (isTagObject(obj)) return 'tag'; - if (isExternalDocsObject(obj)) return 'externalDocs'; - if (isWebhookObject(obj)) return 'webhook'; - if (isPathItemObject(obj)) return 'pathItem'; - if (isRequestBodyObject(obj)) return 'requestBody'; - if (isMediaTypeObject(obj)) return 'mediaType'; - if (isEncodingObject(obj)) return 'encoding'; - if (isHeaderObject(obj)) return 'header'; - if (isLinkObject(obj)) return 'link'; - if (isExampleObject(obj)) return 'example'; - if (isDiscriminatorObject(obj)) return 'discriminator'; - if (isXMLObject(obj)) return 'xml'; - if (isContactObject(obj)) return 'contact'; - if (isLicenseObject(obj)) return 'license'; - if (isOAuthFlowObject(obj)) return 'oauthFlow'; - if (isServerVariableObject(obj)) return 'serverVariable'; + // Check object types as fallback + // Only check for operation if path is not empty (not at root level) + // Root-level objects should not be detected as operations unless they're truly operations + // (handled separately in sortOpenAPIKeys for referenced operation files) + if (path && isOperationObject(obj)) return "operation"; + if (isParameterObject(obj)) return "parameter"; + if (isSchemaObject(obj)) return "schema"; + if (isResponseObject(obj)) return "response"; + if (isSecuritySchemeObject(obj)) return "securityScheme"; + if (isServerObject(obj)) return "server"; + if (isTagObject(obj)) return "tag"; + if (isExternalDocsObject(obj)) return "externalDocs"; + if (isWebhookObject(obj)) return "webhook"; + if (isPathItemObject(obj)) return "pathItem"; + if (isRequestBodyObject(obj)) return "requestBody"; + if (isMediaTypeObject(obj)) return "mediaType"; + if (isEncodingObject(obj)) return "encoding"; + if (isHeaderObject(obj)) return "header"; + if (isLinkObject(obj)) return "link"; + if (isExampleObject(obj)) return "example"; + if (isDiscriminatorObject(obj)) return "discriminator"; + if (isXMLObject(obj)) return "xml"; + if (isContactObject(obj)) return "contact"; + if (isLicenseObject(obj)) return "license"; + if (isOAuthFlowObject(obj)) return "oauthFlow"; + if (isServerVariableObject(obj)) return "serverVariable"; - return 'top-level'; + return "top-level"; } function getStandardKeysForContext(contextKey: string): readonly string[] { - switch (contextKey) { - case 'info': return InfoKeys; - case 'components': return ComponentsKeys; - case 'operation': return OperationKeys; - case 'parameter': return ParameterKeys; - case 'schema': return SchemaKeys; - case 'response': return ResponseKeys; - case 'securityScheme': return SecuritySchemeKeys; - case 'server': return ServerKeys; - case 'tag': return TagKeys; - case 'externalDocs': return ExternalDocsKeys; - case 'webhook': return WebhookKeys; - case 'pathItem': return PathItemKeys; - case 'requestBody': return RequestBodyKeys; - case 'mediaType': return MediaTypeKeys; - case 'encoding': return EncodingKeys; - case 'header': return HeaderKeys; - case 'link': return LinkKeys; - case 'example': return ExampleKeys; - case 'discriminator': return DiscriminatorKeys; - case 'xml': return XMLKeys; - case 'contact': return ContactKeys; - case 'license': return LicenseKeys; - case 'oauthFlow': return OAuthFlowKeys; - case 'serverVariable': return ServerVariableKeys; - case 'definitions': return SchemaKeys; // Definitions use schema keys - case 'securityDefinitions': return SecuritySchemeKeys; // Security definitions use security scheme keys - default: return RootKeys; - } + switch (contextKey) { + case "info": + return InfoKeys; + case "components": + return ComponentsKeys; + case "operation": + return OperationKeys; + case "parameter": + return ParameterKeys; + case "schema": + return SchemaKeys; + case "response": + return ResponseKeys; + case "securityScheme": + return SecuritySchemeKeys; + case "server": + return ServerKeys; + case "tag": + return TagKeys; + case "externalDocs": + return ExternalDocsKeys; + case "webhook": + return WebhookKeys; + case "pathItem": + return PathItemKeys; + case "requestBody": + return RequestBodyKeys; + case "mediaType": + return MediaTypeKeys; + case "encoding": + return EncodingKeys; + case "header": + return HeaderKeys; + case "link": + return LinkKeys; + case "example": + return ExampleKeys; + case "discriminator": + return DiscriminatorKeys; + case "xml": + return XMLKeys; + case "contact": + return ContactKeys; + case "license": + return LicenseKeys; + case "oauthFlow": + return OAuthFlowKeys; + case "serverVariable": + return ServerVariableKeys; + case "definitions": + return SchemaKeys; // Definitions use schema keys + case "securityDefinitions": + return SecuritySchemeKeys; // Security definitions use security scheme keys + default: + return RootKeys; + } } diff --git a/src/prettier-markdown/README.md b/src/prettier-markdown/README.md new file mode 100644 index 0000000..0ba4ace --- /dev/null +++ b/src/prettier-markdown/README.md @@ -0,0 +1,54 @@ +# Prettier Markdown Integration + +This directory contains Prettier's markdown language implementation, adapted for use within this plugin. + +## Structure + +### Core Files (from Prettier) +These files are copied from Prettier's `src/language-markdown` directory: +- `parser-markdown.js` - Markdown parser +- `printer-markdown.js` - Markdown printer +- `clean.js` - AST cleaning utilities +- `utils.js` - Utility functions +- `constants.evaluate.js` - Constants +- `print/` - Printing utilities +- `unified-plugins/` - Unified/Remark plugins +- Other supporting files + +### Adapter Files (created for this plugin) +These files adapt Prettier's internal dependencies: +- `adapter-document-builders.js` - Adapts Prettier's document builders +- `adapter-document-utils.js` - Adapts Prettier's document utilities +- `adapter-document-constants.js` - Adapts Prettier's document constants +- `adapter-prettier-utils.js` - Adapts Prettier's utility functions +- `adapter-pragma.js` - Adapts pragma handling (simplified) + +### Integration Files +- `format-markdown.ts` - Type-safe wrapper for formatting markdown +- `options.js` - Markdown formatting options (adapted from Prettier) + +## Updating from Prettier + +When Prettier updates its markdown implementation: + +1. **Copy updated files** from `prettier/src/language-markdown/` to this directory +2. **Update adapter files** if Prettier's internal structure changed: + - Check if document builders path changed → update `adapter-document-builders.js` + - Check if document utils path changed → update `adapter-document-utils.js` + - Check if utility functions changed → update `adapter-prettier-utils.js` +3. **Update imports** in copied files to use adapter files: + - `../document/builders.js` → `./adapter-document-builders.js` + - `../document/utils.js` → `./adapter-document-utils.js` + - `../document/constants.js` → `./adapter-document-constants.js` + - `../utils/*` → `./adapter-prettier-utils.js` + - `../common/common-options.evaluate.js` → update `options.js` + - `../main/front-matter/index.js` → update `adapter-pragma.js` or `clean.js` + - `../utils/pragma/pragma.evaluate.js` → update `adapter-pragma.js` +4. **Test** that markdown formatting still works correctly + +## Dependencies + +The adapter files attempt to access Prettier's internal APIs at runtime. If Prettier's internal structure changes significantly, you may need to update the adapter files to match. + +The interfaces in the adapter files are designed to be similar to Prettier's actual structure, making updates easier. + diff --git a/src/prettier-markdown/adapter-document-builders.js b/src/prettier-markdown/adapter-document-builders.js new file mode 100644 index 0000000..7a214c2 --- /dev/null +++ b/src/prettier-markdown/adapter-document-builders.js @@ -0,0 +1,55 @@ +/** + * Adapter for Prettier's document builders + * + * This file attempts to import Prettier's document builders from internal APIs. + * Update this file when Prettier's internal structure changes. + */ + +let builders: any = null; + +try { + const prettier = require("prettier"); + + // Try multiple paths to access document builders + if (prettier.__internal?.document?.builders) { + builders = prettier.__internal.document.builders; + } else { + // Try to require directly (may work in plugin context) + try { + builders = require("prettier/internal/document/builders"); + } catch { + // Fallback: try alternative paths + try { + const doc = require("prettier/doc"); + if (doc) { + builders = doc; + } + } catch { + // Not accessible + } + } + } +} catch { + // Builders not accessible +} + +// Export what we found, or throw if not available +if (!builders) { + throw new Error( + "Prettier document builders not accessible. " + + "Markdown formatting requires Prettier's internal document builders." + ); +} + +export const { + align, + fill, + group, + hardline, + indent, + line, + literalline, + markAsRoot, + softline, +} = builders; + diff --git a/src/prettier-markdown/adapter-document-constants.js b/src/prettier-markdown/adapter-document-constants.js new file mode 100644 index 0000000..4888f69 --- /dev/null +++ b/src/prettier-markdown/adapter-document-constants.js @@ -0,0 +1,33 @@ +/** + * Adapter for Prettier's document constants + * + * This file attempts to import Prettier's document constants from internal APIs. + * Update this file when Prettier's internal structure changes. + */ + +let constants: any = null; + +try { + const prettier = require("prettier"); + + if (prettier.__internal?.document?.constants) { + constants = prettier.__internal.document.constants; + } else { + try { + constants = require("prettier/internal/document/constants"); + } catch { + // Constants may not be accessible, provide a fallback + constants = { + DOC_TYPE_STRING: "doc-type-string", + }; + } + } +} catch { + // Provide fallback + constants = { + DOC_TYPE_STRING: "doc-type-string", + }; +} + +export const { DOC_TYPE_STRING } = constants; + diff --git a/src/prettier-markdown/adapter-document-utils.js b/src/prettier-markdown/adapter-document-utils.js new file mode 100644 index 0000000..7560a72 --- /dev/null +++ b/src/prettier-markdown/adapter-document-utils.js @@ -0,0 +1,39 @@ +/** + * Adapter for Prettier's document utils + * + * This file attempts to import Prettier's document utilities from internal APIs. + * Update this file when Prettier's internal structure changes. + */ + +let utils: any = null; + +try { + const prettier = require("prettier"); + + if (prettier.__internal?.document?.utils) { + utils = prettier.__internal.document.utils; + } else { + try { + utils = require("prettier/internal/document/utils"); + } catch { + try { + const docUtils = require("prettier/doc"); + utils = docUtils; + } catch { + // Not accessible + } + } + } +} catch { + // Utils not accessible +} + +if (!utils) { + throw new Error( + "Prettier document utils not accessible. " + + "Markdown formatting requires Prettier's internal document utilities." + ); +} + +export const { getDocType, replaceEndOfLine } = utils; + diff --git a/src/prettier-markdown/adapter-pragma.js b/src/prettier-markdown/adapter-pragma.js new file mode 100644 index 0000000..aa139a4 --- /dev/null +++ b/src/prettier-markdown/adapter-pragma.js @@ -0,0 +1,48 @@ +/** + * Adapter for Prettier's pragma utilities + * + * Simplified version that doesn't depend on Prettier's internal pragma system. + * Update this file when Prettier's pragma behavior changes. + */ + +// Simplified pragma regexes based on Prettier's implementation +const MARKDOWN_HAS_PRAGMA_REGEXP = /^$/m; +const MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP = /^$/m; +const FORMAT_PRAGMA_TO_INSERT = "format"; + +/** + * Simple front matter parser (minimal implementation) + */ +function parseFrontMatter(text: string) { + const yamlMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/); + if (yamlMatch) { + return { + content: text.slice(yamlMatch[0].length), + frontMatter: { + raw: yamlMatch[0], + end: { index: yamlMatch[0].length }, + }, + }; + } + return { content: text, frontMatter: null }; +} + +const hasPragma = (text: string) => + parseFrontMatter(text).content.trimStart().match(MARKDOWN_HAS_PRAGMA_REGEXP) + ?.index === 0; + +const hasIgnorePragma = (text: string) => + parseFrontMatter(text) + .content.trimStart() + .match(MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP)?.index === 0; + +const insertPragma = (text: string) => { + const { frontMatter } = parseFrontMatter(text); + const pragma = ``; + return frontMatter + ? `${frontMatter.raw}\n\n${pragma}\n\n${text.slice(frontMatter.end.index)}` + : `${pragma}\n\n${text}`; +}; + +export { hasIgnorePragma, hasPragma, insertPragma }; + diff --git a/src/prettier-markdown/adapter-prettier-internals.js b/src/prettier-markdown/adapter-prettier-internals.js new file mode 100644 index 0000000..69499b1 --- /dev/null +++ b/src/prettier-markdown/adapter-prettier-internals.js @@ -0,0 +1,130 @@ +/** + * Adapter for Prettier's internal document builders and utilities + * + * This file attempts to access Prettier's internal APIs that are needed + * by the markdown parser and printer. These adapters provide a type-safe + * way to access Prettier internals when available. + * + * Note: This file can be updated when Prettier's internal structure changes. + * The interfaces should remain similar to allow easy updates. + */ + +/** + * Attempts to import Prettier's document builders + * @returns {Promise | null>} + */ +export function getDocumentBuilders() { + try { + // Try to require Prettier's document builders + // Prettier 3.x structure + const prettier = require("prettier"); + + // Path 1: Check if accessible via __internal + if (prettier.__internal?.document?.builders) { + return prettier.__internal.document.builders; + } + + // Path 2: Try to require directly (may work in some contexts) + try { + return require("prettier/internal/document/builders"); + } catch { + // Not accessible + } + + return null; + } catch { + return null; + } +} + +/** + * Attempts to import Prettier's document utils + */ +export function getDocumentUtils() { + try { + const prettier = require("prettier"); + + if (prettier.__internal?.document?.utils) { + return prettier.__internal.document.utils; + } + + try { + return require("prettier/internal/document/utils"); + } catch { + return null; + } + } catch { + return null; + } +} + +/** + * Attempts to import Prettier's document constants + */ +export function getDocumentConstants() { + try { + const prettier = require("prettier"); + + if (prettier.__internal?.document?.constants) { + return prettier.__internal.document.constants; + } + + try { + return require("prettier/internal/document/constants"); + } catch { + return null; + } + } catch { + return null; + } +} + +/** + * Attempts to import Prettier's utility functions + */ +export function getPrettierUtils() { + try { + const prettier = require("prettier"); + + if (prettier.__internal?.utils) { + return prettier.__internal.utils; + } + + return null; + } catch { + return null; + } +} + +/** + * Attempts to get Prettier's doc-to-string printer + * This is needed to convert Doc objects to strings + */ +export function getDocPrinter() { + try { + const prettier = require("prettier"); + + // Try multiple paths + if (prettier.__internal?.doc?.printDocToString) { + return prettier.__internal.doc.printDocToString; + } + + if (prettier.__internal?.docPrinter?.formatDoc) { + return prettier.__internal.docPrinter.formatDoc; + } + + try { + const docUtils = require("prettier/internal/doc"); + if (docUtils?.printDocToString) { + return docUtils.printDocToString; + } + } catch { + // Not accessible + } + + return null; + } catch { + return null; + } +} + diff --git a/src/prettier-markdown/adapter-prettier-utils.js b/src/prettier-markdown/adapter-prettier-utils.js new file mode 100644 index 0000000..978d632 --- /dev/null +++ b/src/prettier-markdown/adapter-prettier-utils.js @@ -0,0 +1,72 @@ +/** + * Adapter for Prettier's utility functions + * + * This file attempts to import Prettier's utility functions from internal APIs. + * Update this file when Prettier's internal structure changes. + */ + +let utils: any = null; + +try { + const prettier = require("prettier"); + + if (prettier.__internal?.utils) { + utils = prettier.__internal.utils; + } else { + try { + // Try alternative paths + utils = require("prettier/internal/utils"); + } catch { + // Utils may not be accessible + } + } +} catch { + // Utils not accessible +} + +// Provide fallback implementations if utils aren't accessible +if (!utils) { + // Minimal fallback implementations + utils = { + getMaxContinuousCount: (str: string, char: string) => { + let max = 0; + let current = 0; + for (const c of str) { + if (c === char) { + current++; + max = Math.max(max, current); + } else { + current = 0; + } + } + return max; + }, + getMinNotPresentContinuousCount: (str: string, char: string) => { + let count = 1; + while (str.includes(char.repeat(count))) { + count++; + } + return count; + }, + getPreferredQuote: (str: string, singleQuote: boolean) => { + if (singleQuote) return "'"; + const hasSingle = str.includes("'"); + const hasDouble = str.includes('"'); + if (hasSingle && !hasDouble) return '"'; + return "'"; + }, + }; +} + +export const getMaxContinuousCount = utils.getMaxContinuousCount; +export const getMinNotPresentContinuousCount = utils.getMinNotPresentContinuousCount; +export const getPreferredQuote = utils.getPreferredQuote; + +// UnexpectedNodeError may be in utils or separate +export class UnexpectedNodeError extends Error { + constructor(node: any, language: string) { + super(`Unexpected node type: ${node.type} in ${language}`); + this.name = "UnexpectedNodeError"; + } +} + diff --git a/src/prettier-markdown/clean.js b/src/prettier-markdown/clean.js new file mode 100644 index 0000000..72fd2fe --- /dev/null +++ b/src/prettier-markdown/clean.js @@ -0,0 +1,91 @@ +import collapseWhiteSpace from "collapse-white-space"; +import { hasPragma } from "./adapter-pragma.js"; + +// Simplified front matter check +function isFrontMatter(node: any): boolean { + return node?.type === "yaml" || node?.type === "toml"; +} + +const ignoredProperties = new Set([ + "position", + "raw", // front-matter +]); +function clean(original, cloned, parent) { + // for codeblock + if ( + original.type === "code" || + original.type === "yaml" || + original.type === "import" || + original.type === "export" || + original.type === "jsx" + ) { + delete cloned.value; + } + + if (original.type === "list") { + delete cloned.isAligned; + } + + if (original.type === "list" || original.type === "listItem") { + delete cloned.spread; + } + + // texts can be splitted or merged + if (original.type === "text") { + return null; + } + + if (original.type === "inlineCode") { + cloned.value = original.value.replaceAll("\n", " "); + } + + if (original.type === "wikiLink") { + cloned.value = original.value.trim().replaceAll(/[\t\n]+/gu, " "); + } + + if ( + original.type === "definition" || + original.type === "linkReference" || + original.type === "imageReference" + ) { + cloned.label = collapseWhiteSpace(original.label); + } + + if ( + (original.type === "link" || original.type === "image") && + original.url && + original.url.includes("(") + ) { + for (const character of "<>") { + cloned.url = original.url.replaceAll( + character, + encodeURIComponent(character), + ); + } + } + + if ( + (original.type === "definition" || + original.type === "link" || + original.type === "image") && + original.title + ) { + cloned.title = original.title.replaceAll(/\\(?=["')])/gu, ""); + } + + // for insert pragma + if ( + parent?.type === "root" && + parent.children.length > 0 && + (parent.children[0] === original || + (isFrontMatter(parent.children[0]) && parent.children[1] === original)) && + original.type === "html" && + hasPragma(original.value) + ) { + return null; + } +} + +clean.ignoredProperties = ignoredProperties; + +export default clean; diff --git a/src/prettier-markdown/constants.evaluate.js b/src/prettier-markdown/constants.evaluate.js new file mode 100644 index 0000000..4b5ab03 --- /dev/null +++ b/src/prettier-markdown/constants.evaluate.js @@ -0,0 +1,86 @@ +import { all as getCjkCharset } from "cjk-regex"; +import { Charset } from "regexp-util"; +import unicodeRegex from "unicode-regex"; + +const cjkCharset = new Charset( + getCjkCharset(), + unicodeRegex({ + Script_Extensions: ["Han", "Katakana", "Hiragana", "Hangul", "Bopomofo"], + General_Category: [ + "Other_Letter", + "Letter_Number", + "Other_Symbol", + "Modifier_Letter", + "Modifier_Symbol", + "Nonspacing_Mark", + ], + }), +); +const variationSelectorsCharset = unicodeRegex({ + Block: ["Variation_Selectors", "Variation_Selectors_Supplement"], +}); + +const CJK_REGEXP = new RegExp( + `(?:${cjkCharset.toString("u")})(?:${variationSelectorsCharset.toString("u")})?`, + "u", +); + +const asciiPunctuationCharacters = [ + "!", + '"', + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "[", + "\\", + "]", + "^", + "_", + "`", + "{", + "|", + "}", + "~", +]; + +// https://spec.commonmark.org/0.25/#punctuation-character +// https://unicode.org/Public/5.1.0/ucd/UCD.html#General_Category_Values +const unicodePunctuationClasses = [ + /* Pc */ "Connector_Punctuation", + /* Pd */ "Dash_Punctuation", + /* Pe */ "Close_Punctuation", + /* Pf */ "Final_Punctuation", + /* Pi */ "Initial_Punctuation", + /* Po */ "Other_Punctuation", + /* Ps */ "Open_Punctuation", +]; + +const PUNCTUATION_REGEXP = new RegExp( + `(?:${[ + new Charset(...asciiPunctuationCharacters).toRegExp("u").source, + ...unicodePunctuationClasses.map( + (charset) => String.raw`\p{General_Category=${charset}}`, + "\u{ff5e}", // Used as a substitute for U+301C in Windows + ), + ].join("|")})`, + "u", +); + +export { CJK_REGEXP, PUNCTUATION_REGEXP }; diff --git a/src/prettier-markdown/embed.js b/src/prettier-markdown/embed.js new file mode 100644 index 0000000..34aea03 --- /dev/null +++ b/src/prettier-markdown/embed.js @@ -0,0 +1,87 @@ +import { hardline, markAsRoot } from "../document/builders.js"; +import { replaceEndOfLine } from "../document/utils.js"; +import getMaxContinuousCount from "../utils/get-max-continuous-count.js"; +import inferParser from "../utils/infer-parser.js"; +import { getFencedCodeBlockValue } from "./utils.js"; + +function embed(path, options) { + const { node } = path; + + if (node.type === "code" && node.lang !== null) { + const parser = inferParser(options, { language: node.lang }); + if (parser) { + return async (textToDoc) => { + const styleUnit = options.__inJsTemplate ? "~" : "`"; + const style = styleUnit.repeat( + Math.max(3, getMaxContinuousCount(node.value, styleUnit) + 1), + ); + const newOptions = { parser }; + + // Override the filepath option. + // This is because whether the trailing comma of type parameters + // should be printed depends on whether it is `*.ts` or `*.tsx`. + // https://github.com/prettier/prettier/issues/15282 + if (node.lang === "ts" || node.lang === "typescript") { + newOptions.filepath = "dummy.ts"; + } else if (node.lang === "tsx") { + newOptions.filepath = "dummy.tsx"; + } + + const doc = await textToDoc( + getFencedCodeBlockValue(node, options.originalText), + newOptions, + ); + + return markAsRoot([ + style, + node.lang, + node.meta ? " " + node.meta : "", + hardline, + replaceEndOfLine(doc), + hardline, + style, + ]); + }; + } + } + + switch (node.type) { + // MDX + case "import": + case "export": + return (textToDoc) => + textToDoc(node.value, { + // TODO: Rename this option since it's not used in HTML + __onHtmlBindingRoot: (ast) => validateImportExport(ast, node.type), + parser: "babel", + }); + case "jsx": + return (textToDoc) => + textToDoc(`<$>${node.value}`, { + parser: "__js_expression", + rootMarker: "mdx", + }); + } + + return null; +} + +function validateImportExport(ast, type) { + const { + program: { body }, + } = ast; + + // https://github.com/mdx-js/mdx/blob/3430138958c9c0344ecad9d59e0d6b5d72bedae3/packages/remark-mdx/extract-imports-and-exports.js#L16 + if ( + !body.every( + (node) => + node.type === "ImportDeclaration" || + node.type === "ExportDefaultDeclaration" || + node.type === "ExportNamedDeclaration", + ) + ) { + throw new Error(`Unexpected '${type}' in MDX.`); + } +} + +export default embed; diff --git a/src/prettier-markdown/format-markdown.js b/src/prettier-markdown/format-markdown.js new file mode 100644 index 0000000..c76b7dc --- /dev/null +++ b/src/prettier-markdown/format-markdown.js @@ -0,0 +1,123 @@ +/** + * Type-safe wrapper for Prettier's markdown formatting + * + * This module provides a synchronous interface to Prettier's markdown + * parser and printer, adapted to work within a Prettier plugin context. + */ + +const { markdown: markdownParser } = require("./parser-markdown.js"); +const printer = require("./printer-markdown.js"); +const { getDocPrinter } = require("./adapter-prettier-internals.js"); + +/** + * Formats a markdown string using Prettier's markdown parser and printer + * + * @param {string} markdown - The markdown string to format + * @param {Object} options - Formatting options + * @param {number} [options.printWidth=80] - Maximum line width + * @param {number} [options.tabWidth=2] - Tab width + * @param {string} [options.proseWrap='preserve'] - Prose wrapping mode + * @param {boolean} [options.singleQuote=false] - Use single quotes + * @returns {string} The formatted markdown string, or the original if formatting fails + */ +function formatMarkdown(markdown, options = {}) { + if (!markdown || typeof markdown !== "string") { + return markdown; + } + + const trimmed = markdown.trim(); + if (trimmed.length === 0) { + return markdown; + } + + try { + // Parse markdown to AST + const ast = markdownParser.parse(trimmed, { + originalText: trimmed, + filepath: "temp.md", + printWidth: options.printWidth || 80, + tabWidth: options.tabWidth || 2, + proseWrap: options.proseWrap || "preserve", + singleQuote: options.singleQuote || false, + }); + + // Create an AstPath-like object for the printer + const astPath = { + getNode: () => ast, + stack: [ast], + node: ast, + callParent: (fn) => fn(astPath), + each: (fn) => { + if (ast.children) { + ast.children.forEach((child, index) => { + const childPath = { + getNode: () => child, + stack: [...astPath.stack, child], + node: child, + index, + previous: index > 0 ? ast.children[index - 1] : null, + next: index < ast.children.length - 1 ? ast.children[index + 1] : null, + parent: ast, + isFirst: index === 0, + isLast: index === ast.children.length - 1, + callParent: (fn) => fn(childPath), + }; + fn(childPath); + }); + } + }, + }; + + // Create a print function for recursive printing + const createPrintFn = (path) => { + return (printPath) => { + return printer.print(printPath, { + printWidth: options.printWidth || 80, + tabWidth: options.tabWidth || 2, + proseWrap: options.proseWrap || "preserve", + singleQuote: options.singleQuote || false, + originalText: trimmed, + }, createPrintFn); + }; + }; + + // Print the AST to a Doc object + const doc = printer.print(astPath, { + printWidth: options.printWidth || 80, + tabWidth: options.tabWidth || 2, + proseWrap: options.proseWrap || "preserve", + singleQuote: options.singleQuote || false, + originalText: trimmed, + }, createPrintFn); + + // Convert Doc to string + if (typeof doc === "string") { + return doc.trimEnd(); + } + + // Try to convert Doc object to string using Prettier's doc printer + const docPrinter = getDocPrinter(); + if (docPrinter && typeof docPrinter === "function") { + try { + const formattedString = docPrinter(doc, { + printWidth: options.printWidth || 80, + tabWidth: options.tabWidth || 2, + useTabs: false, + }); + return typeof formattedString === "string" ? formattedString.trimEnd() : markdown; + } catch { + // Doc printing failed + return markdown; + } + } + + // If we can't convert Doc to string, return original + return markdown; + } catch (error) { + // Parsing or printing failed, return original + return markdown; + } +} + +module.exports = { formatMarkdown }; + diff --git a/src/prettier-markdown/format-markdown.ts b/src/prettier-markdown/format-markdown.ts new file mode 100644 index 0000000..3d3bd96 --- /dev/null +++ b/src/prettier-markdown/format-markdown.ts @@ -0,0 +1,110 @@ +/** + * Type-safe wrapper for Prettier's markdown formatting + * + * This module provides a synchronous interface to Prettier's markdown + * parser and printer, adapted to work within a Prettier plugin context. + */ + +import type { ParserOptions } from "prettier"; +import { + getDocPrinter, + getDocumentBuilders, + getDocumentConstants, + getDocumentUtils, +} from "./adapter-prettier-internals.js"; +import { markdown as markdownParser } from "./parser-markdown.js"; +import printer from "./printer-markdown.js"; + +interface MarkdownFormatOptions { + printWidth?: number; + tabWidth?: number; + proseWrap?: "always" | "never" | "preserve"; + singleQuote?: boolean; +} + +/** + * Formats a markdown string using Prettier's markdown parser and printer + * + * @param markdown - The markdown string to format + * @param options - Formatting options + * @returns The formatted markdown string, or the original if formatting fails + */ +export function formatMarkdown(markdown: string, options: MarkdownFormatOptions = {}): string { + if (!markdown || typeof markdown !== "string") { + return markdown; + } + + const trimmed = markdown.trim(); + if (trimmed.length === 0) { + return markdown; + } + + try { + // Parse markdown to AST + const ast = markdownParser.parse(trimmed, { + originalText: trimmed, + filepath: "temp.md", + } as ParserOptions); + + // Create an AstPath-like object for the printer + const astPath = { + getNode: () => ast, + stack: [ast], + callParent: (fn: (path: any) => any) => fn(astPath), + each: (fn: (path: any) => void) => { + if (ast.children) { + ast.children.forEach((child: any, index: number) => { + const childPath = { + getNode: () => child, + stack: [...astPath.stack, child], + index, + previous: index > 0 ? ast.children[index - 1] : null, + next: index < ast.children.length - 1 ? ast.children[index + 1] : null, + parent: ast, + isFirst: index === 0, + isLast: index === ast.children.length - 1, + }; + fn(childPath); + }); + } + }, + }; + + // Create a print function for recursive printing + const createPrintFn = (path: any): any => { + return (printPath: any) => { + return printer.print(printPath, options as ParserOptions, createPrintFn); + }; + }; + + // Print the AST to a Doc object + const doc = printer.print(astPath, options as ParserOptions, createPrintFn); + + // Convert Doc to string + if (typeof doc === "string") { + return doc.trimEnd(); + } + + // Try to convert Doc object to string using Prettier's doc printer + const docPrinter = getDocPrinter(); + if (docPrinter && typeof docPrinter === "function") { + try { + const formattedString = docPrinter(doc, { + printWidth: options.printWidth || 80, + tabWidth: options.tabWidth || 2, + useTabs: false, + }); + return typeof formattedString === "string" ? formattedString.trimEnd() : markdown; + } catch { + // Doc printing failed + return markdown; + } + } + + // If we can't convert Doc to string, return original + return markdown; + } catch (error) { + // Parsing or printing failed, return original + return markdown; + } +} diff --git a/src/prettier-markdown/get-visitor-keys.js b/src/prettier-markdown/get-visitor-keys.js new file mode 100644 index 0000000..d58d77c --- /dev/null +++ b/src/prettier-markdown/get-visitor-keys.js @@ -0,0 +1,6 @@ +import createGetVisitorKeys from "../utils/create-get-visitor-keys.js"; +import visitorKeys from "./visitor-keys.js"; + +const getVisitorKeys = createGetVisitorKeys(visitorKeys); + +export default getVisitorKeys; diff --git a/src/prettier-markdown/index.js b/src/prettier-markdown/index.js new file mode 100644 index 0000000..635f8ab --- /dev/null +++ b/src/prettier-markdown/index.js @@ -0,0 +1,8 @@ +import printer from "./printer-markdown.js"; + +export const printers = { + mdast: printer, +}; +export { default as languages } from "./languages.evaluate.js"; +export { default as options } from "./options.js"; +export * as parsers from "./parser-markdown.js"; diff --git a/src/prettier-markdown/languages.evaluate.js b/src/prettier-markdown/languages.evaluate.js new file mode 100644 index 0000000..15fddbc --- /dev/null +++ b/src/prettier-markdown/languages.evaluate.js @@ -0,0 +1,20 @@ +import * as linguistLanguages from "linguist-languages"; +import createLanguage from "../utils/create-language.js"; + +const languages = [ + createLanguage(linguistLanguages.Markdown, (data) => ({ + parsers: ["markdown"], + vscodeLanguageIds: ["markdown"], + filenames: [...data.filenames, "README"], + extensions: data.extensions.filter((extension) => extension !== ".mdx"), + })), + createLanguage(linguistLanguages.Markdown, () => ({ + name: "MDX", + parsers: ["mdx"], + vscodeLanguageIds: ["mdx"], + filenames: [], + extensions: [".mdx"], + })), +]; + +export default languages; diff --git a/src/prettier-markdown/loc.js b/src/prettier-markdown/loc.js new file mode 100644 index 0000000..c2ff900 --- /dev/null +++ b/src/prettier-markdown/loc.js @@ -0,0 +1,4 @@ +const locStart = (node) => node.position.start.offset; +const locEnd = (node) => node.position.end.offset; + +export { locEnd, locStart }; diff --git a/src/prettier-markdown/mdx.js b/src/prettier-markdown/mdx.js new file mode 100644 index 0000000..f5d1aa9 --- /dev/null +++ b/src/prettier-markdown/mdx.js @@ -0,0 +1,83 @@ +/** + * modified from https://github.com/mdx-js/mdx/blob/c91b00c673bcf3e7c28b861fd692b69016026c45/packages/remark-mdx/index.js + * + * The MIT License (MIT) + * + * Copyright (c) 2017-2018 Compositor and Zeit, Inc. + * + * 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. + */ + +const IMPORT_REGEX = /^import\s/u; +const EXPORT_REGEX = /^export\s/u; +const BLOCKS_REGEX = String.raw`[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*|`; +const COMMENT_REGEX = /|/u; +const ES_COMMENT_REGEX = /^\{\s*\/\*(.*)\*\/\s*\}/u; +const EMPTY_NEWLINE = "\n\n"; + +const isImport = (text) => IMPORT_REGEX.test(text); +const isExport = (text) => EXPORT_REGEX.test(text); +const isImportOrExport = (text) => isImport(text) || isExport(text); + +const tokenizeEsSyntax = (eat, value) => { + const index = value.indexOf(EMPTY_NEWLINE); + const subvalue = index === -1 ? value : value.slice(0, index); + + if (isImportOrExport(subvalue)) { + return eat(subvalue)({ + type: isExport(subvalue) ? "export" : "import", + value: subvalue, + }); + } +}; + +tokenizeEsSyntax.notInBlock = true; + +tokenizeEsSyntax.locator = (value /* , fromIndex*/) => + isImportOrExport(value) ? -1 : 1; + +const tokenizeEsComment = (eat, value) => { + const match = ES_COMMENT_REGEX.exec(value); + + if (match) { + return eat(match[0])({ + type: "esComment", + value: match[1].trim(), + }); + } +}; + +tokenizeEsComment.locator = (value, fromIndex) => value.indexOf("{", fromIndex); + +/** @import {Plugin, Settings} from "unified" */ + +/** + * @type {Plugin<[], Settings>} + */ +const esSyntax = function () { + const { Parser } = this; + const { blockTokenizers, blockMethods, inlineTokenizers, inlineMethods } = + Parser.prototype; + + blockTokenizers.esSyntax = tokenizeEsSyntax; + inlineTokenizers.esComment = tokenizeEsComment; + + blockMethods.splice(blockMethods.indexOf("paragraph"), 0, "esSyntax"); + inlineMethods.splice(inlineMethods.indexOf("text"), 0, "esComment"); +}; + +export { BLOCKS_REGEX, COMMENT_REGEX, esSyntax }; diff --git a/src/prettier-markdown/options.js b/src/prettier-markdown/options.js new file mode 100644 index 0000000..cff75f3 --- /dev/null +++ b/src/prettier-markdown/options.js @@ -0,0 +1,20 @@ +// Options for markdown formatting +// These match Prettier's default markdown options +// Update this file if Prettier's markdown options change +const options = { + proseWrap: { + type: "choice", + default: "preserve", + choices: [ + { value: "always", description: "Wrap prose if it exceeds the print width" }, + { value: "never", description: "Don't wrap prose" }, + { value: "preserve", description: "Preserve the original wrapping" }, + ], + }, + singleQuote: { + type: "boolean", + default: false, + }, +}; + +export default options; diff --git a/src/prettier-markdown/parser-markdown.js b/src/prettier-markdown/parser-markdown.js new file mode 100644 index 0000000..f6049a9 --- /dev/null +++ b/src/prettier-markdown/parser-markdown.js @@ -0,0 +1,57 @@ +import footnotes from "remark-footnotes"; +import remarkMath from "remark-math"; +import remarkParse from "remark-parse"; +import unified from "unified"; +import { locEnd, locStart } from "./loc.js"; +import { BLOCKS_REGEX, esSyntax } from "./mdx.js"; +import { hasIgnorePragma, hasPragma } from "./pragma.js"; +import frontMatter from "./unified-plugins/front-matter.js"; +import htmlToJsx from "./unified-plugins/html-to-jsx.js"; +import liquid from "./unified-plugins/liquid.js"; +import wikiLink from "./unified-plugins/wiki-link.js"; + +/** + * based on [MDAST](https://github.com/syntax-tree/mdast) with following modifications: + * + * 1. restore unescaped character (Text) + * 2. merge continuous Texts + * 3. replace whitespaces in InlineCode#value with one whitespace + * reference: http://spec.commonmark.org/0.25/#example-605 + * 4. split Text into Sentence + * + * interface Word { value: string } + * interface Whitespace { value: string } + * interface Sentence { children: Array } + * interface InlineCode { children: Array } + */ +function createParse({ isMDX }) { + return (text) => { + const processor = unified() + .use(remarkParse, { + commonmark: true, + ...(isMDX && { blocks: [BLOCKS_REGEX] }), + }) + .use(footnotes) + .use(frontMatter) + .use(remarkMath) + .use(isMDX ? esSyntax : noop) + .use(liquid) + .use(isMDX ? htmlToJsx : noop) + .use(wikiLink); + return processor.run(processor.parse(text)); + }; +} + +function noop() {} + +const baseParser = { + astFormat: "mdast", + hasPragma, + hasIgnorePragma, + locStart, + locEnd, +}; + +export const markdown = { ...baseParser, parse: createParse({ isMDX: false }) }; +export const mdx = { ...baseParser, parse: createParse({ isMDX: true }) }; +export { markdown as remark }; diff --git a/src/prettier-markdown/pragma.js b/src/prettier-markdown/pragma.js new file mode 100644 index 0000000..1078541 --- /dev/null +++ b/src/prettier-markdown/pragma.js @@ -0,0 +1,25 @@ +import { parseFrontMatter } from "../main/front-matter/index.js"; +import { + FORMAT_PRAGMA_TO_INSERT, + MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP, + MARKDOWN_HAS_PRAGMA_REGEXP, +} from "../utils/pragma/pragma.evaluate.js"; + +const hasPragma = (text) => + parseFrontMatter(text).content.trimStart().match(MARKDOWN_HAS_PRAGMA_REGEXP) + ?.index === 0; + +const hasIgnorePragma = (text) => + parseFrontMatter(text) + .content.trimStart() + .match(MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP)?.index === 0; + +const insertPragma = (text) => { + const { frontMatter } = parseFrontMatter(text); + const pragma = ``; + return frontMatter + ? `${frontMatter.raw}\n\n${pragma}\n\n${text.slice(frontMatter.end.index)}` + : `${pragma}\n\n${text}`; +}; + +export { hasIgnorePragma, hasPragma, insertPragma }; diff --git a/src/prettier-markdown/print-paragraph.js b/src/prettier-markdown/print-paragraph.js new file mode 100644 index 0000000..15c54cb --- /dev/null +++ b/src/prettier-markdown/print-paragraph.js @@ -0,0 +1,55 @@ +import { fill } from "../document/builders.js"; +import { DOC_TYPE_ARRAY, DOC_TYPE_FILL } from "../document/constants.js"; +import { getDocType } from "../document/utils.js"; + +/** + * @import AstPath from "../common/ast-path.js" + * @import {Doc} from "../document/builders.js" + */ + +/** + * @param {AstPath} path + * @param {*} options + * @param {*} print + * @returns {Doc} + */ +function printParagraph(path, options, print) { + const parts = path.map(print, "children"); + return flattenFill(parts); +} + +/** + * @param {Doc[]} docs + * @returns {Doc} + */ +function flattenFill(docs) { + /* + * We assume parts always meet following conditions: + * - parts.length is odd + * - odd elements are line-like doc that comes from odd element off inner fill + */ + /** @type {Doc[]} */ + const parts = [""]; + + (function rec(/** @type {*} */ docArray) { + for (const doc of docArray) { + const docType = getDocType(doc); + if (docType === DOC_TYPE_ARRAY) { + rec(doc); + continue; + } + + let head = doc; + let rest = []; + if (docType === DOC_TYPE_FILL) { + [head, ...rest] = doc.parts; + } + + parts.push([parts.pop(), head], ...rest); + } + })(docs); + + return fill(parts); +} + +export { printParagraph }; diff --git a/src/prettier-markdown/print-preprocess.js b/src/prettier-markdown/print-preprocess.js new file mode 100644 index 0000000..521a91d --- /dev/null +++ b/src/prettier-markdown/print-preprocess.js @@ -0,0 +1,256 @@ +import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js"; +import { getOrderedListItemInfo, mapAst, splitText } from "./utils.js"; + +// 0x0 ~ 0x10ffff +const isSingleCharRegex = /^\\?.$/su; +const isNewLineBlockquoteRegex = /^\n *>[ >]*$/u; + +function preprocess(ast, options) { + ast = restoreUnescapedCharacter(ast, options); + ast = mergeContinuousTexts(ast); + ast = transformIndentedCodeblockAndMarkItsParentList(ast, options); + ast = markAlignedList(ast, options); + ast = splitTextIntoSentences(ast); + return ast; +} + +function restoreUnescapedCharacter(ast, options) { + return mapAst(ast, (node) => { + if (node.type !== "text") { + return node; + } + + const { value } = node; + + if ( + value === "*" || + value === "_" || // handle these cases in printer + !isSingleCharRegex.test(value) || + node.position.end.offset - node.position.start.offset === value.length + ) { + return node; + } + + const text = options.originalText.slice( + node.position.start.offset, + node.position.end.offset, + ); + + if (isNewLineBlockquoteRegex.test(text)) { + return node; + } + + return { ...node, value: text }; + }); +} + +function mergeChildren(ast, shouldMerge, mergeNode) { + return mapAst(ast, (node) => { + if (!node.children) { + return node; + } + + const children = []; + let lastChild; + let changed; + for (let child of node.children) { + if (lastChild && shouldMerge(lastChild, child)) { + child = mergeNode(lastChild, child); + // Replace the previous node + children.splice(-1, 1, child); + changed ||= true; + } else { + children.push(child); + } + + lastChild = child; + } + + return changed ? { ...node, children } : node; + }); +} + +function mergeContinuousTexts(ast) { + return mergeChildren( + ast, + (prevNode, node) => prevNode.type === "text" && node.type === "text", + (prevNode, node) => ({ + type: "text", + value: prevNode.value + node.value, + position: { + start: prevNode.position.start, + end: node.position.end, + }, + }), + ); +} + +function splitTextIntoSentences(ast) { + return mapAst(ast, (node, index, [parentNode]) => { + if (node.type !== "text") { + return node; + } + + let { value } = node; + + if (parentNode.type === "paragraph") { + // CommonMark doesn't remove trailing/leading \f, but it should be + // removed in the HTML rendering process + if (index === 0) { + value = htmlWhitespaceUtils.trimStart(value); + } + if (index === parentNode.children.length - 1) { + value = htmlWhitespaceUtils.trimEnd(value); + } + } + + return { + type: "sentence", + position: node.position, + children: splitText(value), + }; + }); +} + +function transformIndentedCodeblockAndMarkItsParentList(ast, options) { + return mapAst(ast, (node, index, parentStack) => { + if (node.type === "code") { + // the first char may point to `\n`, e.g. `\n\t\tbar`, just ignore it + const isIndented = /^\n?(?: {4,}|\t)/u.test( + options.originalText.slice( + node.position.start.offset, + node.position.end.offset, + ), + ); + + node.isIndented = isIndented; + + if (isIndented) { + for (let i = 0; i < parentStack.length; i++) { + const parent = parentStack[i]; + + // no need to check checked items + if (parent.hasIndentedCodeblock) { + break; + } + + if (parent.type === "list") { + parent.hasIndentedCodeblock = true; + } + } + } + } + return node; + }); +} + +function markAlignedList(ast, options) { + return mapAst(ast, (node, index, parentStack) => { + if (node.type === "list" && node.children.length > 0) { + // if one of its parents is not aligned, it's not possible to be aligned in sub-lists + for (let i = 0; i < parentStack.length; i++) { + const parent = parentStack[i]; + if (parent.type === "list" && !parent.isAligned) { + node.isAligned = false; + return node; + } + } + + node.isAligned = isAligned(node); + } + + return node; + }); + + function getListItemStart(listItem) { + return listItem.children.length === 0 + ? -1 + : listItem.children[0].position.start.column - 1; + } + + function isAligned(list) { + if (!list.ordered) { + /** + * - 123 + * - 123 + */ + return true; + } + + const [firstItem, secondItem] = list.children; + + const firstInfo = getOrderedListItemInfo(firstItem, options); + + if (firstInfo.leadingSpaces.length > 1) { + /** + * 1. 123 + * + * 1. 123 + * 1. 123 + */ + return true; + } + + const firstStart = getListItemStart(firstItem); + + if (firstStart === -1) { + /** + * 1. + * + * 1. + * 1. + */ + return false; + } + + if (list.children.length === 1) { + /** + * aligned: + * + * 11. 123 + * + * not aligned: + * + * 1. 123 + */ + return firstStart % options.tabWidth === 0; + } + + const secondStart = getListItemStart(secondItem); + + if (firstStart !== secondStart) { + /** + * 11. 123 + * 1. 123 + * + * 1. 123 + * 11. 123 + */ + return false; + } + + if (firstStart % options.tabWidth === 0) { + /** + * 11. 123 + * 12. 123 + */ + return true; + } + + /** + * aligned: + * + * 11. 123 + * 1. 123 + * + * not aligned: + * + * 1. 123 + * 2. 123 + */ + const secondInfo = getOrderedListItemInfo(secondItem, options); + return secondInfo.leadingSpaces.length > 1; + } +} + +export default preprocess; diff --git a/src/prettier-markdown/print-sentence.js b/src/prettier-markdown/print-sentence.js new file mode 100644 index 0000000..70c1abf --- /dev/null +++ b/src/prettier-markdown/print-sentence.js @@ -0,0 +1,37 @@ +/** + * @import AstPath from "../common/ast-path.js" + * @import {Doc} from "../document/builders.js" + */ + +import { fill } from "../document/builders.js"; +import { DOC_TYPE_STRING } from "../document/constants.js"; +import { getDocType } from "../document/utils.js"; + +/** + * @param {AstPath} path + * @param {*} print + * @returns {Doc} + */ +function printSentence(path, print) { + /** @type {Doc[]} */ + const parts = [""]; + + path.each(() => { + const { node } = path; + const doc = print(); + switch (node.type) { + case "whitespace": + if (getDocType(doc) !== DOC_TYPE_STRING) { + parts.push(doc, ""); + break; + } + // fallthrough + default: + parts.push([parts.pop(), doc]); + } + }, "children"); + + return fill(parts); +} + +export { printSentence }; diff --git a/src/prettier-markdown/print-whitespace.js b/src/prettier-markdown/print-whitespace.js new file mode 100644 index 0000000..d489605 --- /dev/null +++ b/src/prettier-markdown/print-whitespace.js @@ -0,0 +1,266 @@ +import { hardline, line, softline } from "../document/builders.js"; +import { + KIND_CJ_LETTER, + KIND_CJK_PUNCTUATION, + KIND_K_LETTER, + KIND_NON_CJK, +} from "./utils.js"; + +/** + * @import {WordNode, WhitespaceValue, WordKind} from "./utils.js" + * @import AstPath from "../common/ast-path.js" + * @typedef {"always" | "never" | "preserve"} ProseWrap + * @typedef {{ next?: WordNode | null, previous?: WordNode | null }} + * AdjacentNodes Nodes adjacent to a `whitespace` node. Are always of type + * `word`. + */ + +const SINGLE_LINE_NODE_TYPES = new Set([ + "heading", + "tableCell", + "link", + "wikiLink", +]); + +/** + * A line break between a character from this set and CJ can be converted to a + * space. Includes only ASCII punctuation marks for now. + */ +const lineBreakBetweenTheseAndCJConvertsToSpace = new Set( + "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", +); + +/** + * Determine the preferred style of spacing between Chinese or Japanese and non-CJK + * characters in the parent `sentence` node. + * + * @param {AstPath} path + * @returns {boolean} `true` if Space tends to be inserted between CJ and + * non-CJK, `false` otherwise. + */ +function isInSentenceWithCJSpaces({ parent: sentenceNode }) { + if (sentenceNode.usesCJSpaces === undefined) { + const stats = { " ": 0, "": 0 }; + const { children } = sentenceNode; + + for (let i = 1; i < children.length - 1; ++i) { + const node = children[i]; + if ( + node.type === "whitespace" && + (node.value === " " || node.value === "") + ) { + const previousKind = children[i - 1].kind; + const nextKind = children[i + 1].kind; + if ( + (previousKind === KIND_CJ_LETTER && nextKind === KIND_NON_CJK) || + (previousKind === KIND_NON_CJK && nextKind === KIND_CJ_LETTER) + ) { + ++stats[node.value]; + } + } + } + + // Inject a property to cache the result. + sentenceNode.usesCJSpaces = stats[" "] > stats[""]; + } + + return sentenceNode.usesCJSpaces; +} + +/** + * Check whether the given `"\n"` node can be converted to a space. + * + * For example, if you would like to squash English text + * + * "You might want\nto use Prettier." + * + * into a single line, you would replace `"\n"` with `" "`: + * + * "You might want to use Prettier." + * + * However, Chinese and Japanese don't use U+0020 Space to divide words, so line + * breaks shouldn't be replaced with spaces for those languages. + * + * PRs are welcome to support line breaking rules for other languages. + * + * @param {AstPath} path + * @param {boolean} isLink + * @returns {boolean} + */ +function lineBreakCanBeConvertedToSpace(path, isLink) { + if (isLink) { + return true; + } + + /** @type {AdjacentNodes} */ + const { previous, next } = path; + + // e.g. " \nletter" + if (!previous || !next) { + return true; + } + + const previousKind = previous.kind; + const nextKind = next.kind; + + if ( + // "\n" between non-CJK or Korean characters always can be converted to a + // space. Korean Hangul simulates Latin words. See + // https://github.com/prettier/prettier/issues/6516 + (isNonCJKOrKoreanLetter(previousKind) && + isNonCJKOrKoreanLetter(nextKind)) || + // Han & Hangul: same way preferred + (previousKind === KIND_K_LETTER && nextKind === KIND_CJ_LETTER) || + (nextKind === KIND_K_LETTER && previousKind === KIND_CJ_LETTER) + ) { + return true; + } + + // Do not convert \n to a space: + if ( + // around CJK punctuation + previousKind === KIND_CJK_PUNCTUATION || + nextKind === KIND_CJK_PUNCTUATION || + // between CJ + (previousKind === KIND_CJ_LETTER && nextKind === KIND_CJ_LETTER) + ) { + return false; + } + + // The rest of this function deals only with line breaks between CJ and + // non-CJK characters. + + // Convert a line break between CJ and certain non-letter characters (e.g. + // ASCII punctuation) to a space. + // + // E.g. :::\n句子句子句子\n::: → ::: 句子句子句子 ::: + // + // Note: line breaks like "(\n句子句子\n)" or "句子\n." are suppressed in + // `isBreakable(...)`. + if ( + lineBreakBetweenTheseAndCJConvertsToSpace.has(next.value[0]) || + lineBreakBetweenTheseAndCJConvertsToSpace.has(previous.value.at(-1)) + ) { + return true; + } + + // Converting a line break between CJ and non-ASCII punctuation to a space is + // undesired in many cases. PRs are welcome to fine-tune this logic. + // + // Examples where \n must not be converted to a space: + // + // 1. "〜" (U+301C, belongs to Pd) in + // + // "ア〜\nエの中から1つ選べ。" + // + // 2. "…" (U+2026, belongs to Po) in + // + // "これはひどい……\nなんと汚いコミットログなんだ……" + if (previous.hasTrailingPunctuation || next.hasLeadingPunctuation) { + return false; + } + + // If the sentence uses the style with spaces between CJ and non-CJK, "\n" can + // be converted to a space. + return isInSentenceWithCJSpaces(path); +} + +/** + * @param {WordKind | undefined} kind + * @returns {boolean} `true` if `kind` is Korean letter or non-CJK + */ +function isNonCJKOrKoreanLetter(kind) { + return kind === KIND_NON_CJK || kind === KIND_K_LETTER; +} + +/** + * Check whether whitespace can be printed as a line break. + * + * @param {AstPath} path + * @param {WhitespaceValue} value + * @param {ProseWrap} proseWrap + * @param {boolean} isLink + * @returns {boolean} + */ +function isBreakable(path, value, proseWrap, isLink) { + if ( + proseWrap !== "always" || + path.hasAncestor((node) => SINGLE_LINE_NODE_TYPES.has(node.type)) + ) { + return false; + } + + if (isLink) { + return value !== ""; + } + + /** @type {AdjacentNodes} */ + const { previous, next } = path; + + // [1]: We will make a breaking change to the rule to convert spaces between + // a Chinese or Japanese character and another character in the future. + // Such a space must have been always interchangeable with a line break. + // https://wpt.fyi/results/css/css-text/line-breaking?label=master&label=experimental&aligned&q=segment-break-transformation-rules- + // [2]: we should not break lines even between Chinese/Japanese characters because Chrome & Safari replaces "\n" between such characters with " " now. + // [3]: Hangul (Korean) must simulate Latin words; see https://github.com/prettier/prettier/issues/6516 + // [printable][""][Hangul] & vice versa => Don't break + // [printable][\n][Hangul] will be interchangeable to [printable][" "][Hangul] in the future + // (will be compatible with Firefox's behavior) + + if (!previous || !next) { + // empty side is Latin ASCII symbol (e.g. *, [, ], or `) + // value is " " or "\n" (not "") + // [1] & [2]? No, it's the only exception because " " & "\n" have been always interchangeable only here + return true; + } + + if (value === "") { + // [1] & [2] & [3] + // At least either of previous or next is non-Latin (=CJK) + return false; + } + + if ( + // See the same product terms as the following in lineBreakCanBeConvertedToSpace + // The behavior is consistent between browsers and Prettier in that line breaks between Korean and Chinese/Japanese letters are equivalent to spaces. + // Currently, [CJK punctuation][\n][Hangul] is interchangeable to [CJK punctuation][""][Hangul], + // but this is not compatible with Firefox's behavior. + // Will be changed to [CJK punctuation][" "][Hangul] in the future + (previous.kind === KIND_K_LETTER && next.kind === KIND_CJ_LETTER) || + (next.kind === KIND_K_LETTER && previous.kind === KIND_CJ_LETTER) + ) { + return true; + } + + // [1] & [2] + if (previous.isCJ || next.isCJ) { + return false; + } + + return true; +} + +/** + * @param {AstPath} path + * @param {WhitespaceValue} value + * @param {ProseWrap} proseWrap + * @param {boolean} [isLink] Special mode of (un)wrapping that preserves the + * normalized form of link labels. https://spec.commonmark.org/0.30/#matches + */ +function printWhitespace(path, value, proseWrap, isLink) { + if (proseWrap === "preserve" && value === "\n") { + return hardline; + } + + const canBeSpace = + value === " " || + (value === "\n" && lineBreakCanBeConvertedToSpace(path, isLink)); + + if (isBreakable(path, value, proseWrap, isLink)) { + return canBeSpace ? line : softline; + } + + return canBeSpace ? " " : ""; +} + +export { printWhitespace }; diff --git a/src/prettier-markdown/print/table.js b/src/prettier-markdown/print/table.js new file mode 100644 index 0000000..cc8fbc9 --- /dev/null +++ b/src/prettier-markdown/print/table.js @@ -0,0 +1,81 @@ +import { + breakParent, + group, + hardlineWithoutBreakParent, + ifBreak, + join, +} from "../../document/builders.js"; +import { printDocToString } from "../../document/printer.js"; +import getStringWidth from "../../utils/get-string-width.js"; + +function printTable(path, options, print) { + const { node } = path; + + const columnMaxWidths = []; + // { [rowIndex: number]: { [columnIndex: number]: {text: string, width: number} } } + const contents = path.map( + () => + path.map(({ index: columnIndex }) => { + const text = printDocToString(print(), options).formatted; + const width = getStringWidth(text); + columnMaxWidths[columnIndex] = Math.max( + columnMaxWidths[columnIndex] ?? 3, // minimum width = 3 (---, :--, :-:, --:) + width, + ); + return { text, width }; + }, "children"), + "children", + ); + + const alignedTable = printTableContents(/* isCompact */ false); + if (options.proseWrap !== "never") { + return [breakParent, alignedTable]; + } + + // Only if the --prose-wrap never is set and it exceeds the print width. + const compactTable = printTableContents(/* isCompact */ true); + return [breakParent, group(ifBreak(compactTable, alignedTable))]; + + function printTableContents(isCompact) { + return join( + hardlineWithoutBreakParent, + [ + printRow(contents[0], isCompact), + printAlign(isCompact), + ...contents + .slice(1) + .map((rowContents) => printRow(rowContents, isCompact)), + ].map((columns) => `| ${columns.join(" | ")} |`), + ); + } + + function printAlign(isCompact) { + return columnMaxWidths.map((width, index) => { + const align = node.align[index]; + const first = align === "center" || align === "left" ? ":" : "-"; + const last = align === "center" || align === "right" ? ":" : "-"; + const middle = isCompact ? "-" : "-".repeat(width - 2); + return `${first}${middle}${last}`; + }); + } + + function printRow(columns, isCompact) { + return columns.map(({ text, width }, columnIndex) => { + if (isCompact) { + return text; + } + const spaces = columnMaxWidths[columnIndex] - width; + const align = node.align[columnIndex]; + let before = 0; + if (align === "right") { + before = spaces; + } else if (align === "center") { + before = Math.floor(spaces / 2); + } + const after = spaces - before; + return `${" ".repeat(before)}${text}${" ".repeat(after)}`; + }); + } +} + +export { printTable }; diff --git a/src/prettier-markdown/printer-markdown.js b/src/prettier-markdown/printer-markdown.js new file mode 100644 index 0000000..264442b --- /dev/null +++ b/src/prettier-markdown/printer-markdown.js @@ -0,0 +1,810 @@ +import collapseWhiteSpace from "collapse-white-space"; +import escapeStringRegexp from "escape-string-regexp"; +import { + align, + fill, + group, + hardline, + indent, + line, + literalline, + markAsRoot, + softline, +} from "./adapter-document-builders.js"; +import { DOC_TYPE_STRING } from "./adapter-document-constants.js"; +import { getDocType, replaceEndOfLine } from "./adapter-document-utils.js"; +import { + getMaxContinuousCount, + getMinNotPresentContinuousCount, + getPreferredQuote, + UnexpectedNodeError, +} from "./adapter-prettier-utils.js"; +import clean from "./clean.js"; +import { PUNCTUATION_REGEXP } from "./constants.evaluate.js"; +import embed from "./embed.js"; +import getVisitorKeys from "./get-visitor-keys.js"; +import { locEnd, locStart } from "./loc.js"; +import { insertPragma } from "./pragma.js"; +import { printTable } from "./print/table.js"; +import { printParagraph } from "./print-paragraph.js"; +import preprocess from "./print-preprocess.js"; +import { printSentence } from "./print-sentence.js"; +import { printWhitespace } from "./print-whitespace.js"; +import { + getFencedCodeBlockValue, + hasGitDiffFriendlyOrderedList, + INLINE_NODE_TYPES, + INLINE_NODE_WRAPPER_TYPES, + isAutolink, + splitText, +} from "./utils.js"; + +/** + * @import {Doc} from "../document/builders.js" + */ + +const SIBLING_NODE_TYPES = new Set(["listItem", "definition"]); + +function prevOrNextWord(path) { + const { previous, next } = path; + const hasPrevOrNextWord = + (previous?.type === "sentence" && + previous.children.at(-1)?.type === "word" && + !previous.children.at(-1).hasTrailingPunctuation) || + (next?.type === "sentence" && + next.children[0]?.type === "word" && + !next.children[0].hasLeadingPunctuation); + return hasPrevOrNextWord; +} + +function genericPrint(path, options, print) { + const { node } = path; + + if (shouldRemainTheSameContent(path)) { + /* + * We assume parts always meet following conditions: + * - parts.length is odd + * - odd (0-indexed) elements are line-like doc + */ + /** @type {Doc[]} */ + const parts = [""]; + const textsNodes = splitText( + options.originalText.slice( + node.position.start.offset, + node.position.end.offset, + ), + ); + for (const node of textsNodes) { + if (node.type === "word") { + parts.push([parts.pop(), node.value]); + continue; + } + const doc = printWhitespace(path, node.value, options.proseWrap, true); + if (getDocType(doc) === DOC_TYPE_STRING) { + parts.push([parts.pop(), doc]); + continue; + } + // In this path, doc is line. To meet the condition, we need additional element "". + parts.push(doc, ""); + } + return fill(parts); + } + + switch (node.type) { + case "root": + /* c8 ignore next 3 */ + if (node.children.length === 0) { + return ""; + } + return [printRoot(path, options, print), hardline]; + case "paragraph": + return printParagraph(path, options, print); + case "sentence": + return printSentence(path, print); + case "word": { + let escapedValue = node.value + .replaceAll("*", String.raw`\*`) // escape all `*` + .replaceAll( + new RegExp( + [ + `(^|${PUNCTUATION_REGEXP.source})(_+)`, + `(_+)(${PUNCTUATION_REGEXP.source}|$)`, + ].join("|"), + "gu", + ), + (_, text1, underscore1, underscore2, text2) => + (underscore1 + ? `${text1}${underscore1}` + : `${underscore2}${text2}` + ).replaceAll("_", String.raw`\_`), + ); // escape all `_` except concating with non-punctuation, e.g. `1_2_3` is not considered emphasis + + const isFirstSentence = (node, name, index) => + node.type === "sentence" && index === 0; + const isLastChildAutolink = (node, name, index) => + isAutolink(node.children[index - 1]); + + if ( + escapedValue !== node.value && + (path.match(undefined, isFirstSentence, isLastChildAutolink) || + path.match( + undefined, + isFirstSentence, + (node, name, index) => node.type === "emphasis" && index === 0, + isLastChildAutolink, + )) + ) { + // backslash is parsed as part of autolinks, so we need to remove it + escapedValue = escapedValue.replace(/^(\\?[*_])+/u, (prefix) => + prefix.replaceAll("\\", ""), + ); + } + + return escapedValue; + } + case "whitespace": { + const { next } = path; + + const proseWrap = + // leading char that may cause different syntax + next && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(next.value) + ? "never" + : options.proseWrap; + + return printWhitespace(path, node.value, proseWrap); + } + case "emphasis": { + let style; + if (isAutolink(node.children[0])) { + style = options.originalText[node.position.start.offset]; + } else { + const hasPrevOrNextWord = prevOrNextWord(path); // `1*2*3` is considered emphasis but `1_2_3` is not + const inStrongAndHasPrevOrNextWord = // `1***2***3` is considered strong emphasis but `1**_2_**3` is not + path.callParent( + ({ node }) => node.type === "strong" && prevOrNextWord(path), + ); + style = + hasPrevOrNextWord || + inStrongAndHasPrevOrNextWord || + path.hasAncestor((node) => node.type === "emphasis") + ? "*" + : "_"; + } + return [style, printChildren(path, options, print), style]; + } + case "strong": + return ["**", printChildren(path, options, print), "**"]; + case "delete": + return ["~~", printChildren(path, options, print), "~~"]; + case "inlineCode": { + const code = + options.proseWrap === "preserve" + ? node.value + : node.value.replaceAll("\n", " "); + const backtickCount = getMinNotPresentContinuousCount(code, "`"); + const backtickString = "`".repeat(backtickCount); + const padding = + code.startsWith("`") || + code.endsWith("`") || + (/^[\n ]/u.test(code) && /[\n ]$/u.test(code) && /[^\n ]/u.test(code)) + ? " " + : ""; + return [backtickString, padding, code, padding, backtickString]; + } + case "wikiLink": { + let contents = ""; + if (options.proseWrap === "preserve") { + contents = node.value; + } else { + contents = node.value.replaceAll(/[\t\n]+/gu, " "); + } + + return ["[[", contents, "]]"]; + } + case "link": + switch (options.originalText[node.position.start.offset]) { + case "<": { + const mailto = "mailto:"; + const url = + // is parsed as { url: "mailto:hello@example.com" } + node.url.startsWith(mailto) && + options.originalText.slice( + node.position.start.offset + 1, + node.position.start.offset + 1 + mailto.length, + ) !== mailto + ? node.url.slice(mailto.length) + : node.url; + return ["<", url, ">"]; + } + case "[": + return [ + "[", + printChildren(path, options, print), + "](", + printUrl(node.url, ")"), + printTitle(node.title, options), + ")", + ]; + default: + return options.originalText.slice( + node.position.start.offset, + node.position.end.offset, + ); + } + case "image": + return [ + "![", + node.alt || "", + "](", + printUrl(node.url, ")"), + printTitle(node.title, options), + ")", + ]; + case "blockquote": + return ["> ", align("> ", printChildren(path, options, print))]; + case "heading": + return [ + "#".repeat(node.depth) + " ", + printChildren(path, options, print), + ]; + case "code": { + if (node.isIndented) { + // indented code block + const alignment = " ".repeat(4); + return align(alignment, [ + alignment, + replaceEndOfLine(node.value, hardline), + ]); + } + + // fenced code block + const styleUnit = options.__inJsTemplate ? "~" : "`"; + const style = styleUnit.repeat( + Math.max(3, getMaxContinuousCount(node.value, styleUnit) + 1), + ); + return [ + style, + node.lang || "", + node.meta ? " " + node.meta : "", + hardline, + replaceEndOfLine( + getFencedCodeBlockValue(node, options.originalText), + hardline, + ), + hardline, + style, + ]; + } + case "html": { + const { parent, isLast } = path; + const value = + parent.type === "root" && isLast ? node.value.trimEnd() : node.value; + const isHtmlComment = /^$/su.test(value); + + return replaceEndOfLine( + value, + // @ts-expect-error + isHtmlComment ? hardline : markAsRoot(literalline), + ); + } + case "list": { + const nthSiblingIndex = getNthListSiblingIndex(node, path.parent); + + const isGitDiffFriendlyOrderedList = hasGitDiffFriendlyOrderedList( + node, + options, + ); + + return printChildren(path, options, print, { + processor() { + const prefix = getPrefix(); + const { node: childNode } = path; + + if ( + childNode.children.length === 2 && + childNode.children[1].type === "html" && + childNode.children[0].position.start.column !== + childNode.children[1].position.start.column + ) { + return [prefix, printListItem(path, options, print, prefix)]; + } + + return [ + prefix, + align( + " ".repeat(prefix.length), + printListItem(path, options, print, prefix), + ), + ]; + + function getPrefix() { + const rawPrefix = node.ordered + ? (path.isFirst + ? node.start + : isGitDiffFriendlyOrderedList + ? 1 + : node.start + path.index) + + (nthSiblingIndex % 2 === 0 ? ". " : ") ") + : nthSiblingIndex % 2 === 0 + ? "- " + : "* "; + + return (node.isAligned || + /* workaround for https://github.com/remarkjs/remark/issues/315 */ node.hasIndentedCodeblock) && + node.ordered + ? alignListPrefix(rawPrefix, options) + : rawPrefix; + } + }, + }); + } + case "thematicBreak": { + const { ancestors } = path; + const counter = ancestors.findIndex((node) => node.type === "list"); + if (counter === -1) { + return "---"; + } + const nthSiblingIndex = getNthListSiblingIndex( + ancestors[counter], + ancestors[counter + 1], + ); + return nthSiblingIndex % 2 === 0 ? "***" : "---"; + } + case "linkReference": + return [ + "[", + printChildren(path, options, print), + "]", + node.referenceType === "full" + ? printLinkReference(node) + : node.referenceType === "collapsed" + ? "[]" + : "", + ]; + case "imageReference": + switch (node.referenceType) { + case "full": + return ["![", node.alt || "", "]", printLinkReference(node)]; + default: + return [ + "![", + node.alt, + "]", + node.referenceType === "collapsed" ? "[]" : "", + ]; + } + case "definition": { + const lineOrSpace = options.proseWrap === "always" ? line : " "; + return group([ + printLinkReference(node), + ":", + indent([ + lineOrSpace, + printUrl(node.url), + node.title === null + ? "" + : [lineOrSpace, printTitle(node.title, options, false)], + ]), + ]); + } + // `footnote` requires `.use(footnotes, {inlineNotes: true})`, we are not using this option + // https://github.com/remarkjs/remark-footnotes#optionsinlinenotes + /* c8 ignore next 2 */ + case "footnote": + return ["[^", printChildren(path, options, print), "]"]; + case "footnoteReference": + return printFootnoteReference(node); + case "footnoteDefinition": { + const shouldInlineFootnote = + node.children.length === 1 && + node.children[0].type === "paragraph" && + (options.proseWrap === "never" || + (options.proseWrap === "preserve" && + node.children[0].position.start.line === + node.children[0].position.end.line)); + return [ + printFootnoteReference(node), + ": ", + shouldInlineFootnote + ? printChildren(path, options, print) + : group([ + align( + " ".repeat(4), + printChildren(path, options, print, { + processor: ({ isFirst }) => + isFirst ? group([softline, print()]) : print(), + }), + ), + ]), + ]; + } + case "table": + return printTable(path, options, print); + case "tableCell": + return printChildren(path, options, print); + case "break": + return /\s/u.test(options.originalText[node.position.start.offset]) + ? [" ", markAsRoot(literalline)] + : ["\\", hardline]; + case "liquidNode": + return replaceEndOfLine(node.value, hardline); + // MDX + // fallback to the original text if multiparser failed + // or `embeddedLanguageFormatting: "off"` + case "import": + case "export": + case "jsx": + return node.value.trimEnd(); + case "esComment": + return ["{/* ", node.value, " */}"]; + case "math": + return [ + "$$", + hardline, + node.value ? [replaceEndOfLine(node.value, hardline), hardline] : "", + "$$", + ]; + case "inlineMath": + // remark-math trims content but we don't want to remove whitespaces + // since it's very possible that it's recognized as math accidentally + return options.originalText.slice(locStart(node), locEnd(node)); + + case "frontMatter": // Handled in core + case "tableRow": // handled in "table" + case "listItem": // handled in "list" + case "text": // handled in other types + default: + /* c8 ignore next */ + throw new UnexpectedNodeError(node, "Markdown"); + } +} + +function printListItem(path, options, print, listPrefix) { + const { node } = path; + const prefix = node.checked === null ? "" : node.checked ? "[x] " : "[ ] "; + return [ + prefix, + printChildren(path, options, print, { + processor({ node, isFirst }) { + if (isFirst && node.type !== "list") { + return align(" ".repeat(prefix.length), print()); + } + + const alignment = " ".repeat( + clamp(options.tabWidth - listPrefix.length, 0, 3), // 4+ will cause indented code block + ); + return [alignment, align(alignment, print())]; + }, + }), + ]; +} + +function alignListPrefix(prefix, options) { + const additionalSpaces = getAdditionalSpaces(); + return ( + prefix + + " ".repeat( + additionalSpaces >= 4 ? 0 : additionalSpaces, // 4+ will cause indented code block + ) + ); + + function getAdditionalSpaces() { + const restSpaces = prefix.length % options.tabWidth; + return restSpaces === 0 ? 0 : options.tabWidth - restSpaces; + } +} + +function getNthListSiblingIndex(node, parentNode) { + return getNthSiblingIndex( + node, + parentNode, + (siblingNode) => siblingNode.ordered === node.ordered, + ); +} + +function getNthSiblingIndex(node, parentNode, condition) { + let index = -1; + + for (const childNode of parentNode.children) { + if (childNode.type === node.type && condition(childNode)) { + index++; + } else { + index = -1; + } + + if (childNode === node) { + return index; + } + } +} + +function printRoot(path, options, print) { + /** @typedef {{ index: number, offset: number }} IgnorePosition */ + /** @type {Array<{start: IgnorePosition, end: IgnorePosition}>} */ + const ignoreRanges = []; + + /** @type {IgnorePosition | null} */ + let ignoreStart = null; + + const { children } = path.node; + for (const [index, childNode] of children.entries()) { + switch (isPrettierIgnore(childNode)) { + case "start": + if (ignoreStart === null) { + ignoreStart = { index, offset: childNode.position.end.offset }; + } + break; + case "end": + if (ignoreStart !== null) { + ignoreRanges.push({ + start: ignoreStart, + end: { index, offset: childNode.position.start.offset }, + }); + ignoreStart = null; + } + break; + default: + // do nothing + break; + } + } + + return printChildren(path, options, print, { + processor({ index }) { + if (ignoreRanges.length > 0) { + const ignoreRange = ignoreRanges[0]; + + if (index === ignoreRange.start.index) { + return [ + printIgnoreComment(children[ignoreRange.start.index]), + options.originalText.slice( + ignoreRange.start.offset, + ignoreRange.end.offset, + ), + printIgnoreComment(children[ignoreRange.end.index]), + ]; + } + + if (ignoreRange.start.index < index && index < ignoreRange.end.index) { + return false; + } + + if (index === ignoreRange.end.index) { + ignoreRanges.shift(); + return false; + } + } + + return print(); + }, + }); +} + +function printChildren(path, options, print, events = {}) { + const { processor = print } = events; + + const parts = []; + + path.each(() => { + const result = processor(path); + if (result !== false) { + if (parts.length > 0 && shouldPrePrintHardline(path)) { + parts.push(hardline); + + if ( + shouldPrePrintDoubleHardline(path, options) || + shouldPrePrintTripleHardline(path) + ) { + parts.push(hardline); + } + + if (shouldPrePrintTripleHardline(path)) { + parts.push(hardline); + } + } + + parts.push(result); + } + }, "children"); + + return parts; +} + +function printIgnoreComment(node) { + if (node.type === "html") { + return node.value; + } + + if ( + node.type === "paragraph" && + Array.isArray(node.children) && + node.children.length === 1 && + node.children[0].type === "esComment" + ) { + return ["{/* ", node.children[0].value, " */}"]; + } +} + +/** @return {false | 'next' | 'start' | 'end'} */ +function isPrettierIgnore(node) { + let match; + + if (node.type === "html") { + match = node.value.match( + /^$/u, + ); + } else { + let comment; + + if (node.type === "esComment") { + comment = node; + } else if ( + node.type === "paragraph" && + node.children.length === 1 && + node.children[0].type === "esComment" + ) { + comment = node.children[0]; + } + + if (comment) { + match = comment.value.match(/^prettier-ignore(?:-(start|end))?$/u); + } + } + + return match ? match[1] || "next" : false; +} + +function shouldPrePrintHardline({ node, parent }) { + const isInlineNode = INLINE_NODE_TYPES.has(node.type); + + const isInlineHTML = + node.type === "html" && INLINE_NODE_WRAPPER_TYPES.has(parent.type); + + return !isInlineNode && !isInlineHTML; +} + +function isLooseListItem(node, options) { + return ( + node.type === "listItem" && + (node.spread || + // Check if `listItem` ends with `\n` + // since it can't be empty, so we only need check the last character + options.originalText.charAt(node.position.end.offset - 1) === "\n") + ); +} + +function shouldPrePrintDoubleHardline({ node, previous, parent }, options) { + if ( + isLooseListItem(previous, options) || + (node.type === "list" && + parent.type === "listItem" && + previous.type === "code") + ) { + return true; + } + + const isSequence = previous.type === node.type; + const isSiblingNode = isSequence && SIBLING_NODE_TYPES.has(node.type); + const isInTightListItem = + parent.type === "listItem" && + (node.type === "list" || !isLooseListItem(parent, options)); + const isPrevNodePrettierIgnore = isPrettierIgnore(previous) === "next"; + const isBlockHtmlWithoutBlankLineBetweenPrevHtml = + node.type === "html" && + previous.type === "html" && + previous.position.end.line + 1 === node.position.start.line; + const isHtmlDirectAfterListItem = + node.type === "html" && + parent.type === "listItem" && + previous.type === "paragraph" && + previous.position.end.line + 1 === node.position.start.line; + + return !( + isSiblingNode || + isInTightListItem || + isPrevNodePrettierIgnore || + isBlockHtmlWithoutBlankLineBetweenPrevHtml || + isHtmlDirectAfterListItem + ); +} + +function shouldPrePrintTripleHardline({ node, previous }) { + const isPrevNodeList = previous.type === "list"; + const isIndentedCode = node.type === "code" && node.isIndented; + + return isPrevNodeList && isIndentedCode; +} + +function shouldRemainTheSameContent(path) { + const node = path.findAncestor( + (node) => node.type === "linkReference" || node.type === "imageReference", + ); + return ( + node && (node.type !== "linkReference" || node.referenceType !== "full") + ); +} + +const encodeUrl = (url, characters) => { + for (const character of characters) { + url = url.replaceAll(character, encodeURIComponent(character)); + } + return url; +}; + +/** + * @param {string} url + * @param {string[] | string} [dangerousCharOrChars] + * @returns {string} + */ +function printUrl(url, dangerousCharOrChars = []) { + const dangerousChars = [ + " ", + ...(Array.isArray(dangerousCharOrChars) + ? dangerousCharOrChars + : [dangerousCharOrChars]), + ]; + + return new RegExp( + dangerousChars.map((x) => escapeStringRegexp(x)).join("|"), + "u", + ).test(url) + ? `<${encodeUrl(url, "<>")}>` + : url; +} + +function printTitle(title, options, printSpace = true) { + if (!title) { + return ""; + } + if (printSpace) { + return " " + printTitle(title, options, false); + } + + // title is escaped after `remark-parse` v7 + title = title.replaceAll(/\\(?=["')])/gu, ""); + + if (title.includes('"') && title.includes("'") && !title.includes(")")) { + return `(${title})`; // avoid escaped quotes + } + const quote = getPreferredQuote(title, options.singleQuote); + title = title.replaceAll("\\", "\\\\"); + title = title.replaceAll(quote, `\\${quote}`); + return `${quote}${title}${quote}`; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(value, max)); +} + +function hasPrettierIgnore(path) { + return path.index > 0 && isPrettierIgnore(path.previous) === "next"; +} + +// `remark-parse` lowercase the `label` as `identifier`, we don't want do that +// https://github.com/remarkjs/remark/blob/daddcb463af2d5b2115496c395d0571c0ff87d15/packages/remark-parse/lib/tokenize/reference.js +function printLinkReference(node) { + return `[${collapseWhiteSpace(node.label)}]`; +} + +function printFootnoteReference(node) { + return `[^${node.label}]`; +} + +const printer = { + features: { + experimental_frontMatterSupport: { + massageAstNode: true, + embed: true, + print: true, + }, + }, + preprocess, + print: genericPrint, + embed, + massageAstNode: clean, + hasPrettierIgnore, + insertPragma, + getVisitorKeys, +}; + +export default printer; diff --git a/src/prettier-markdown/unified-plugins/front-matter.js b/src/prettier-markdown/unified-plugins/front-matter.js new file mode 100644 index 0000000..3bda14e --- /dev/null +++ b/src/prettier-markdown/unified-plugins/front-matter.js @@ -0,0 +1,23 @@ +import { parseFrontMatter } from "../../main/front-matter/index.js"; + +/** @import {Plugin, Settings} from "unified" */ + +/** + * @type {Plugin<[], Settings>} + */ +const frontMatter = function () { + const proto = this.Parser.prototype; + proto.blockMethods = ["frontMatter", ...proto.blockMethods]; + proto.blockTokenizers.frontMatter = tokenizer; + + function tokenizer(eat, value) { + const { frontMatter } = parseFrontMatter(value); + + if (frontMatter) { + return eat(frontMatter.raw)({ ...frontMatter, type: "frontMatter" }); + } + } + tokenizer.onlyAtStart = true; +}; + +export default frontMatter; diff --git a/src/prettier-markdown/unified-plugins/html-to-jsx.js b/src/prettier-markdown/unified-plugins/html-to-jsx.js new file mode 100644 index 0000000..c8d8666 --- /dev/null +++ b/src/prettier-markdown/unified-plugins/html-to-jsx.js @@ -0,0 +1,19 @@ +import { COMMENT_REGEX } from "../mdx.js"; +import { INLINE_NODE_WRAPPER_TYPES, mapAst } from "../utils.js"; + +function htmlToJsx() { + return (ast) => + mapAst(ast, (node, _index, [parent]) => { + if ( + node.type !== "html" || + // Keep HTML-style comments (legacy MDX) + COMMENT_REGEX.test(node.value) || + INLINE_NODE_WRAPPER_TYPES.has(parent.type) + ) { + return node; + } + return { ...node, type: "jsx" }; + }); +} + +export default htmlToJsx; diff --git a/src/prettier-markdown/unified-plugins/liquid.js b/src/prettier-markdown/unified-plugins/liquid.js new file mode 100644 index 0000000..4071cfa --- /dev/null +++ b/src/prettier-markdown/unified-plugins/liquid.js @@ -0,0 +1,27 @@ +/** @import {Plugin, Settings} from "unified" */ + +/** + * @type {Plugin<[], Settings>} + */ +const liquid = function () { + const proto = this.Parser.prototype; + const methods = proto.inlineMethods; + methods.splice(methods.indexOf("text"), 0, "liquid"); + proto.inlineTokenizers.liquid = tokenizer; + + function tokenizer(eat, value) { + const match = value.match(/^(\{%.*?%\}|\{\{.*?\}\})/su); + + if (match) { + return eat(match[0])({ + type: "liquidNode", + value: match[0], + }); + } + } + tokenizer.locator = function (value, fromIndex) { + return value.indexOf("{", fromIndex); + }; +}; + +export default liquid; diff --git a/src/prettier-markdown/unified-plugins/wiki-link.js b/src/prettier-markdown/unified-plugins/wiki-link.js new file mode 100644 index 0000000..797b30e --- /dev/null +++ b/src/prettier-markdown/unified-plugins/wiki-link.js @@ -0,0 +1,32 @@ +/** @import {Plugin, Settings} from "unified" */ + +/** + * @type {Plugin<[], Settings>} + */ +const wikiLink = function () { + const entityType = "wikiLink"; + const wikiLinkRegex = /^\[\[(?.+?)\]\]/su; + const proto = this.Parser.prototype; + const methods = proto.inlineMethods; + methods.splice(methods.indexOf("link"), 0, entityType); + proto.inlineTokenizers.wikiLink = tokenizer; + + function tokenizer(eat, value) { + const match = wikiLinkRegex.exec(value); + + if (match) { + const linkContents = match.groups.linkContents.trim(); + + return eat(match[0])({ + type: entityType, + value: linkContents, + }); + } + } + + tokenizer.locator = function (value, fromIndex) { + return value.indexOf("[", fromIndex); + }; +}; + +export default wikiLink; diff --git a/src/prettier-markdown/utils.js b/src/prettier-markdown/utils.js new file mode 100644 index 0000000..13a3745 --- /dev/null +++ b/src/prettier-markdown/utils.js @@ -0,0 +1,268 @@ +import * as assert from "#universal/assert"; +import { CJK_REGEXP, PUNCTUATION_REGEXP } from "./constants.evaluate.js"; +import { locEnd, locStart } from "./loc.js"; + +const INLINE_NODE_TYPES = new Set([ + "liquidNode", + "inlineCode", + "emphasis", + "esComment", + "strong", + "delete", + "wikiLink", + "link", + "linkReference", + "image", + "imageReference", + "footnote", + "footnoteReference", + "sentence", + "whitespace", + "word", + "break", + "inlineMath", +]); + +const INLINE_NODE_WRAPPER_TYPES = new Set([ + ...INLINE_NODE_TYPES, + "tableCell", + "paragraph", + "heading", +]); + +const KIND_NON_CJK = "non-cjk"; +const KIND_CJ_LETTER = "cj-letter"; +const KIND_K_LETTER = "k-letter"; +const KIND_CJK_PUNCTUATION = "cjk-punctuation"; + +const K_REGEXP = /\p{Script_Extensions=Hangul}/u; + +/** + * @typedef {" " | "\n" | ""} WhitespaceValue + * @typedef { KIND_NON_CJK | KIND_CJ_LETTER | KIND_K_LETTER | KIND_CJK_PUNCTUATION } WordKind + * @typedef {{ + * type: "whitespace", + * value: WhitespaceValue, + * kind?: never + * }} WhitespaceNode + * @typedef {{ + * type: "word", + * value: string, + * kind: WordKind, + * isCJ: boolean, + * hasLeadingPunctuation: boolean, + * hasTrailingPunctuation: boolean, + * }} WordNode + * Node for a single CJK character or a sequence of non-CJK characters + * @typedef {WhitespaceNode | WordNode} TextNode + */ + +/** + * split text into whitespaces and words + * @param {string} text + */ +function splitText(text) { + /** @type {Array} */ + const nodes = []; + + const tokens = text.split(/([\t\n ]+)/u); + for (const [index, token] of tokens.entries()) { + // whitespace + if (index % 2 === 1) { + nodes.push({ + type: "whitespace", + value: /\n/u.test(token) ? "\n" : " ", + }); + continue; + } + + // word separated by whitespace + + if ((index === 0 || index === tokens.length - 1) && token === "") { + continue; + } + + const innerTokens = token.split(new RegExp(`(${CJK_REGEXP.source})`, "u")); + for (const [innerIndex, innerToken] of innerTokens.entries()) { + if ( + (innerIndex === 0 || innerIndex === innerTokens.length - 1) && + innerToken === "" + ) { + continue; + } + + // non-CJK word + if (innerIndex % 2 === 0) { + if (innerToken !== "") { + appendNode({ + type: "word", + value: innerToken, + kind: KIND_NON_CJK, + isCJ: false, + hasLeadingPunctuation: PUNCTUATION_REGEXP.test(innerToken[0]), + hasTrailingPunctuation: PUNCTUATION_REGEXP.test(innerToken.at(-1)), + }); + } + continue; + } + + // CJK character + + // punctuation for CJ(K) + // Korean doesn't use them in horizontal writing usually + if (PUNCTUATION_REGEXP.test(innerToken)) { + appendNode({ + type: "word", + value: innerToken, + kind: KIND_CJK_PUNCTUATION, + isCJ: true, + hasLeadingPunctuation: true, + hasTrailingPunctuation: true, + }); + continue; + } + + // Korean uses space to divide words, but Chinese & Japanese do not + // This is why Korean should be treated like non-CJK + if (K_REGEXP.test(innerToken)) { + appendNode({ + type: "word", + value: innerToken, + kind: KIND_K_LETTER, + isCJ: false, + hasLeadingPunctuation: false, + hasTrailingPunctuation: false, + }); + continue; + } + + appendNode({ + type: "word", + value: innerToken, + kind: KIND_CJ_LETTER, + isCJ: true, + hasLeadingPunctuation: false, + hasTrailingPunctuation: false, + }); + } + } + + // Check for `canBeConvertedToSpace` in ./print-whitespace.js etc. + if (process.env.NODE_ENV !== "production") { + for (let i = 1; i < nodes.length; i++) { + assert.ok( + !(nodes[i - 1].type === "whitespace" && nodes[i].type === "whitespace"), + "splitText should not create consecutive whitespace nodes", + ); + } + } + + return nodes; + + function appendNode(node) { + const lastNode = nodes.at(-1); + if ( + lastNode?.type === "word" && + !isBetween(KIND_NON_CJK, KIND_CJK_PUNCTUATION) && + // disallow leading/trailing full-width whitespace + ![lastNode.value, node.value].some((value) => /\u3000/u.test(value)) + ) { + nodes.push({ type: "whitespace", value: "" }); + } + nodes.push(node); + + function isBetween(kind1, kind2) { + return ( + (lastNode.kind === kind1 && node.kind === kind2) || + (lastNode.kind === kind2 && node.kind === kind1) + ); + } + } +} + +function getOrderedListItemInfo(orderListItem, options) { + const text = options.originalText.slice( + orderListItem.position.start.offset, + orderListItem.position.end.offset, + ); + + const { numberText, leadingSpaces } = text.match( + /^\s*(?\d+)(\.|\))(?\s*)/u, + ).groups; + + return { number: Number(numberText), leadingSpaces }; +} + +function hasGitDiffFriendlyOrderedList(node, options) { + if (!node.ordered || node.children.length < 2) { + return false; + } + + const secondNumber = getOrderedListItemInfo(node.children[1], options).number; + + if (secondNumber !== 1) { + return false; + } + + const firstNumber = getOrderedListItemInfo(node.children[0], options).number; + + if (firstNumber !== 0) { + return true; + } + + return ( + node.children.length > 2 && + getOrderedListItemInfo(node.children[2], options).number === 1 + ); +} + +// The final new line should not include in value +// https://github.com/remarkjs/remark/issues/512 +function getFencedCodeBlockValue(node, originalText) { + const { value } = node; + if ( + node.position.end.offset === originalText.length && + value.endsWith("\n") && + // Code block has no end mark + originalText.endsWith("\n") + ) { + return value.slice(0, -1); + } + return value; +} + +function mapAst(ast, handler) { + return (function preorder(node, index, parentStack) { + const newNode = { ...handler(node, index, parentStack) }; + if (newNode.children) { + newNode.children = newNode.children.map((child, index) => + preorder(child, index, [newNode, ...parentStack]), + ); + } + + return newNode; + })(ast, null, []); +} + +function isAutolink(node) { + if (node?.type !== "link" || node.children.length !== 1) { + return false; + } + const [child] = node.children; + return locStart(node) === locStart(child) && locEnd(node) === locEnd(child); +} + +export { + getFencedCodeBlockValue, + getOrderedListItemInfo, + hasGitDiffFriendlyOrderedList, + INLINE_NODE_TYPES, + INLINE_NODE_WRAPPER_TYPES, + isAutolink, + KIND_CJ_LETTER, + KIND_CJK_PUNCTUATION, + KIND_K_LETTER, + KIND_NON_CJK, + mapAst, + splitText, +}; diff --git a/src/prettier-markdown/visitor-keys.js b/src/prettier-markdown/visitor-keys.js new file mode 100644 index 0000000..f906d15 --- /dev/null +++ b/src/prettier-markdown/visitor-keys.js @@ -0,0 +1,41 @@ +const visitorKeys = { + root: ["children"], + paragraph: ["children"], + sentence: ["children"], + word: [], + whitespace: [], + emphasis: ["children"], + strong: ["children"], + delete: ["children"], + inlineCode: [], + wikiLink: [], + link: ["children"], + image: [], + blockquote: ["children"], + heading: ["children"], + code: [], + html: [], + list: ["children"], + thematicBreak: [], + linkReference: ["children"], + imageReference: [], + definition: [], + footnote: ["children"], + footnoteReference: [], + footnoteDefinition: ["children"], + table: ["children"], + tableCell: ["children"], + break: [], + liquidNode: [], + import: [], + export: [], + esComment: [], + jsx: [], + math: [], + inlineMath: [], + tableRow: ["children"], + listItem: ["children"], + text: [], +}; + +export default visitorKeys; diff --git a/test/markdown-formatting.test.ts b/test/markdown-formatting.test.ts new file mode 100644 index 0000000..dc2d130 --- /dev/null +++ b/test/markdown-formatting.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "bun:test"; +import { printers } from "../src/index.js"; + +describe("Markdown Formatting in Descriptions", () => { + const printer = printers?.["openapi-ast"]; + + describe("Basic markdown formatting", () => { + it("should format description fields with markdown", () => { + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: "yaml", + content: { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + description: "This is a test description\n\n\nWith multiple spaces ", + }, + paths: { + "/test": { + get: { + summary: "Get endpoint ", + description: "Endpoint description\n\n\nwith extra spaces", + operationId: "getTest", + responses: { + "200": { + description: "Success response", + }, + }, + }, + }, + }, + }, + originalText: "", + }; + + // @ts-expect-error We are mocking things here + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }); + expect(result).toBeDefined(); + + if (!result) { + throw new Error("Result is undefined"); + } + + const resultString = result.toString(); + + // Check that multiple spaces are normalized in the original content + // Note: YAML may format this differently, but the content should be processed + // The description field should exist and be formatted + expect(resultString).toContain("description:"); + + // Check that multiple blank lines are normalized + expect(resultString).not.toMatch(/\n{4,}/); + }); + + it("should preserve code blocks in descriptions", () => { + const testData = { + isOpenAPI: true, + format: "yaml", + content: { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + description: + "Here is some code:\n\n const x = 1;\n const y = 2;\n\nAnd more text.", + }, + }, + originalText: "", + }; + + // @ts-expect-error We are mocking things here + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }); + expect(result).toBeDefined(); + + if (!result) { + throw new Error("Result is undefined"); + } + + const resultString = result.toString(); + + // Code blocks (4+ spaces) should be preserved + expect(resultString).toContain(" const x = 1;"); + expect(resultString).toContain(" const y = 2;"); + }); + + it("should format markdown in nested objects", () => { + const testData = { + isOpenAPI: true, + format: "yaml", + content: { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + }, + paths: { + "/test": { + get: { + operationId: "test", + parameters: [ + { + name: "filter", + in: "query", + description: "Filter parameter with spaces", + }, + ], + responses: { + "200": { + description: "Success response", + }, + }, + }, + }, + }, + }, + originalText: "", + }; + + // @ts-expect-error We are mocking things here + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }); + expect(result).toBeDefined(); + + if (!result) { + throw new Error("Result is undefined"); + } + + const resultString = result.toString(); + + // Both parameter and response descriptions should be formatted + expect(resultString).toContain("description:"); + }); + }); + + describe("Summary field formatting", () => { + it("should format summary fields", () => { + const testData = { + isOpenAPI: true, + format: "yaml", + content: { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + summary: "API summary with spaces", + }, + paths: { + "/test": { + get: { + summary: "Get endpoint summary", + operationId: "test", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }, + originalText: "", + }; + + // @ts-expect-error We are mocking things here + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }); + expect(result).toBeDefined(); + + if (!result) { + throw new Error("Result is undefined"); + } + + const resultString = result.toString(); + + // Summary fields should be processed + expect(resultString).toContain("summary:"); + }); + }); +});