mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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:
parent
9739dc9e1b
commit
04779b8557
13 changed files with 828 additions and 10 deletions
31
alembic/versions/2026_10_05_1200_markdown_mimetype.py
Normal file
31
alembic/versions/2026_10_05_1200_markdown_mimetype.py
Normal 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
250
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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/')) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
170
app/src/components/MarkdownPreview.js
Normal file
170
app/src/components/MarkdownPreview.js
Normal 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>
|
||||
</>
|
||||
}
|
||||
198
app/src/components/MarkdownPreview.test.js
Normal file
198
app/src/components/MarkdownPreview.test.js
Normal 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('\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('<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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue