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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-05 13:26:37 -06:00 • committed by Roland
parent 9739dc9e1b
commit 04779b8557
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
13 changed files with 828 additions and 10 deletions

View file

@ -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'""")

250
app/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

@ -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(<Modal.Content><MarkdownPreview url={url}/></Modal.Content>, 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/')) {

View file

@ -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('<MarkdownPreview url={url}/>');
});
});
describe('IframePreview color scheme', () => {
/*
* The previewed file is a bare document with no styles of its own, so it renders with the

View file

@ -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 = <Message kind='error' title='Could not load file'>{error}</Message>;
} else if (text === null) {
body = <Loading/>;
} else if (formatted) {
body = <div className='markdown-body' dangerouslySetInnerHTML={{__html: html}}/>;
} else {
body = <pre className='markdown-raw' data-testid='markdown-raw'>{text}</pre>;
}
return <>
<ButtonGroup>
<Button size='small' variant={formatted ? 'filled' : 'default'} onClick={() => setFormatted(true)}>
Formatted
</Button>
<Button size='small' variant={formatted ? 'default' : 'filled'} onClick={() => setFormatted(false)}>
Raw
</Button>
</ButtonGroup>
<div className='markdown-preview'>
{body}
</div>
</>
}

View file

@ -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('<script>alert(1)</script>\n\n<img src=x onerror="alert(2)">');
expect(div.querySelector('script')).toBeNull();
expect(div.querySelector('img')).toBeNull();
expect(div.textContent).toContain('<script>alert(1)</script>');
});
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('<https://example.com/page>').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(<MarkdownPreview url={FILE_URL}/>);
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(<MarkdownPreview url={FILE_URL}/>);
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(<MarkdownPreview url={FILE_URL}/>);
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(<MarkdownPreview url={FILE_URL}/>);
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);
});
});

View file

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

View file

@ -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'

View file

@ -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 <stdio.h>\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

View file

@ -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."""