diff --git a/README.md b/README.md
index 3c92d32..4ce9ef9 100644
--- a/README.md
+++ b/README.md
@@ -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
+### 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
diff --git a/package.json b/package.json
index 5cc9171..72d33d1 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 93b0541..ce3d4a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/editor/extension.test.ts b/src/editor/extension.test.ts
new file mode 100644
index 0000000..e04ca93
--- /dev/null
+++ b/src/editor/extension.test.ts
@@ -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);
+ });
+});
diff --git a/src/editor/extension.ts b/src/editor/extension.ts
index 143a5c0..d1197c3 100644
--- a/src/editor/extension.ts
+++ b/src/editor/extension.ts
@@ -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(/(?();
-
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];
diff --git a/src/editor/md-post-processor.test.ts b/src/editor/md-post-processor.test.ts
new file mode 100644
index 0000000..1ef6a24
--- /dev/null
+++ b/src/editor/md-post-processor.test.ts
@@ -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);
+ });
+});
diff --git a/src/editor/md-post-processor.ts b/src/editor/md-post-processor.ts
index 25cc66f..a82f3bc 100644
--- a/src/editor/md-post-processor.ts
+++ b/src/editor/md-post-processor.ts
@@ -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,
);
},
);
diff --git a/src/main.ts b/src/main.ts
index 6c1ada5..b408c5a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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);
+ }
+ });
}
}
diff --git a/src/settings.ts b/src/settings.ts
index 7a81aaa..fe3e711 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -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);
diff --git a/src/suggest/to-unit-suggest.test.ts b/src/suggest/to-unit-suggest.test.ts
new file mode 100644
index 0000000..ce21720
--- /dev/null
+++ b/src/suggest/to-unit-suggest.test.ts
@@ -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 = {}) {
+ 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",
+ });
+ });
+});
diff --git a/src/suggest/to-unit-suggest.ts b/src/suggest/to-unit-suggest.ts
index 100b830..9bd1f72 100644
--- a/src/suggest/to-unit-suggest.ts
+++ b/src/suggest/to-unit-suggest.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/src/utils/conversion.ts b/src/utils/conversion.ts
index 7c4e85e..ec936a0 100644
--- a/src/utils/conversion.ts
+++ b/src/utils/conversion.ts
@@ -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})$`,
+);