@kernhq/module-inventory 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
|
@@ -5,16 +5,58 @@
|
|
|
5
5
|
{
|
|
6
6
|
"idx": 0,
|
|
7
7
|
"version": "7",
|
|
8
|
-
"when":
|
|
8
|
+
"when": 1787759199523,
|
|
9
9
|
"tag": "0000_init",
|
|
10
10
|
"breakpoints": true
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"idx": 1,
|
|
14
14
|
"version": "7",
|
|
15
|
-
"when":
|
|
15
|
+
"when": 1787759200523,
|
|
16
16
|
"tag": "0001_rls",
|
|
17
17
|
"breakpoints": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"idx": 2,
|
|
21
|
+
"version": "7",
|
|
22
|
+
"when": 1787774484162,
|
|
23
|
+
"tag": "0002_custody_and_categories",
|
|
24
|
+
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1787779029775,
|
|
30
|
+
"tag": "0003_repairs",
|
|
31
|
+
"breakpoints": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"idx": 4,
|
|
35
|
+
"version": "7",
|
|
36
|
+
"when": 1787783383973,
|
|
37
|
+
"tag": "0004_platform_surfaces",
|
|
38
|
+
"breakpoints": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"idx": 5,
|
|
42
|
+
"version": "7",
|
|
43
|
+
"when": 1787788455672,
|
|
44
|
+
"tag": "0005_repair_dates",
|
|
45
|
+
"breakpoints": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"idx": 6,
|
|
49
|
+
"version": "7",
|
|
50
|
+
"when": 1787788455673,
|
|
51
|
+
"tag": "0006_workspace_registry_read",
|
|
52
|
+
"breakpoints": true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"idx": 7,
|
|
56
|
+
"version": "7",
|
|
57
|
+
"when": 1787788455674,
|
|
58
|
+
"tag": "0007_history_sequence",
|
|
59
|
+
"breakpoints": true
|
|
18
60
|
}
|
|
19
61
|
]
|
|
20
62
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-inventory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Kern inventory module: the company's asset register — what it owns,
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Kern inventory module: the company's asset register — what it owns, item by item, with asset tags, serial numbers, purchase and warranty details",
|
|
5
5
|
"homepage": "https://github.com/KernAIO/module-inventory#readme",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"type": "module",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"migrations",
|
|
22
22
|
"src",
|
|
23
23
|
"drizzle.config.ts",
|
|
24
|
+
"tsconfig.base.json",
|
|
24
25
|
"tsconfig.json",
|
|
25
26
|
"tsconfig.client.json",
|
|
26
27
|
"vitest.config.ts",
|
|
@@ -63,7 +64,7 @@
|
|
|
63
64
|
"@kernhq/contracts": "^0.7.0",
|
|
64
65
|
"@kernhq/kernel": "^0.7.0",
|
|
65
66
|
"@kernhq/tsconfig": "^0.1.0",
|
|
66
|
-
"@kernhq/ui": "^0.
|
|
67
|
+
"@kernhq/ui": "^0.12.0",
|
|
67
68
|
"@tanstack/svelte-query": "^6.1.0",
|
|
68
69
|
"@types/node": "^24.0.0",
|
|
69
70
|
"@types/pg": "^8.15.0",
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
"peerDependencies": {
|
|
78
79
|
"@kernhq/contracts": "^0.7.0",
|
|
79
80
|
"@kernhq/kernel": "^0.7.0",
|
|
80
|
-
"@kernhq/ui": "^0.
|
|
81
|
+
"@kernhq/ui": "^0.12.0",
|
|
81
82
|
"@tanstack/svelte-query": "^6.1.0",
|
|
82
83
|
"svelte": "^5.46.0"
|
|
83
84
|
},
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { getHost } from '@kernhq/ui'
|
|
2
2
|
import { createInventoryClient, type InventoryApi } from './api.js'
|
|
3
|
-
import {
|
|
3
|
+
import { t } from './i18n.js'
|
|
4
|
+
import { createMockInventoryApi, type MockFileFacts } from './mock.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slice of the host's core client the mock needs, named by shape.
|
|
8
|
+
*
|
|
9
|
+
* Only in the mock path: the real server asks core for a file's name over the broker before it
|
|
10
|
+
* records an attachment. `mock.ts` imports nothing and therefore has no client to ask, so the name
|
|
11
|
+
* is resolved here — where `getHost()` is already in hand — and handed in.
|
|
12
|
+
*/
|
|
13
|
+
interface MockFileApi {
|
|
14
|
+
files: { get(input: { id: string }): Promise<{ name: string; mimeType?: string; size?: number }> }
|
|
15
|
+
}
|
|
4
16
|
|
|
5
17
|
/**
|
|
6
18
|
* This module's API client, made once and shared.
|
|
@@ -17,7 +29,20 @@ export function getInventoryApi(): InventoryApi {
|
|
|
17
29
|
if (cached) return cached
|
|
18
30
|
const host = getHost()
|
|
19
31
|
cached = host.isMock
|
|
20
|
-
? (createMockInventoryApi(
|
|
32
|
+
? (createMockInventoryApi({
|
|
33
|
+
describeFile: async (fileId): Promise<MockFileFacts> => {
|
|
34
|
+
try {
|
|
35
|
+
const file = await (host.api as MockFileApi).files.get({ id: fileId })
|
|
36
|
+
return { name: file.name, mimeType: file.mimeType ?? null, size: file.size ?? null }
|
|
37
|
+
} catch {
|
|
38
|
+
// A demo must not lose a file because its record could not be read; the row still
|
|
39
|
+
// says something rather than nothing — and says it in the reader's own language. This
|
|
40
|
+
// was the literal string 'Uploaded file', which is a label this module prints and
|
|
41
|
+
// therefore one of its strings, not data that arrived from somewhere else.
|
|
42
|
+
return { name: t('file_unnamed'), mimeType: null, size: null }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}) as unknown as InventoryApi)
|
|
21
46
|
: createInventoryClient({ baseUrl: host.apiBaseUrl })
|
|
22
47
|
return cached
|
|
23
48
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createModuleClient, type KernClientOptions } from '@kernhq/sdk'
|
|
2
2
|
import type { ContractRouterClient } from '@orpc/contract'
|
|
3
|
-
import type { InventoryContract } from '../contract.js'
|
|
3
|
+
import type { InventoryContract } from '../contract/index.js'
|
|
4
4
|
|
|
5
5
|
/** The typed client, derived from the contract — no hand-written method list to drift. */
|
|
6
6
|
export type InventoryApi = ContractRouterClient<InventoryContract>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { FSI, isolate, isolated, PDI } from './bidi.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The rule that stops a Latin value rendering backwards inside a Persian or Arabic sentence.
|
|
9
|
+
*
|
|
10
|
+
* Two halves, and the second is the one that lasts. The first checks `isolate()` itself. The second
|
|
11
|
+
* reads every client source and refuses a `t()` call that interpolates a value without isolating it
|
|
12
|
+
* — because this defect is invisible in the source, invisible in a type-check, invisible in
|
|
13
|
+
* English, and only appears as a quote or a full stop at the wrong end of a name on somebody's
|
|
14
|
+
* screen. A message added next year with a `{name}` in it fails here rather than there.
|
|
15
|
+
*/
|
|
16
|
+
describe('isolate', () => {
|
|
17
|
+
it('wraps a value in the first-strong isolate pair', () => {
|
|
18
|
+
expect(isolate('MacBook Pro 14"')).toBe(`${FSI}MacBook Pro 14"${PDI}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('leaves an absent value as nothing at all, rather than two invisible characters', () => {
|
|
22
|
+
// `{name}` at a call site is very often `asset?.name ?? ''`, and a placeholder nothing filled
|
|
23
|
+
// should leave no trace in the sentence.
|
|
24
|
+
expect(isolate('')).toBe('')
|
|
25
|
+
expect(isolate(null)).toBe('')
|
|
26
|
+
expect(isolate(undefined)).toBe('')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('is idempotent, so a value isolated twice is still one run', () => {
|
|
30
|
+
// `history_cleared` passes a `field` that is already `t(key)` or an isolated column name, and
|
|
31
|
+
// `isolated({ field })` runs over it again. An unbalanced nest would reorder the sentence.
|
|
32
|
+
expect(isolate(isolate('INV-0042'))).toBe(isolate('INV-0042'))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('strips embedding controls that rode along on a pasted value', () => {
|
|
36
|
+
// The same characters `price.ts` strips before parsing a number: they arrive by copying out of
|
|
37
|
+
// an RTL document, and an unbalanced one inside an isolate reorders the rest of the sentence.
|
|
38
|
+
expect(isolate('C02X1234')).toBe(`${FSI}C02X1234${PDI}`)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('takes a number as readily as a string', () => {
|
|
42
|
+
expect(isolate(42)).toBe(`${FSI}42${PDI}`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('isolates every value of a bag, so a parameter added later is covered by construction', () => {
|
|
46
|
+
expect(isolated({ actor: 'Ada', person: 'Bruno' })).toEqual({
|
|
47
|
+
actor: `${FSI}Ada${PDI}`,
|
|
48
|
+
person: `${FSI}Bruno${PDI}`,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- every call site, mechanically
|
|
54
|
+
|
|
55
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
56
|
+
|
|
57
|
+
/** Comments only. Never a code line, so a `//` inside a string literal cannot truncate a call. */
|
|
58
|
+
function stripComments(source: string): string {
|
|
59
|
+
return source
|
|
60
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
61
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
62
|
+
.split('\n')
|
|
63
|
+
.filter((line) => {
|
|
64
|
+
const trimmed = line.trimStart()
|
|
65
|
+
return !trimmed.startsWith('//') && !trimmed.startsWith('*')
|
|
66
|
+
})
|
|
67
|
+
.join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The arguments of a call, split on the commas that are actually arguments.
|
|
72
|
+
*
|
|
73
|
+
* **String-aware**, because a message key is not the only thing that can carry a comma and a
|
|
74
|
+
* scanner that silently finds fewer call sites than there are is one that passes while the defect
|
|
75
|
+
* ships. `errors.test.ts` has the same walker for the same reason.
|
|
76
|
+
*/
|
|
77
|
+
function argumentsOf(source: string, open: number): string[] | null {
|
|
78
|
+
const parts: string[] = []
|
|
79
|
+
let depth = 0
|
|
80
|
+
let quote: string | null = null
|
|
81
|
+
let start = open + 1
|
|
82
|
+
for (let i = open; i < source.length; i++) {
|
|
83
|
+
const ch = source[i]
|
|
84
|
+
if (quote) {
|
|
85
|
+
if (ch === '\\') i++
|
|
86
|
+
else if (ch === quote) quote = null
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch
|
|
90
|
+
else if (ch === '(' || ch === '{' || ch === '[') depth++
|
|
91
|
+
else if (ch === ')' || ch === '}' || ch === ']') {
|
|
92
|
+
depth--
|
|
93
|
+
if (depth === 0) {
|
|
94
|
+
parts.push(source.slice(start, i))
|
|
95
|
+
return parts
|
|
96
|
+
}
|
|
97
|
+
} else if (ch === ',' && depth === 1) {
|
|
98
|
+
parts.push(source.slice(start, i))
|
|
99
|
+
start = i + 1
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The second argument of every `t(…)` call that has one. */
|
|
106
|
+
function parameterisedCalls(source: string): Array<{ key: string; params: string }> {
|
|
107
|
+
const found: Array<{ key: string; params: string }> = []
|
|
108
|
+
for (const match of source.matchAll(/\bt\(/g)) {
|
|
109
|
+
const args = argumentsOf(source, match.index + match[0].length - 1)
|
|
110
|
+
if (!args || args.length < 2) continue
|
|
111
|
+
found.push({ key: args[0]!.trim(), params: args.slice(1).join(',').trim() })
|
|
112
|
+
}
|
|
113
|
+
return found
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A count is exempt, and must be: `t()` formats a number through `Intl.NumberFormat`, so «۱۲»
|
|
118
|
+
* reaches a Persian reader as their own digits. Isolating it would hand `t()` a string instead and
|
|
119
|
+
* print "12" in the middle of a Persian sentence — the opposite of the fix.
|
|
120
|
+
*/
|
|
121
|
+
const COUNT_ONLY = /^\{\s*(n|count)\s*(:[^,}]+)?\s*\}$/
|
|
122
|
+
|
|
123
|
+
function sources(): Array<{ file: string; text: string }> {
|
|
124
|
+
return readdirSync(HERE, { recursive: true, encoding: 'utf8' })
|
|
125
|
+
.filter((file) => (file.endsWith('.ts') || file.endsWith('.svelte')) && !file.endsWith('.test.ts'))
|
|
126
|
+
.map((file) => ({ file, text: stripComments(readFileSync(join(HERE, file), 'utf8')) }))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('every translated sentence isolates the values put into it', () => {
|
|
130
|
+
it('passes `isolated(…)` at every parameterised call site, or a bare count', () => {
|
|
131
|
+
const offenders: string[] = []
|
|
132
|
+
for (const { file, text } of sources())
|
|
133
|
+
for (const { key, params } of parameterisedCalls(text))
|
|
134
|
+
if (!params.startsWith('isolated(') && !COUNT_ONLY.test(params))
|
|
135
|
+
offenders.push(`${file}: t(${key}, ${params.replace(/\s+/g, ' ')})`)
|
|
136
|
+
expect(offenders).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The scanner has to actually find things, or an empty result would pass for ever. This module
|
|
141
|
+
* has dozens of parameterised messages; a run that finds none means the matcher broke, not that
|
|
142
|
+
* the code got better.
|
|
143
|
+
*/
|
|
144
|
+
it('is looking at real call sites rather than passing on an empty sweep', () => {
|
|
145
|
+
const total = sources().reduce((sum, { text }) => sum + parameterisedCalls(text).length, 0)
|
|
146
|
+
expect(total).toBeGreaterThan(20)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting a value somebody typed inside a sentence somebody else translated.
|
|
3
|
+
*
|
|
4
|
+
* **A Latin value dropped into a Persian or Arabic sentence renders backwards, and nothing about
|
|
5
|
+
* the source says so.** The Unicode bidirectional algorithm resolves the *neutral* characters at a
|
|
6
|
+
* run's edges — a quote, a bracket, a full stop, a hyphen — against the paragraph, not against the
|
|
7
|
+
* value they were typed as part of. So in an RTL paragraph:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* MacBook Pro 14" به Bruno Weber تحویل داده شد → … ﻪﺑ "MacBook Pro 14
|
|
11
|
+
* یادداشت: For the Berlin trip. → .For the Berlin trip :ﺖﺷﺍﺩﺩﺎﯾ
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The inch mark leaves the name it belongs to and lands against the next word; the full stop of an
|
|
15
|
+
* English note ends up at the head of it. Both were reproduced with `fribidi` against this module's
|
|
16
|
+
* own catalogue before this file existed — an asset tag, a serial number and a Latin name inside
|
|
17
|
+
* `history_changed`, `held_by` and every `{name}` toast.
|
|
18
|
+
*
|
|
19
|
+
* `isolate()` is the fix the standard provides: **U+2068 FIRST STRONG ISOLATE** opens a run whose
|
|
20
|
+
* direction is taken from the value's own first strong character, and **U+2069 POP DIRECTIONAL
|
|
21
|
+
* ISOLATE** closes it. Neutrals inside the run resolve against the value; the run as a whole is
|
|
22
|
+
* placed as one unit in the sentence. First-strong rather than a forced LTR, because a Persian
|
|
23
|
+
* asset name inside a Persian sentence must go on reading right-to-left — the value decides, which
|
|
24
|
+
* is the whole point.
|
|
25
|
+
*
|
|
26
|
+
* **Where CSS can do it instead, CSS does it.** A value rendered into its own element — an asset
|
|
27
|
+
* tag in a `<code>`, a serial in a `<span class="ltr">` — carries `unicode-bidi: isolate` and needs
|
|
28
|
+
* nothing from here. This is for the other case: `t('history_changed', { from, to })` returns one
|
|
29
|
+
* string with the values already substituted, and there is no element left to put a rule on.
|
|
30
|
+
*
|
|
31
|
+
* Pure, and a plain module rather than something inside a component, for the reason `price.ts` and
|
|
32
|
+
* `timeline.ts` are: a `.svelte` file drags a compiler behind it and cannot be unit-tested.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Written as escapes, never as the characters themselves.
|
|
37
|
+
*
|
|
38
|
+
* These are invisible. A literal one in a source file is a character nobody can see in a diff, in
|
|
39
|
+
* a review, or in an editor — and `price.ts` already strips a class of them precisely because they
|
|
40
|
+
* ride along on pasted text without anybody noticing.
|
|
41
|
+
*/
|
|
42
|
+
/** U+2068 FIRST STRONG ISOLATE — direction taken from the value's own first strong character. */
|
|
43
|
+
export const FSI = '\u2068'
|
|
44
|
+
/** U+2069 POP DIRECTIONAL ISOLATE. */
|
|
45
|
+
export const PDI = '\u2069'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The isolates (U+2066–U+2069) and the embeddings and overrides (U+202A–U+202E).
|
|
49
|
+
*
|
|
50
|
+
* Deliberately **not** LRM and RLM (U+200E, U+200F). Those are marks rather than containers: they
|
|
51
|
+
* cannot be left unbalanced, and `Intl.DateTimeFormat` puts them inside an Arabic date on purpose —
|
|
52
|
+
* `14/03/2027` is CLDR's own output, and stripping them would break the date to fix nothing.
|
|
53
|
+
*/
|
|
54
|
+
const CONTROLS = /[\u2066-\u2069\u202a-\u202e]/g
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One value, safe to interpolate into a translated sentence.
|
|
58
|
+
*
|
|
59
|
+
* An empty or absent value comes back as an empty string rather than as two invisible characters:
|
|
60
|
+
* a placeholder nothing filled should leave no trace in the sentence, and `{name}` is very often
|
|
61
|
+
* `asset?.name ?? ''` at the call site.
|
|
62
|
+
*
|
|
63
|
+
* Anything the value already carries is stripped first. Controls arrive by being pasted out of an
|
|
64
|
+
* RTL document — the same reason `price.ts` strips them before parsing a number — and an unbalanced
|
|
65
|
+
* one inside an isolate would reorder the rest of the sentence rather than the rest of the value.
|
|
66
|
+
*/
|
|
67
|
+
export function isolate(value: string | number | null | undefined): string {
|
|
68
|
+
if (value === null || value === undefined) return ''
|
|
69
|
+
const text = String(value).replace(CONTROLS, '')
|
|
70
|
+
return text ? `${FSI}${text}${PDI}` : ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Isolate every value of a parameter bag, for the common case of a whole `t()` call.
|
|
75
|
+
*
|
|
76
|
+
* `t(key, isolated({ actor, person }))` rather than four `isolate()` calls at the site, so a
|
|
77
|
+
* parameter added to a message later is isolated by construction instead of by remembering.
|
|
78
|
+
*/
|
|
79
|
+
export function isolated<K extends string>(
|
|
80
|
+
params: Record<K, string | number | null | undefined>,
|
|
81
|
+
): Record<K, string> {
|
|
82
|
+
const out = {} as Record<K, string>
|
|
83
|
+
for (const key of Object.keys(params) as K[]) out[key] = isolate(params[key])
|
|
84
|
+
return out
|
|
85
|
+
}
|