From 04779b85577d73fbf101eba2dad3ebcc217e8325 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:26:37 -0600 Subject: [PATCH] Preview markdown files formatted, with a raw toggle (#780) * Preview markdown files formatted, with a raw toggle Markdown files are typed text/markdown (libmagic reports them as text/plain, or as a programming language when they hold code) and keep the text indexer, so they stay deep-searchable. Existing files switch type on their next refresh. The file preview renders markdown with markdown-it into the page, so it follows the theme tokens and the night media filter. A Formatted/Raw toggle shows the source instead, and the choice is remembered. Relative links and images resolve against the file's directory; URLs that climb out of /media/ are dropped. Raw HTML in a file is escaped rather than parsed, and the output is also sanitized with DOMPurify. Co-Authored-By: Claude Opus 5.5 * Do not load images from other sites in the markdown preview An image from another site (http:, https:, or protocol-relative //) loaded as soon as the preview opened, telling that site who was reading the file and when. Such an image now becomes a link labeled with its alt text, so the reader chooses whether to follow it. Inside a link (a README badge) it becomes the outer link's text, since links cannot nest. Media and embedded data: images still load. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../2026_10_05_1200_markdown_mimetype.py | 31 +++ app/package-lock.json | 250 +++++++++++++++++- app/package.json | 2 + app/src/App.css | 77 ++++++ app/src/components/Common.js | 2 +- app/src/components/FilePreview.js | 4 + app/src/components/FilePreview.test.js | 20 ++ app/src/components/MarkdownPreview.js | 170 ++++++++++++ app/src/components/MarkdownPreview.test.js | 198 ++++++++++++++ wrolpi/files/indexers.py | 2 +- wrolpi/files/lib.py | 3 + wrolpi/files/test/test_lib.py | 29 ++ wrolpi/test/test_db_bootstrap.py | 50 ++++ 13 files changed, 828 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/2026_10_05_1200_markdown_mimetype.py create mode 100644 app/src/components/MarkdownPreview.js create mode 100644 app/src/components/MarkdownPreview.test.js diff --git a/alembic/versions/2026_10_05_1200_markdown_mimetype.py b/alembic/versions/2026_10_05_1200_markdown_mimetype.py new file mode 100644 index 00000000..02300ea4 --- /dev/null +++ b/alembic/versions/2026_10_05_1200_markdown_mimetype.py @@ -0,0 +1,31 @@ +"""Retag existing markdown file groups as text/markdown. + +A refresh skips a group whose files have not changed, so markdown indexed before text/markdown +existed would keep the type libmagic gave it (text/plain, or a programming language when the file +holds code) and the indexer that type chose. Marking the rows unindexed lets apply_indexers +re-index them with TextIndexer. SQLite's LIKE is case-insensitive for ASCII, so `.MD` matches. + +Revision ID: 2026_10_05_1200 +Revises: 2026_09_28_1200 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '2026_10_05_1200' +down_revision = '2026_09_28_1200' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + UPDATE file_group SET mimetype = 'text/markdown', "indexed" = 0 + WHERE (primary_path LIKE '%.md' OR primary_path LIKE '%.markdown') + AND mimetype LIKE 'text/%' + AND mimetype != 'text/markdown' + """) + + +def downgrade(): + # The original types are not recorded; text/plain is what libmagic reports for most markdown. + op.execute("""UPDATE file_group SET mimetype = 'text/plain', "indexed" = 0 WHERE mimetype = 'text/markdown'""") diff --git a/app/package-lock.json b/app/package-lock.json index 9e44866c..44811df7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -18,9 +18,11 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.6.7", "cypress": "^15.21.1", + "dompurify": "^3.4.16", "esptool-js": "^0.7.0", "lodash": "^4.18.1", "maplibre-gl": "^5.24.0", + "markdown-it": "^15.0.2", "mathjs": "^15.2.0", "pmtiles": "^4.5.0", "protomaps-themes-base": "^4.5.0", @@ -104,6 +106,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz", "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", @@ -1358,6 +1361,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz", "integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -5011,6 +5015,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -5435,6 +5440,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6005,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -6524,6 +6531,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -6534,6 +6542,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -6672,6 +6681,7 @@ "version": "5.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.50.0.tgz", "integrity": "sha512-vwksQWSFZiUhgq3Kv7o1Jcj0DUNylwnIlGvKvLLYsq8pAWha6/WCnXUeaSoNNha/K7QSf2+jvmkxggC1u3pIwQ==", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.50.0", "@typescript-eslint/type-utils": "5.50.0", @@ -6723,6 +6733,7 @@ "version": "5.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.50.0.tgz", "integrity": "sha512-KCcSyNaogUDftK2G9RXfQyOCt51uB5yqC6pkUYqhYh8Kgt+DwR5M0EwEAxGPy/+DH6hnmKeGsNhiZRQxjH71uQ==", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.50.0", "@typescript-eslint/types": "5.50.0", @@ -7053,6 +7064,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7163,6 +7175,7 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8120,6 +8133,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -8981,6 +8995,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9268,6 +9283,7 @@ "integrity": "sha512-ogHpHMj0XNlZA5MGzjg8SWHf0eMw9lTwVQRKjpcT1GzNTxNUjwnHA8YCG6nOJ8ThU+XZaUxJgpMYZnJ//8aDaw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", @@ -9929,6 +9945,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -10362,6 +10387,7 @@ "version": "8.33.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.33.0.tgz", "integrity": "sha512-WjOpFQgKK8VrCnAtl8We0SUOy/oVZ5NHykyMiagV1M9r8IFpIJX7DduK6n1mpfhlG7T1NLWm2SuD8QB7KFySaA==", + "peer": true, "dependencies": { "@eslint/eslintrc": "^1.4.1", "@humanwhocodes/config-array": "^0.11.8", @@ -10775,6 +10801,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13322,6 +13349,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -15546,6 +15574,25 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -16041,6 +16088,61 @@ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC" }, + "node_modules/markdown-it": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", + "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.2.tgz", + "integrity": "sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -16088,6 +16190,12 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -16229,6 +16337,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17108,6 +17217,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -18166,6 +18276,7 @@ "version": "6.0.11", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -18435,6 +18546,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -18626,6 +18746,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -18781,6 +18902,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -18874,6 +18996,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -19039,6 +19162,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19706,6 +19830,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -21086,6 +21211,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21175,7 +21301,8 @@ "version": "0.186.1", "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-stdlib": { "version": "2.17.2", @@ -21377,7 +21504,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -21457,6 +21585,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "peer": true, "engines": { "node": ">=10" }, @@ -21510,6 +21639,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21518,6 +21648,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -21905,6 +22041,7 @@ "version": "5.105.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21984,6 +22121,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22095,6 +22233,7 @@ "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -22153,6 +22292,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22343,6 +22483,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22629,6 +22770,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -23126,6 +23268,7 @@ "version": "7.20.12", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz", "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==", + "peer": true, "requires": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "7.29.0", @@ -23987,6 +24130,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz", "integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==", + "peer": true, "requires": { "@babel/helper-plugin-utils": "^7.18.6" } @@ -26386,6 +26530,7 @@ "version": "8.3.18", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", + "peer": true, "requires": {} }, "@mantine/notifications": { @@ -26713,6 +26858,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -27065,6 +27211,7 @@ "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "peer": true, "requires": { "@babel/code-frame": "7.29.0", "@babel/runtime": "^7.12.5", @@ -27495,6 +27642,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "peer": true, "requires": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -27504,6 +27652,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "peer": true, "requires": {} }, "@types/react-reconciler": { @@ -27631,6 +27780,7 @@ "version": "5.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.50.0.tgz", "integrity": "sha512-vwksQWSFZiUhgq3Kv7o1Jcj0DUNylwnIlGvKvLLYsq8pAWha6/WCnXUeaSoNNha/K7QSf2+jvmkxggC1u3pIwQ==", + "peer": true, "requires": { "@typescript-eslint/scope-manager": "5.50.0", "@typescript-eslint/type-utils": "5.50.0", @@ -27656,6 +27806,7 @@ "version": "5.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.50.0.tgz", "integrity": "sha512-KCcSyNaogUDftK2G9RXfQyOCt51uB5yqC6pkUYqhYh8Kgt+DwR5M0EwEAxGPy/+DH6hnmKeGsNhiZRQxjH71uQ==", + "peer": true, "requires": { "@typescript-eslint/scope-manager": "5.50.0", "@typescript-eslint/types": "5.50.0", @@ -27905,7 +28056,8 @@ "acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==" + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "peer": true }, "acorn-globals": { "version": "6.0.0", @@ -27983,6 +28135,7 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -28655,6 +28808,7 @@ "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "peer": true, "requires": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -29232,6 +29386,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -29440,6 +29595,7 @@ "version": "15.21.1", "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.21.1.tgz", "integrity": "sha512-ogHpHMj0XNlZA5MGzjg8SWHf0eMw9lTwVQRKjpcT1GzNTxNUjwnHA8YCG6nOJ8ThU+XZaUxJgpMYZnJ//8aDaw==", + "peer": true, "requires": { "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", @@ -29907,6 +30063,14 @@ "domelementtype": "^2.2.0" } }, + "dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "requires": { + "@types/trusted-types": "^2.0.7" + } + }, "domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -30243,6 +30407,7 @@ "version": "8.33.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.33.0.tgz", "integrity": "sha512-WjOpFQgKK8VrCnAtl8We0SUOy/oVZ5NHykyMiagV1M9r8IFpIJX7DduK6n1mpfhlG7T1NLWm2SuD8QB7KFySaA==", + "peer": true, "requires": { "@eslint/eslintrc": "^1.4.1", "@humanwhocodes/config-array": "^0.11.8", @@ -30618,6 +30783,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -32282,6 +32448,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "peer": true, "requires": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -33910,6 +34077,14 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "requires": { + "uc.micro": "^3.0.0" + } + }, "listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -34260,6 +34435,31 @@ } } }, + "markdown-it": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", + "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "requires": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "dependencies": { + "argparse": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.2.tgz", + "integrity": "sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==" + }, + "entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==" + } + } + }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -34293,6 +34493,11 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" }, + "mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==" + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -34381,6 +34586,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -34986,6 +35192,7 @@ "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "peer": true, "requires": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -35569,6 +35776,7 @@ "version": "6.0.11", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", + "peer": true, "requires": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -35776,6 +35984,11 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" }, + "punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==" + }, "pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -35903,6 +36116,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -36016,6 +36230,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -36084,7 +36299,8 @@ "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", - "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==" + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "peer": true }, "react-remove-scroll": { "version": "2.7.2", @@ -36187,6 +36403,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -36634,6 +36851,7 @@ "version": "2.80.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "peer": true, "requires": { "fsevents": "~2.3.2" } @@ -37589,6 +37807,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -37647,7 +37866,8 @@ "three": { "version": "0.186.1", "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", - "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==" + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "peer": true }, "three-stdlib": { "version": "2.17.2", @@ -37805,7 +38025,8 @@ "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "tsutils": { "version": "3.21.0", @@ -37868,7 +38089,8 @@ "type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==" + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "peer": true }, "type-is": { "version": "1.6.18", @@ -37905,7 +38127,13 @@ "typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==" + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "peer": true + }, + "uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==" }, "unbox-primitive": { "version": "1.0.2", @@ -38161,6 +38389,7 @@ "version": "5.105.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -38193,6 +38422,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38259,6 +38489,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "devOptional": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38337,6 +38568,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "devOptional": true, + "peer": true, "requires": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -38373,6 +38605,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "devOptional": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -38665,6 +38898,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/app/package.json b/app/package.json index 2e365c32..20e53144 100644 --- a/app/package.json +++ b/app/package.json @@ -13,9 +13,11 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.6.7", "cypress": "^15.21.1", + "dompurify": "^3.4.16", "esptool-js": "^0.7.0", "lodash": "^4.18.1", "maplibre-gl": "^5.24.0", + "markdown-it": "^15.0.2", "mathjs": "^15.2.0", "pmtiles": "^4.5.0", "protomaps-themes-base": "^4.5.0", diff --git a/app/src/App.css b/app/src/App.css index 2644e9fd..4c961e69 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -316,6 +316,83 @@ pre.wrap-text { height: 100%; } +/* A markdown file preview, formatted or raw. Scrolls rather than fitting like .preview-fit, because + it is a document to read, not media to frame. Colors come from the theme tokens. */ +.markdown-preview { + height: calc(100dvh - 21rem); + min-height: 15rem; + overflow: auto; + margin-top: 0.5em; + padding: 0 0.25em; +} + +.markdown-raw { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.markdown-body { + max-width: 50rem; + line-height: 1.6; + overflow-wrap: break-word; +} + +.markdown-body > :first-child { + margin-top: 0; +} + +.markdown-body h1, .markdown-body h2 { + padding-bottom: 0.25em; + border-bottom: 1px solid var(--table-line); +} + +.markdown-body img { + max-width: 100%; +} + +.markdown-body blockquote { + margin: 0 0 1em; + padding: 0 1em; + color: var(--muted); + border-left: 0.25em solid var(--border); +} + +.markdown-body code { + padding: 0.1em 0.3em; + border-radius: 0.25em; + background-color: var(--head); +} + +.markdown-body pre { + padding: 0.75em 1em; + overflow-x: auto; + border-radius: 0.25em; + background-color: var(--head); +} + +.markdown-body pre code { + padding: 0; + background-color: transparent; +} + +.markdown-body table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; +} + +.markdown-body th, .markdown-body td { + padding: 0.3em 0.75em; + border: 1px solid var(--table-line); +} + +.markdown-body hr { + border: none; + border-top: 1px solid var(--border); +} + td.file-path { cursor: pointer; } diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 3803a962..f5e0d69a 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1165,7 +1165,7 @@ export function mimetypeIconName(mimetype, lowerPath = '') { return 'file code'; } else if (mimetype.startsWith('application/pdf')) { return 'file pdf'; - } else if (mimetype.startsWith('text/plain')) { + } else if (mimetype.startsWith('text/plain') || mimetype.startsWith('text/markdown')) { return 'file text'; } else if (mimetype.startsWith('image/')) { return 'image'; diff --git a/app/src/components/FilePreview.js b/app/src/components/FilePreview.js index 3f5362f9..a9e9fdb3 100644 --- a/app/src/components/FilePreview.js +++ b/app/src/components/FilePreview.js @@ -14,6 +14,7 @@ import {pathDirectory} from "./FileBrowser"; import {InlineErrorBoundary} from "./ErrorBoundary"; import {useLocation} from "react-router"; import {ThemeContext} from "../contexts/contexts"; +import {isMarkdownFile, MarkdownPreview} from "./MarkdownPreview"; // Routes where file views should not be tracked const EXCLUDED_TRACKING_ROUTES = [ @@ -607,6 +608,9 @@ export function FilePreviewProvider({children}) { if (mimetype.startsWith('text/') && size > MAXIMUM_TEXT_SIZE) { // Large text files should be downloaded. window.open(downloadURL); + } else if (isMarkdownFile(mimetype, lowerPath)) { + setModalContent(, url, downloadURL, path, + taggable); } else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) { setModalContent(getIframePreviewModal(previewFile), url, downloadURL, path, taggable); } else if (mimetype.startsWith('video/')) { diff --git a/app/src/components/FilePreview.test.js b/app/src/components/FilePreview.test.js index 24f90479..c8bcbe3b 100644 --- a/app/src/components/FilePreview.test.js +++ b/app/src/components/FilePreview.test.js @@ -101,6 +101,26 @@ describe('preview gate remounting (source contract)', () => { }); }); +describe('markdown preview routing (source contract)', () => { + /* + * Markdown is text/*, so the generic text branch would also accept it and show it raw in an + * iframe. The markdown branch has to be tested first for the formatted view to be reachable. + */ + const fs = require('fs'); + const path = require('path'); + const source = fs.readFileSync(path.join(__dirname, 'FilePreview.js'), 'utf8'); + + test('markdown is routed to MarkdownPreview before the generic text preview', () => { + const markdownBranch = source.indexOf('} else if (isMarkdownFile(mimetype, lowerPath)) {'); + const textBranch = source.indexOf("} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {"); + + expect(markdownBranch).toBeGreaterThan(-1); + expect(textBranch).toBeGreaterThan(-1); + expect(markdownBranch).toBeLessThan(textBranch); + expect(source.slice(markdownBranch, textBranch)).toContain(''); + }); +}); + describe('IframePreview color scheme', () => { /* * The previewed file is a bare document with no styles of its own, so it renders with the diff --git a/app/src/components/MarkdownPreview.js b/app/src/components/MarkdownPreview.js new file mode 100644 index 00000000..5d7e1848 --- /dev/null +++ b/app/src/components/MarkdownPreview.js @@ -0,0 +1,170 @@ +import React from "react"; +import MarkdownIt from "markdown-it"; +import DOMPurify from "dompurify"; +import {useLocalStorage} from "./Common"; +import {Button, ButtonGroup, Loading, Message} from "./ui"; + +/* + * Raw HTML in the file is escaped, not parsed: markdown files arrive with downloads, and the preview + * renders on the API's origin. `validateLink` (on by default) refuses javascript:, vbscript:, file:, + * and non-image data: URLs. DOMPurify runs anyway, so neither guard is the only one. + */ +const markdown = new MarkdownIt({html: false, linkify: true}); + +// A URL with a scheme (https:, mailto:, ...) points somewhere other than the media directory. +const URL_WITH_SCHEME = /^[a-z][a-z0-9+.-]*:/i; + +/** + * Resolve a URL written in a markdown file against the file's own media URL, the way a browser + * would resolve it against a document in that directory. + * + * Returns the URL unchanged if it is not relative, or null if it climbs out of the media directory; + * only media is served relative to a file. + */ +function resolveRelativeURL(url, fileURL) { + if (!url || URL_WITH_SCHEME.test(url) || url.startsWith('/') || url.startsWith('#')) { + return url; + } + const resolved = new URL(url, new URL(fileURL, window.location.origin)); + if (!resolved.pathname.startsWith('/media/')) { + return null; + } + return resolved.pathname + resolved.search + resolved.hash; +} + +function rewriteURL(token, attr, fileURL) { + const url = token.attrGet(attr); + if (url === null) { + return; + } + const resolved = resolveRelativeURL(url, fileURL); + if (resolved === null) { + token.attrs = token.attrs.filter(([name]) => name !== attr); + } else { + token.attrSet(attr, resolved); + } +} + +function openInNewTab(token) { + const href = token.attrGet('href'); + // Following a link would close the preview; a fragment stays within the document. + if (href && !href.startsWith('#')) { + token.attrSet('target', '_blank'); + token.attrSet('rel', 'noopener noreferrer'); + } +} + +/** An image the preview may load: one served by this WROLPi, or embedded in the file. */ +function isLocalImage(src) { + return !src || src.startsWith('data:') || (src.startsWith('/') && !src.startsWith('//')); +} + +/** + * Replace an image from another site with a link to it. Loading it would tell that site who is + * reading the file and when (a tracking pixel), so the reader chooses whether to follow it. + * + * Inside a link (a README badge) the image becomes the outer link's text; links cannot nest. + */ +function replaceRemoteImage(state, image, insideLink) { + const src = image.attrGet('src'); + const text = new state.Token('text', '', 0); + text.content = image.content || src; + if (insideLink) { + return [text]; + } + const linkOpen = new state.Token('link_open', 'a', 1); + linkOpen.attrSet('href', src); + openInNewTab(linkOpen); + return [linkOpen, text, new state.Token('link_close', 'a', -1)]; +} + +markdown.core.ruler.push('wrolpi_media_urls', (state) => { + const {fileURL} = state.env; + for (const token of state.tokens) { + if (!token.children) { + continue; + } + let insideLink = false; + token.children = token.children.flatMap(child => { + if (child.type === 'image') { + rewriteURL(child, 'src', fileURL); + return isLocalImage(child.attrGet('src')) ? [child] : replaceRemoteImage(state, child, insideLink); + } else if (child.type === 'link_open') { + insideLink = true; + rewriteURL(child, 'href', fileURL); + openInNewTab(child); + } else if (child.type === 'link_close') { + insideLink = false; + } + return [child]; + }); + } +}); + +/** Render markdown to sanitized HTML. Relative URLs resolve against `fileURL`, the file's /media/ URL. */ +export function renderMarkdown(text, fileURL) { + const html = markdown.render(text, {fileURL}); + return DOMPurify.sanitize(html, {ADD_ATTR: ['target']}); +} + +export function isMarkdownFile(mimetype, lowerPath) { + // By suffix too, so a markdown file whose stored type is still text/plain (or a language libmagic + // guessed) is not shown raw; the 2026_10_05_1200 migration retags those rows, a refresh does not. + return mimetype.startsWith('text/markdown') + || (mimetype.startsWith('text/') && (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown'))); +} + +/** Preview a markdown file, formatted or as its raw source. */ +export function MarkdownPreview({url}) { + const [formatted, setFormatted] = useLocalStorage('markdownPreviewFormatted', true); + const [text, setText] = React.useState(null); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + const controller = new AbortController(); + setText(null); + setError(null); + fetch(url, {signal: controller.signal}) + .then(response => { + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return response.text(); + }) + .then(setText) + .catch(e => { + if (e.name !== 'AbortError') { + console.error(e); + setError(e.message); + } + }); + return () => controller.abort(); + }, [url]); + + const html = React.useMemo(() => text === null ? null : renderMarkdown(text, url), [text, url]); + + let body; + if (error) { + body = {error}; + } else if (text === null) { + body = ; + } else if (formatted) { + body =
; + } else { + body =
{text}
; + } + + return <> + + + + +
+ {body} +
+ +} diff --git a/app/src/components/MarkdownPreview.test.js b/app/src/components/MarkdownPreview.test.js new file mode 100644 index 00000000..270e09a8 --- /dev/null +++ b/app/src/components/MarkdownPreview.test.js @@ -0,0 +1,198 @@ +import React from 'react'; +import {fireEvent, screen, waitFor} from '@testing-library/react'; +import {isMarkdownFile, MarkdownPreview, renderMarkdown} from './MarkdownPreview'; +import {renderUI} from '../test-utils'; + +const FILE_URL = '/media/notes/water/storage.md'; + +/** Render markdown into a detached element so assertions read the DOM, not the HTML string. */ +const renderToDOM = (text, fileURL = FILE_URL) => { + const div = document.createElement('div'); + div.innerHTML = renderMarkdown(text, fileURL); + return div; +}; + +describe('renderMarkdown', () => { + test('formats headings, emphasis, lists, code, and tables', () => { + const div = renderToDOM([ + '# Water', + '', + 'Rotate *every* barrel.', + '', + '- one', + '- two', + '', + '```', + 'boil 1 minute', + '```', + '', + '| a | b |', + '|---|---|', + '| 1 | 2 |', + ].join('\n')); + + expect(div.querySelector('h1').textContent).toBe('Water'); + expect(div.querySelector('em').textContent).toBe('every'); + expect(div.querySelectorAll('ul li')).toHaveLength(2); + expect(div.querySelector('pre code').textContent).toBe('boil 1 minute\n'); + expect(div.querySelectorAll('table td')).toHaveLength(2); + }); + + test('raw HTML in the file is shown as text, never parsed', () => { + const div = renderToDOM('\n\n'); + + expect(div.querySelector('script')).toBeNull(); + expect(div.querySelector('img')).toBeNull(); + expect(div.textContent).toContain(''); + }); + + test('script URLs are not turned into links', () => { + const div = renderToDOM('[click](javascript:alert(1))'); + + expect(div.querySelector('a')).toBeNull(); + }); + + test('relative images resolve against the markdown file directory', () => { + const div = renderToDOM('![pump](images/hand%20pump.png)\n\n![tank](../tank.jpg)'); + + const [pump, tank] = div.querySelectorAll('img'); + expect(pump.getAttribute('src')).toBe('/media/notes/water/images/hand%20pump.png'); + expect(tank.getAttribute('src')).toBe('/media/notes/tank.jpg'); + }); + + test('relative links resolve against the markdown file directory and open a new tab', () => { + const a = renderToDOM('[filters](filters.md#ceramic)').querySelector('a'); + + expect(a.getAttribute('href')).toBe('/media/notes/water/filters.md#ceramic'); + expect(a.getAttribute('target')).toBe('_blank'); + }); + + test('a relative URL that climbs out of the media directory is dropped', () => { + const div = renderToDOM('[api](../../../api/status)\n\n![x](../../../../x.png)'); + + expect(div.querySelector('a').hasAttribute('href')).toBe(false); + expect(div.querySelector('img').hasAttribute('src')).toBe(false); + }); + + test.each([ + ['https://tracker.example/pixel.gif'], + ['http://tracker.example/pixel.gif'], + // Protocol-relative: starts with a slash, but loads from another host. + ['//tracker.example/pixel.gif'], + ])('an image from another site (%s) is not loaded, it becomes a link', (src) => { + const div = renderToDOM(`![pixel](${src})`); + + expect(div.querySelector('img')).toBeNull(); + const a = div.querySelector('a'); + expect(a.getAttribute('href')).toBe(src); + expect(a.textContent).toBe('pixel'); + expect(a.getAttribute('target')).toBe('_blank'); + expect(a.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + test('an image from another site with no alt text is labeled by its URL', () => { + const a = renderToDOM('![](https://example.com/a.png)').querySelector('a'); + + expect(a.textContent).toBe('https://example.com/a.png'); + }); + + test('an image from another site inside a link becomes the link text, not a nested link', () => { + // The badge pattern common in READMEs. + const div = renderToDOM('[![build status](https://ci.example/badge.svg)](https://ci.example/project)'); + + expect(div.querySelector('img')).toBeNull(); + const links = div.querySelectorAll('a'); + expect(links).toHaveLength(1); + expect(links[0].getAttribute('href')).toBe('https://ci.example/project'); + expect(links[0].textContent).toBe('build status'); + }); + + test('media and embedded data images still load', () => { + const [media, data] = renderToDOM('![a](/media/x/a.png)\n\n![b](data:image/png;base64,AAAA)') + .querySelectorAll('img'); + + expect(media.getAttribute('src')).toBe('/media/x/a.png'); + expect(data.getAttribute('src')).toBe('data:image/png;base64,AAAA'); + }); + + test('external links open in a new tab without an opener', () => { + const a = renderToDOM('').querySelector('a'); + + expect(a.getAttribute('href')).toBe('https://example.com/page'); + expect(a.getAttribute('target')).toBe('_blank'); + expect(a.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + test('absolute and fragment URLs are left alone', () => { + const [root, fragment] = renderToDOM('[root](/files)\n\n[top](#top)').querySelectorAll('a'); + + expect(root.getAttribute('href')).toBe('/files'); + expect(fragment.getAttribute('href')).toBe('#top'); + expect(fragment.hasAttribute('target')).toBe(false); + }); +}); + +describe('MarkdownPreview', () => { + const SOURCE = '# Water storage\n\nRotate *every* six months.\n'; + + beforeEach(() => { + window.localStorage.clear(); + global.fetch = jest.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve(SOURCE), + })); + }); + + afterEach(() => { + delete global.fetch; + }); + + test('fetches the file and shows it formatted', async () => { + renderUI(); + + const heading = await screen.findByRole('heading', {name: 'Water storage'}); + expect(heading.tagName).toBe('H1'); + expect(global.fetch).toHaveBeenCalledWith(FILE_URL, expect.anything()); + }); + + test('Raw shows the unformatted source, and the choice is remembered', async () => { + const {unmount} = renderUI(); + await screen.findByRole('heading', {name: 'Water storage'}); + + fireEvent.click(screen.getByRole('button', {name: 'Raw'})); + + expect(screen.queryByRole('heading')).toBeNull(); + expect(screen.getByTestId('markdown-raw').textContent).toBe(SOURCE); + + // The next preview opens in the mode the reader chose last. + unmount(); + renderUI(); + await waitFor(() => expect(screen.getByTestId('markdown-raw').textContent).toBe(SOURCE)); + expect(screen.queryByRole('heading')).toBeNull(); + + fireEvent.click(screen.getByRole('button', {name: 'Formatted'})); + expect(screen.getByRole('heading', {name: 'Water storage'})).toBeTruthy(); + }); + + test('a failed fetch shows an error instead of loading forever', async () => { + global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404, statusText: 'Not Found'})); + + renderUI(); + + expect(await screen.findByText(/404 Not Found/)).toBeTruthy(); + }); +}); + +describe('isMarkdownFile', () => { + test.each([ + ['text/markdown', 'notes/a.md', true], + // A stored type from before text/markdown existed; refresh does not retag unchanged files. + ['text/plain', 'notes/a.md', true], + ['text/plain', 'notes/a.markdown', true], + ['text/plain', 'notes/a.txt', false], + // A suffix alone is not enough; a binary file named .md is not markdown. + ['application/octet-stream', 'notes/a.md', false], + ])('%s %s -> %s', (mimetype, lowerPath, expected) => { + expect(isMarkdownFile(mimetype, lowerPath)).toBe(expected); + }); +}); diff --git a/wrolpi/files/indexers.py b/wrolpi/files/indexers.py index 2701ed5d..204c1e2b 100644 --- a/wrolpi/files/indexers.py +++ b/wrolpi/files/indexers.py @@ -93,7 +93,7 @@ class ZipIndexer(Indexer, ABC): raise -@register_indexer('text/plain') +@register_indexer('text/plain', 'text/markdown') class TextIndexer(Indexer, ABC): """Handles plain text files. diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index 5a277a1d..36de4fcd 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -402,6 +402,9 @@ def _mimetype_suffix_map(path: Path, mimetype: str): return 'text/html' if suffix.endswith('.hgt'): return 'application/octet-stream' + if mimetype.startswith('text/') and suffix in ('.md', '.markdown'): + # libmagic reports markdown as text/plain, or as a programming language when it sniffs code. + return 'text/markdown' if mimetype == 'text/plain': if suffix.endswith('.json'): return 'application/json' diff --git a/wrolpi/files/test/test_lib.py b/wrolpi/files/test/test_lib.py index be49fa27..903261ad 100644 --- a/wrolpi/files/test/test_lib.py +++ b/wrolpi/files/test/test_lib.py @@ -476,6 +476,7 @@ def test_find_indexer_longest_prefix(): try: assert indexers.find_indexer('text/html') is indexers.HTMLIndexer assert indexers.find_indexer('text/plain') is indexers.TextIndexer + assert indexers.find_indexer('text/markdown') is indexers.TextIndexer assert indexers.find_indexer('text/csv') is indexers.DefaultIndexer assert indexers.find_indexer('application/zip') is indexers.ZipIndexer finally: @@ -778,6 +779,19 @@ async def test_files_indexer(async_client, test_session, make_files_structure, t assert total == 1 +@pytest.mark.asyncio +async def test_markdown_indexed(test_session, make_files_structure, refresh_files): + """Markdown files are text/markdown FileGroups whose contents are deep-searchable.""" + make_files_structure({'notes.md': '# Water storage\n\nRotate the barrels every sixmonths.\n'}) + await refresh_files() + + markdown, = test_session.query(FileGroup).all() + assert markdown.mimetype == 'text/markdown' + assert markdown.indexer == indexers.TextIndexer + files, total = lib.search_files(test_session, 'sixmonths', 10, 0, deep=True) + assert total == 1 and files[0]['id'] == markdown.id + + @pytest.mark.parametrize('name,expected', [ ('this.txt', 'this txt'), ('name', 'name'), @@ -893,6 +907,21 @@ def test_get_mimetype_stl(test_directory): assert lib.get_mimetype(stl_path) == 'model/stl' +@pytest.mark.parametrize('name,contents', [ + ('notes.md', '# Title\n\nSome *text* and a [link](https://example.com).\n\n- a\n- b\n'), + ('plain.md', 'Only a paragraph.\nNo markdown syntax at all.\n'), + # libmagic sniffs fenced code and may report a programming language instead of text/plain. + ('code.markdown', '#include \n\nint main(void) {\n return 0;\n}\n'), + ('UPPER.MD', '# Upper case suffix\n'), +]) +def test_get_mimetype_markdown(test_directory, name, contents): + """Markdown files resolve to text/markdown however libmagic sniffs their text.""" + path = test_directory / name + path.write_text(contents) + lib.get_mimetype.cache_clear() + assert lib.get_mimetype(path) == 'text/markdown' + + def test_get_mimetype_3mf(test_directory): """.3mf files are zipped XML; ensure they resolve to model/3mf.""" import zipfile diff --git a/wrolpi/test/test_db_bootstrap.py b/wrolpi/test/test_db_bootstrap.py index 681df056..78d511e0 100644 --- a/wrolpi/test/test_db_bootstrap.py +++ b/wrolpi/test/test_db_bootstrap.py @@ -211,6 +211,56 @@ def test_domain_collection_summary_migration_backfills(test_directory): assert conn.execute('SELECT item_count, total_size FROM collection WHERE id = 1').fetchone() == (1, 100) +def test_markdown_mimetype_migration_retags_existing_rows(test_directory): + """Markdown indexed before text/markdown existed is retagged and re-indexed. A refresh skips a + group whose files have not changed, so it would otherwise keep its old mimetype and indexer.""" + import sqlite3 + from alembic.config import Config + from alembic import command + from wrolpi.db import get_db_uri, get_db_file + from wrolpi.vars import PROJECT_DIR + + (test_directory / 'config').mkdir(parents=True, exist_ok=True) + config = Config(str(PROJECT_DIR / 'alembic.ini')) + config.set_main_option('sqlalchemy.url', get_db_uri()) + command.upgrade(config, '2026_09_28_1200') + + rows = ( + (1, 'notes.md', 'text/plain'), + # libmagic reports a programming language when a markdown file holds code. + (2, 'code.markdown', 'text/x-c'), + (3, 'UPPER.MD', 'text/html'), + (4, 'already.md', 'text/markdown'), + (5, 'readme.txt', 'text/plain'), + # Not text, so not markdown, whatever its name. + (6, 'binary.md', 'application/octet-stream'), + ) + db_file = get_db_file() + with sqlite3.connect(db_file) as conn: + for id_, name, mimetype in rows: + stem = name.rsplit('.', 1)[0] + conn.execute( + 'INSERT INTO file_group (id, directory, primary_path, files, stem, mimetype, indexed)' + ' VALUES (?,?,?,?,?,?,1)', + (id_, str(test_directory), str(test_directory / name), '[]', stem, mimetype), + ) + conn.commit() + + command.upgrade(config, 'head') + + with sqlite3.connect(db_file) as conn: + result = {r[0]: (r[1], r[2]) for r in conn.execute('SELECT id, mimetype, indexed FROM file_group')} + assert result == { + 1: ('text/markdown', 0), + 2: ('text/markdown', 0), + 3: ('text/markdown', 0), + # Already correct, so not re-indexed. + 4: ('text/markdown', 1), + 5: ('text/plain', 1), + 6: ('application/octet-stream', 1), + } + + def test_migrations_match_models(test_directory): """`alembic check`: a database migrated to head matches the SQLAlchemy models (tables, columns, indexes). Fails when a model gains an index or column without a migration, or vice versa."""