fix: conversion parsing, rendering, and autosuggest edge cases

This commit is contained in:
Ruslan Zabarov 2026-05-20 16:48:28 -04:00
parent 70ee07b587
commit f69a9bcffa
No known key found for this signature in database
GPG key ID: C4C0468C1FEC37CE
13 changed files with 846 additions and 43 deletions

View file

@ -15,6 +15,7 @@ Use the following syntax within your notes to perform inline conversion:
- [Autosuggest](#autosuggest)
- [Convert units command](#convert-units-command)
- [Custom units](#custom-units)
### Autosuggest
@ -28,33 +29,41 @@ You can open a modal to perform a unit conversion. This is mostly done to improv
<img src="https://github.com/user-attachments/assets/ca168075-8408-41e3-812f-181c511b1585" alt="convert units command example" width="350">
### Custom units
You can add custom units in settings. Each custom unit belongs to an existing measure and is defined as a positive factor of a built-in anchor unit.
## Supported Units
**Length**: mm, cm, m, in, ft-us, ft, mi
**Length**: mm, cm, m, km, in, yd, ft-us, ft, mi
**Area**: mm2 cm2, m2, ha, km2, in2, ft2, ac, mi2
**Area**: mm2, cm2, m2, ha, km2, in2, yd2, ft2, ac, mi2
**Mass**: mcg, mg, g, kg, oz, lb, mt, t
**Mass**: mcg, mg, g, kg, mt, oz, lb, t
**Volume**: mm3, cm3, ml, l, kl, m3, km3, tsp, Tbs, in3, fl-oz, cup, pnt, qt, gal, ft3, yd3
**Volume**: mm3, cm3, ml, cl, dl, l, kl, m3, km3, krm, tsk, msk, kkp, glas, kanna, tsp, Tbs, in3, fl-oz, cup, pnt, qt, gal, ft3, yd3
**Volume Flow Rate**: mm3/s, cm3/s, ml/s, cl/s, dl/s, l/s, l/min, l/h, kl/s, kl/min, kl/h, m3/s, m3/min, m3/h, km3/s, tsp/s, Tbs/s, in3/s, in3/min, in3/h, fl-oz/s, fl-oz/min, fl-oz/h, cup/s, pnt/s, pnt/min, pnt/h, qt/s, gal/s, gal/min, gal/h, ft3/s, ft3/min, ft3/h, yd3/s, yd3/min, yd3/h'
**Volume Flow Rate**: mm3/s, cm3/s, ml/s, cl/s, dl/s, l/s, l/min, l/h, kl/s, kl/min, kl/h, m3/s, m3/min, m3/h, km3/s, tsp/s, Tbs/s, in3/s, in3/min, in3/h, fl-oz/s, fl-oz/min, fl-oz/h, cup/s, pnt/s, pnt/min, pnt/h, qt/s, gal/s, gal/min, gal/h, ft3/s, ft3/min, ft3/h, yd3/s, yd3/min, yd3/h
**Temperature**: C, F, K, R
**Each**: ea, dz
**Temperature**: C, K, F, R
**Time**: ns, mu, ms, s, min, h, d, week, month, year
**Frequency**: Hz, mHz, kHz, MHz, GHz, THz, rpm, deg/s, rad/s
**Frequency**: mHz, Hz, kHz, MHz, GHz, THz, rpm, deg/s, rad/s
**Parts Per**: ppm, ppb, ppt, ppq
**Speed**: m/s, km/h, m/h, knot, ft/s
**Pace**: s/m, min/km, s/ft, min/km
**Pace**: min/km, s/m, min/mi, s/ft
**Pressure**: Pa, hPa, kPa, MPa, bar, torr, psi, ksi
**Pressure**: Pa, kPa, MPa, hPa, bar, torr, psi, ksi
**Digital**: b, Kb, Mb, Gb, Tb, B, KB, MB, GB, TB
**Illuminance**: lx, ft-cd, Parts-Per, ppm, ppb, ppt, ppq
**Illuminance**: lx, ft-cd
**Voltage**: V, mV, kV
@ -70,4 +79,4 @@ You can open a modal to perform a unit conversion. This is mostly done to improv
**Reactive Energy**: VARh, mVARh, kVARh, MVARh, GVARh
**Angle**: deg, rad, grad, arcmin, arcsec
**Angle**: rad, deg, grad, arcmin, arcsec

View file

@ -12,6 +12,8 @@
"dev": "rolldown --config rolldown.config.js -w",
"build": "rolldown --config rolldown.config.js",
"lint": "oxlint",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"format": "oxfmt",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
@ -27,7 +29,8 @@
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"rolldown": "1.0.1",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"packageManager": "pnpm@11.1.3"
}

View file

@ -39,6 +39,9 @@ importers:
typescript:
specifier: ^6.0.3
version: 6.0.3
vitest:
specifier: ^4.1.6
version: 4.1.6(vite@8.0.13)
packages:
@ -57,6 +60,9 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@ -411,31 +417,182 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/codemirror@5.60.8':
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
'@types/convert-units@2.3.12':
resolution: {integrity: sha512-4EaowCOJMy8Cb2nB8XSN61U82VV4qFsFYY2WhKIwXy936aBTp2yWKvMpTCt1g76MDmd+DmVi9YxKSYVrfCQ/yw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/tern@0.23.9':
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
'@vitest/expect@4.1.6':
resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
'@vitest/mocker@4.1.6':
resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.6':
resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
'@vitest/runner@4.1.6':
resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==}
'@vitest/snapshot@4.1.6':
resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==}
'@vitest/spy@4.1.6':
resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==}
'@vitest/utils@4.1.6':
resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convert-units@2.3.4:
resolution: {integrity: sha512-ERHfdA0UhHJp1IpwE6PnFJx8LqG7B1ZjJ20UvVCmopEnVCfER68Tbe3kvN63dLbYXDA2xFWRE6zd4Wsf0w7POg==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
globals@17.6.0:
resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
engines: {node: '>=18'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lodash._basebind@2.3.0:
resolution: {integrity: sha512-SHqM7YCuJ+BeGTs7lqpWnmdHEeF4MWxS3dksJctHFNxR81FXPOzA4bS5Vs5CpcGTkBpM8FCl+YEbQEblRw8ABg==}
@ -493,15 +650,26 @@ packages:
lodash.support@2.3.0:
resolution: {integrity: sha512-etc7VWbB0U3Iya8ixj2xy4sDBN3jvPX7ODi8iXtn4KkkjNpdngrdc7Vlt5jub/Vgqx6/dWtp7Ml9awhCQPYKGQ==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
obsidian@1.12.3:
resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==}
peerDependencies:
'@codemirror/state': 6.5.0
'@codemirror/view': 6.38.6
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
oxfmt@0.49.0:
resolution: {integrity: sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -522,18 +690,60 @@ packages:
oxlint-tsgolint:
optional: true
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
rolldown@1.0.1:
resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.1.2:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -542,9 +752,98 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
vite@8.0.13:
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.18
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@4.1.6:
resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.6
'@vitest/browser-preview': 4.1.6
'@vitest/browser-webdriverio': 4.1.6
'@vitest/coverage-istanbul': 4.1.6
'@vitest/coverage-v8': 4.1.6
'@vitest/ui': 4.1.6
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots:
'@codemirror/state@6.6.0':
@ -574,6 +873,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@marijn/find-cluster-break@1.0.2': {}
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
@ -750,23 +1051,79 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
optional: true
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/codemirror@5.60.8':
dependencies:
'@types/tern': 0.23.9
'@types/convert-units@2.3.12': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/tern@0.23.9':
dependencies:
'@types/estree': 1.0.9
'@vitest/expect@4.1.6':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.6
'@vitest/utils': 4.1.6
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.6(vite@8.0.13)':
dependencies:
'@vitest/spy': 4.1.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.13
'@vitest/pretty-format@4.1.6':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.6':
dependencies:
'@vitest/utils': 4.1.6
pathe: 2.0.3
'@vitest/snapshot@4.1.6':
dependencies:
'@vitest/pretty-format': 4.1.6
'@vitest/utils': 4.1.6
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.6': {}
'@vitest/utils@4.1.6':
dependencies:
'@vitest/pretty-format': 4.1.6
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
assertion-error@2.0.1: {}
chai@6.2.2: {}
convert-source-map@2.0.0: {}
convert-units@2.3.4:
dependencies:
lodash.foreach: 2.3.0
@ -774,8 +1131,74 @@ snapshots:
crelt@1.0.6: {}
detect-libc@2.1.2: {}
es-module-lexer@2.1.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
expect-type@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
fsevents@2.3.3:
optional: true
globals@17.6.0: {}
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lodash._basebind@2.3.0:
dependencies:
lodash._basecreate: 2.3.0
@ -860,8 +1283,14 @@ snapshots:
dependencies:
lodash._renative: 2.3.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
moment@2.29.4: {}
nanoid@3.3.12: {}
obsidian@1.12.3(@codemirror/state@6.6.0)(@codemirror/view@6.43.0):
dependencies:
'@codemirror/state': 6.6.0
@ -869,6 +1298,8 @@ snapshots:
'@types/codemirror': 5.60.8
moment: 2.29.4
obug@2.1.1: {}
oxfmt@0.49.0:
dependencies:
tinypool: 2.1.0
@ -915,6 +1346,18 @@ snapshots:
'@oxlint/binding-win32-ia32-msvc': 1.64.0
'@oxlint/binding-win32-x64-msvc': 1.64.0
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
rolldown@1.0.1:
dependencies:
'@oxc-project/types': 0.130.0
@ -936,13 +1379,72 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.1
'@rolldown/binding-win32-x64-msvc': 1.0.1
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
style-mod@4.1.3: {}
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@2.1.0: {}
tinyrainbow@3.1.0: {}
tslib@2.8.1:
optional: true
typescript@6.0.3: {}
vite@8.0.13:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
rolldown: 1.0.1
tinyglobby: 0.2.16
optionalDependencies:
fsevents: 2.3.3
vitest@4.1.6(vite@8.0.13):
dependencies:
'@vitest/expect': 4.1.6
'@vitest/mocker': 4.1.6(vite@8.0.13)
'@vitest/pretty-format': 4.1.6
'@vitest/runner': 4.1.6
'@vitest/snapshot': 4.1.6
'@vitest/spy': 4.1.6
'@vitest/utils': 4.1.6
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.13
why-is-node-running: 2.3.0
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2

View file

@ -0,0 +1,39 @@
import { Text } from "@codemirror/state";
import { describe, expect, it, vi } from "vitest";
import { isIgnoredMarkdownPosition } from "./extension";
vi.mock("obsidian", () => ({
MarkdownView: class {},
}));
function positionOf(doc: Text, text: string): number {
const position = doc.toString().indexOf(text);
if (position === -1) {
throw new Error(`Could not find ${text}`);
}
return position;
}
describe("isIgnoredMarkdownPosition", () => {
it("allows normal markdown text", () => {
const doc = Text.of(["normal [1m|cm]"]);
expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(false);
});
it("skips inline code", () => {
const doc = Text.of(["normal `[1m|cm]`"]);
expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(true);
});
it("skips fenced code blocks", () => {
const doc = Text.of(["```", "[1m|cm]", "```"]);
expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(true);
});
it("skips frontmatter", () => {
const doc = Text.of(["---", "distance: [1m|cm]", "---", "body [2m|cm]"]);
expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[1m|cm]"))).toBe(true);
expect(isIgnoredMarkdownPosition(doc, positionOf(doc, "[2m|cm]"))).toBe(false);
});
});

View file

@ -7,11 +7,65 @@ import {
WidgetType,
} from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";
import type { Text } from "@codemirror/state";
import UnitConverterPlugin from "../main";
import { convertValue, formatConversion, CONVERSION_REGEX } from "../utils/conversion";
import type { UnitCode } from "../utils/conversion";
import { MarkdownView } from "obsidian";
function isFenceLine(line: string): boolean {
return /^\s{0,3}(```|~~~)/.test(line);
}
function isInFrontmatter(doc: Text, lineNumber: number): boolean {
if (doc.lines === 0 || doc.line(1).text.trim() !== "---") {
return false;
}
for (let currentLine = 2; currentLine <= doc.lines; currentLine++) {
if (doc.line(currentLine).text.trim() === "---") {
return lineNumber <= currentLine;
}
}
return true;
}
function isInFencedCode(doc: Text, lineNumber: number): boolean {
let isInFence = false;
for (let currentLine = 1; currentLine <= lineNumber; currentLine++) {
const line = doc.line(currentLine).text;
if (!isFenceLine(line)) {
continue;
}
if (currentLine === lineNumber) {
return true;
}
isInFence = !isInFence;
}
return isInFence;
}
function isInInlineCode(line: string, offset: number): boolean {
const textBeforeMatch = line.slice(0, offset);
const unescapedBackticks = textBeforeMatch.match(/(?<!\\)`/g);
return (unescapedBackticks?.length ?? 0) % 2 === 1;
}
export function isIgnoredMarkdownPosition(doc: Text, position: number): boolean {
const line = doc.lineAt(position);
return (
isInFrontmatter(doc, line.number) ||
isInFencedCode(doc, line.number) ||
isInInlineCode(line.text, position - line.from)
);
}
class ConversionWidget extends WidgetType {
constructor(
private readonly value: number,
@ -63,13 +117,17 @@ export function createUnitConversionExtension(plugin: UnitConverterPlugin) {
isLivePreview: boolean = false;
constructor(view: EditorView) {
this.isLivePreview = this.detectEditorMode();
this.isLivePreview = this.detectEditorMode(view);
this.decorations = this.buildDecorations(view);
}
detectEditorMode(): boolean {
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView);
detectEditorMode(editorView: EditorView): boolean {
const sourceView = editorView.dom.closest(".markdown-source-view");
if (sourceView) {
return sourceView.classList.contains("is-live-preview");
}
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return false;
const currentMode = view.currentMode;
@ -77,7 +135,7 @@ export function createUnitConversionExtension(plugin: UnitConverterPlugin) {
}
update(update: ViewUpdate) {
const newMode = this.detectEditorMode();
const newMode = this.detectEditorMode(update.view);
const modeChanged = newMode !== this.isLivePreview;
if (
@ -97,7 +155,6 @@ export function createUnitConversionExtension(plugin: UnitConverterPlugin) {
}
const builder = new RangeSetBuilder<Decoration>();
const cursorPos = view.state.selection.main.head;
const cursorLine = view.state.doc.lineAt(cursorPos).number;
@ -105,13 +162,17 @@ export function createUnitConversionExtension(plugin: UnitConverterPlugin) {
const text = view.state.doc.sliceString(from, to);
let match;
CONVERSION_REGEX.lastIndex = 0;
while ((match = CONVERSION_REGEX.exec(text)) !== null) {
const start = from + match.index;
const end = start + match[0].length;
const lineAtMatch = view.state.doc.lineAt(start).number;
// Apply decoration if we're not on the active line
if (lineAtMatch !== cursorLine) {
if (
lineAtMatch !== cursorLine &&
!isIgnoredMarkdownPosition(view.state.doc, start)
) {
const value = parseFloat(match[1]);
const fromUnit = match[2];
const toUnit = match[3];

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { isWithinIgnoredMarkdownElement } from "./md-post-processor";
interface FakeElement {
tagName: string;
classList: {
contains: (className: string) => boolean;
};
parentElement: FakeElement | null;
}
function createNode(tagName: string, classNames: string[] = [], parentElement: FakeElement | null = null) {
const element: FakeElement = {
tagName,
classList: {
contains: (className: string) => classNames.includes(className),
},
parentElement,
};
return { parentElement: element } as unknown as Node;
}
describe("isWithinIgnoredMarkdownElement", () => {
it("skips text inside code and pre elements", () => {
expect(isWithinIgnoredMarkdownElement(createNode("CODE"))).toBe(true);
expect(isWithinIgnoredMarkdownElement(createNode("PRE"))).toBe(true);
});
it("skips text inside Obsidian code-related containers", () => {
expect(isWithinIgnoredMarkdownElement(createNode("SPAN", ["cm-inline-code"]))).toBe(true);
expect(isWithinIgnoredMarkdownElement(createNode("DIV", ["HyperMD-codeblock"]))).toBe(true);
});
it("allows normal markdown text", () => {
expect(isWithinIgnoredMarkdownElement(createNode("P"))).toBe(false);
});
});

View file

@ -1,10 +1,30 @@
import { MarkdownPostProcessor } from "obsidian";
import type { UnitConverterSettings } from "../settings";
import type { MarkdownPostProcessor } from "obsidian";
import type UnitConverterPlugin from "../main";
import { CONVERSION_REGEX, formatConversion } from "../utils/conversion";
export function createMarkdownPostProcessor(
settings: UnitConverterSettings,
): MarkdownPostProcessor {
export function isWithinIgnoredMarkdownElement(node: Node): boolean {
let element = node.parentElement;
while (element) {
if (element.tagName === "CODE" || element.tagName === "PRE") {
return true;
}
if (
element.classList.contains("HyperMD-codeblock") ||
element.classList.contains("cm-inline-code") ||
element.classList.contains("frontmatter")
) {
return true;
}
element = element.parentElement;
}
return false;
}
export function createMarkdownPostProcessor(plugin: UnitConverterPlugin): MarkdownPostProcessor {
return (element: HTMLElement) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
let node = walker.nextNode();
@ -12,11 +32,12 @@ export function createMarkdownPostProcessor(
while (node) {
const text = node.nodeValue;
if (!text) {
if (!text || isWithinIgnoredMarkdownElement(node)) {
node = walker.nextNode();
continue;
}
CONVERSION_REGEX.lastIndex = 0;
const newText = text.replace(
CONVERSION_REGEX,
(match: string, valueStr: string, fromUnit: string, toUnit: string) => {
@ -25,9 +46,9 @@ export function createMarkdownPostProcessor(
value,
fromUnit,
toUnit,
settings.useDescriptiveNames,
settings.showOriginalUnits,
settings.customUnits,
plugin.settings.useDescriptiveNames,
plugin.settings.showOriginalUnits,
plugin.settings.customUnits,
);
},
);

View file

@ -1,4 +1,4 @@
import { Plugin, Editor } from "obsidian";
import { Editor, MarkdownView, Plugin } from "obsidian";
import { DEFAULT_SETTINGS, UnitConverterSettings, UnitConverterSettingTab } from "./settings";
import DestinationUnitSuggest from "./suggest/to-unit-suggest";
import { ConversionModal } from "./modal/conversion-modal";
@ -17,11 +17,9 @@ export default class UnitConverterPlugin extends Plugin {
this.addSettingTab(new UnitConverterSettingTab(this));
if (this.settings.isAutosuggestEnabled) {
this.registerEditorSuggest(new DestinationUnitSuggest(this));
}
this.registerEditorSuggest(new DestinationUnitSuggest(this));
this.registerMarkdownPostProcessor(createMarkdownPostProcessor(this.settings));
this.registerMarkdownPostProcessor(createMarkdownPostProcessor(this));
this.registerEditorExtension(createUnitConversionExtension(this));
@ -48,5 +46,19 @@ export default class UnitConverterPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
this.refreshMarkdownViews();
}
refreshMarkdownViews() {
this.app.workspace.iterateAllLeaves((leaf) => {
if (!(leaf.view instanceof MarkdownView)) {
return;
}
leaf.view.editor?.refresh();
if (leaf.view.getMode() === "preview") {
leaf.view.previewMode.rerender(true);
}
});
}
}

View file

@ -226,7 +226,7 @@ export class UnitConverterSettingTab extends PluginSettingTab {
.addText((text) => {
text.setPlaceholder("60").setValue(draft.factor.toString());
text.inputEl.setAttribute("type", "number");
text.inputEl.setAttribute("min", "0");
text.inputEl.setAttribute("min", "0.0000000001");
text.inputEl.setAttribute("step", "any");
text.onChange((value) => {
draft.factor = Number(value);

View file

@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import DestinationUnitSuggest from "./to-unit-suggest";
import type { UnitConverterSettings } from "../settings";
vi.mock("obsidian", () => ({
EditorSuggest: class {
context = null;
constructor() {}
},
}));
function createSuggest(settings: Partial<UnitConverterSettings> = {}) {
return new DestinationUnitSuggest({
app: {},
settings: {
useDescriptiveNames: false,
isAutosuggestEnabled: true,
showOriginalUnits: false,
customUnits: [],
...settings,
},
} as never);
}
describe("DestinationUnitSuggest", () => {
it("does not trigger when autosuggest is disabled", () => {
const suggest = createSuggest({ isAutosuggestEnabled: false });
const editor = { getLine: () => "[1m|" };
expect(suggest.onTrigger({ line: 0, ch: 4 }, editor as never)).toBeNull();
});
it("uses the nearest unfinished conversion before the cursor", () => {
const suggest = createSuggest();
const line = "[1m|cm] [2kg|";
const editor = { getLine: () => line };
const trigger = suggest.onTrigger({ line: 0, ch: line.length }, editor as never);
expect(trigger).toEqual({
start: { line: 0, ch: 13 },
end: { line: 0, ch: 13 },
query: "kg",
});
});
});

View file

@ -6,7 +6,7 @@ import {
EditorSuggestContext,
EditorSuggestTriggerInfo,
} from "obsidian";
import { getCompatibleUnits, isBuiltInUnit } from "../utils/conversion";
import { getCompatibleUnits, isBuiltInUnit, PARTIAL_CONVERSION_REGEX } from "../utils/conversion";
interface DestinationUnitCompletion {
label: string;
@ -19,11 +19,14 @@ export default class DestinationUnitSuggest extends EditorSuggest<DestinationUni
}
onTrigger(cursor: EditorPosition, editor: Editor): EditorSuggestTriggerInfo | null {
if (!this.plugin.settings.isAutosuggestEnabled) {
return null;
}
const line = editor.getLine(cursor.line);
const subString = line.substring(0, cursor.ch);
// Match pattern [value unit| or [value unit|text
const match = subString.match(/\[([\d.]+)([a-zA-Z0-9\-/]+)\|([a-zA-Z0-9-]*)/);
const match = subString.match(PARTIAL_CONVERSION_REGEX);
if (!match) return null;
const [, , fromUnit] = match;

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
CONVERSION_REGEX,
convertValue,
validateCustomUnit,
type CustomUnitDefinition,
} from "./conversion";
function matchConversion(input: string): RegExpExecArray | null {
CONVERSION_REGEX.lastIndex = 0;
return CONVERSION_REGEX.exec(input);
}
describe("CONVERSION_REGEX", () => {
it("matches common numeric syntax", () => {
expect(matchConversion("[-3m|cm]")?.[1]).toBe("-3");
expect(matchConversion("[.5m|cm]")?.[1]).toBe(".5");
expect(matchConversion("[1.2m|cm]")?.[1]).toBe("1.2");
expect(matchConversion("[1e3m|km]")).toMatchObject({
1: "1e3",
2: "m",
3: "km",
});
});
it("rejects malformed numeric syntax", () => {
expect(matchConversion("[1.2.3m|cm]")).toBeNull();
});
});
describe("custom units", () => {
const customUnits: CustomUnitDefinition[] = [
{
abbr: "step",
singular: "step",
plural: "steps",
measure: "length",
anchorUnit: "cm",
factor: 60,
},
];
it("requires a positive finite factor", () => {
expect(validateCustomUnit({ ...customUnits[0], factor: 0 }).isValid).toBe(false);
expect(validateCustomUnit({ ...customUnits[0], factor: -1 }).isValid).toBe(false);
expect(validateCustomUnit({ ...customUnits[0], factor: Number.POSITIVE_INFINITY }).isValid).toBe(
false,
);
});
it("converts custom units through built-in anchor units", () => {
expect(convertValue(2, "step", "m", customUnits)).toBeCloseTo(1.2);
expect(convertValue(120, "cm", "step", customUnits)).toBeCloseTo(2);
});
});

View file

@ -27,7 +27,10 @@ export interface CustomUnitValidationResult {
message?: string;
}
const UNIT_CODE_REGEX = /^[a-zA-Z0-9\-/]+$/;
const UNIT_CODE_PATTERN = "[a-zA-Z0-9\\-/]+";
const PARTIAL_UNIT_CODE_PATTERN = "[a-zA-Z0-9\\-/]*";
const NUMBER_PATTERN = "-?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?";
const UNIT_CODE_REGEX = new RegExp(`^${UNIT_CODE_PATTERN}$`);
function getCustomUnit(
unit: UnitCode,
@ -174,7 +177,7 @@ export function getDisplayUnit(
return (value === 1 ? measure.singular : measure.plural).toLowerCase();
}
} catch (e) {
console.error("Error getting descriptive name:", e);
console.debug("Error getting descriptive name:", e);
}
return unit.toString();
@ -207,7 +210,7 @@ export function formatConversion(
const originalUnit = getDisplayUnit(value, fromUnit, useDescriptiveNames, customUnits);
return `${value} ${originalUnit} (${convertedValue.toFixed(precision)} ${displayUnit})`;
} catch (e) {
console.error("Conversion error:", e);
console.debug("Conversion error:", e);
return `[${value}${fromUnit}|${toUnit}]`; // Return original format on error
}
}
@ -237,8 +240,8 @@ export function validateCustomUnit(
return { isValid: false, message: "Add singular and plural names." };
}
if (!Number.isFinite(unit.factor) || unit.factor < 0) {
return { isValid: false, message: "Factor must be 0 or greater." };
if (!Number.isFinite(unit.factor) || unit.factor <= 0) {
return { isValid: false, message: "Factor must be greater than 0." };
}
if (!isBuiltInUnit(anchorUnit)) {
@ -274,6 +277,16 @@ export function validateCustomUnit(
}
/**
* Regular expression to match unit conversion syntax
* Regular expression to match unit conversion syntax.
*/
export const CONVERSION_REGEX = /\[([\d.]+)([a-zA-Z0-9\-/]+)\|([a-zA-Z0-9\-/]+)\]/g;
export const CONVERSION_REGEX = new RegExp(
`\\[(${NUMBER_PATTERN})(${UNIT_CODE_PATTERN})\\|(${UNIT_CODE_PATTERN})\\]`,
"g",
);
/**
* Regular expression to match an unfinished conversion before the cursor.
*/
export const PARTIAL_CONVERSION_REGEX = new RegExp(
`\\[(${NUMBER_PATTERN})(${UNIT_CODE_PATTERN})\\|(${PARTIAL_UNIT_CODE_PATTERN})$`,
);