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('\n\n');
+
+ 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');
+
+ 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(``);
+
+ 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('').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('[](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('\n\n')
+ .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."""