@messagevisor/catalog 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/assets/index-CfGbXx4X.css +1 -0
- package/dist/assets/index-r8ugP5JL.js +73 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +14 -0
- package/dist/logo-text.png +0 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -0
- package/lib/node/formatExamplePreview.d.ts +10 -0
- package/lib/node/formatExamplePreview.js +79 -0
- package/lib/node/formatExamplePreview.js.map +1 -0
- package/lib/node/index.d.ts +191 -0
- package/lib/node/index.js +1645 -0
- package/lib/node/index.js.map +1 -0
- package/package.json +59 -13
- package/src/App.tsx +73 -0
- package/src/api.spec.ts +42 -0
- package/src/api.ts +87 -0
- package/src/catalogBrandAssets.ts +8 -0
- package/src/components/details/ConditionTree.tsx +146 -0
- package/src/components/details/FieldGrid.tsx +16 -0
- package/src/components/details/GroupSegmentTree.tsx +73 -0
- package/src/components/details/MarkdownContent.tsx +23 -0
- package/src/components/details/TranslationsTable.tsx +263 -0
- package/src/components/details/UsageLinks.tsx +29 -0
- package/src/components/history/HistoryTimeline.tsx +122 -0
- package/src/components/layout/AppShell.tsx +338 -0
- package/src/components/layout/PageHeader.tsx +13 -0
- package/src/components/layout/Tabs.tsx +35 -0
- package/src/components/lists/EntityList.tsx +451 -0
- package/src/components/ui/Badge.tsx +21 -0
- package/src/components/ui/Button.tsx +12 -0
- package/src/components/ui/Card.tsx +9 -0
- package/src/components/ui/CodeBlock.tsx +7 -0
- package/src/components/ui/EmptyState.tsx +8 -0
- package/src/components/ui/Input.tsx +12 -0
- package/src/components/ui/LabelValueBadge.tsx +55 -0
- package/src/config.ts +2 -0
- package/src/context/CatalogContext.tsx +50 -0
- package/src/entityTypes.ts +49 -0
- package/src/index.ts +1 -0
- package/src/main.tsx +28 -0
- package/src/node/formatExamplePreview.ts +85 -0
- package/src/node/index.spec.ts +713 -0
- package/src/node/index.ts +2007 -0
- package/src/pages/EntityDetailPage.tsx +3345 -0
- package/src/pages/HistoryPage.tsx +26 -0
- package/src/pages/HomePage.tsx +21 -0
- package/src/pages/ListPage.tsx +59 -0
- package/src/styles.css +95 -0
- package/src/theme.ts +36 -0
- package/src/types.ts +127 -0
- package/src/utils/formatCatalogTimestamp.ts +77 -0
- package/src/utils/hashTranslationValue.spec.ts +20 -0
- package/src/utils/hashTranslationValue.ts +22 -0
- package/src/utils/searchQuery.ts +46 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useParams } from "react-router-dom";
|
|
2
|
+
|
|
3
|
+
import { HistoryTimeline } from "../components/history/HistoryTimeline";
|
|
4
|
+
import { encodeRouteSegment } from "../entityTypes";
|
|
5
|
+
import { PageHeader } from "../components/layout/PageHeader";
|
|
6
|
+
import { useCatalog } from "../context/CatalogContext";
|
|
7
|
+
|
|
8
|
+
export function HistoryPage() {
|
|
9
|
+
const { manifest } = useCatalog();
|
|
10
|
+
const { setKey } = useParams();
|
|
11
|
+
const path = setKey
|
|
12
|
+
? `/data/sets/${encodeRouteSegment(setKey)}/history`
|
|
13
|
+
: "/data/project/history";
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div>
|
|
17
|
+
<PageHeader
|
|
18
|
+
title={setKey ? `History for ${setKey}` : "Project History"}
|
|
19
|
+
description="Recent Git changes for authored definitions."
|
|
20
|
+
/>
|
|
21
|
+
<div className="px-6 pb-6">
|
|
22
|
+
<HistoryTimeline path={path} setKey={setKey} commitUrl={manifest.links?.commit} />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Navigate } from "react-router-dom";
|
|
2
|
+
|
|
3
|
+
import { useCatalog } from "../context/CatalogContext";
|
|
4
|
+
import { encodeRouteSegment } from "../entityTypes";
|
|
5
|
+
|
|
6
|
+
export function HomePage() {
|
|
7
|
+
const { manifest } = useCatalog();
|
|
8
|
+
|
|
9
|
+
if (!manifest.sets) {
|
|
10
|
+
return <Navigate to="/messages" replace />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const firstSetKey = manifest.setKeys[0];
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Navigate
|
|
17
|
+
to={firstSetKey ? `/sets/${encodeRouteSegment(firstSetKey)}/messages` : "/history"}
|
|
18
|
+
replace
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Navigate, useParams } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
import { fetchIndex } from "../api";
|
|
5
|
+
import { entityLabels, entityPathToType } from "../entityTypes";
|
|
6
|
+
import type { CatalogIndex, EntityPath } from "../types";
|
|
7
|
+
import { EntityList } from "../components/lists/EntityList";
|
|
8
|
+
import { PageHeader } from "../components/layout/PageHeader";
|
|
9
|
+
import { EmptyState } from "../components/ui/EmptyState";
|
|
10
|
+
|
|
11
|
+
function isEntityPath(value: string | undefined): value is EntityPath {
|
|
12
|
+
return (
|
|
13
|
+
value === "locales" ||
|
|
14
|
+
value === "messages" ||
|
|
15
|
+
value === "attributes" ||
|
|
16
|
+
value === "segments" ||
|
|
17
|
+
value === "targets"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ListPage() {
|
|
22
|
+
const { entityPath, setKey } = useParams();
|
|
23
|
+
const [index, setIndex] = React.useState<CatalogIndex | null>(null);
|
|
24
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
setIndex(null);
|
|
28
|
+
setError(null);
|
|
29
|
+
fetchIndex(setKey)
|
|
30
|
+
.then(setIndex)
|
|
31
|
+
.catch((err: Error) => setError(err.message));
|
|
32
|
+
}, [setKey]);
|
|
33
|
+
|
|
34
|
+
if (!isEntityPath(entityPath)) {
|
|
35
|
+
return <Navigate to="locales" replace />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const type = entityPathToType[entityPath];
|
|
39
|
+
|
|
40
|
+
if (error) {
|
|
41
|
+
return <EmptyState title="Unable to load catalog index" description={error} />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!index) {
|
|
45
|
+
return <div className="text-muted">Loading {entityLabels[type].plural.toLowerCase()}...</div>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div>
|
|
50
|
+
<PageHeader title={entityLabels[type].plural} />
|
|
51
|
+
<EntityList
|
|
52
|
+
type={type}
|
|
53
|
+
entities={index.entities[type]}
|
|
54
|
+
setKey={setKey}
|
|
55
|
+
allEntities={index.entities}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
color-scheme: light;
|
|
7
|
+
|
|
8
|
+
--mv-color-background: #e5e7eb;
|
|
9
|
+
--mv-color-surface: #ffffff;
|
|
10
|
+
--mv-color-elevated: #f8fafc;
|
|
11
|
+
--mv-color-border: #e5e7eb;
|
|
12
|
+
--mv-color-text: #374151;
|
|
13
|
+
--mv-color-muted: #6b7280;
|
|
14
|
+
--mv-color-primary: #475569;
|
|
15
|
+
--mv-color-success: #16a34a;
|
|
16
|
+
--mv-color-warning: #ea580c;
|
|
17
|
+
--mv-color-danger: #dc2626;
|
|
18
|
+
--mv-color-header: #1f2937;
|
|
19
|
+
--mv-color-header-active: #374151;
|
|
20
|
+
--mv-color-header-text: #f9fafb;
|
|
21
|
+
--mv-color-pill: #cbd5e1;
|
|
22
|
+
|
|
23
|
+
--mv-color-faint: #9ca3af;
|
|
24
|
+
--mv-color-placeholder: #9ca3af;
|
|
25
|
+
|
|
26
|
+
--mv-color-success-surface: #bbf7d0;
|
|
27
|
+
--mv-color-success-outline: #86efac;
|
|
28
|
+
--mv-color-warning-surface: #fed7aa;
|
|
29
|
+
--mv-color-warning-outline: #fdba74;
|
|
30
|
+
--mv-color-danger-surface: #fee2e2;
|
|
31
|
+
--mv-color-danger-outline: #fca5a5;
|
|
32
|
+
|
|
33
|
+
--mv-color-ring: rgb(71 85 105 / 0.45);
|
|
34
|
+
|
|
35
|
+
--mv-shadow-sm: 0 1px 2px 0 rgb(15 23 42 / 0.06);
|
|
36
|
+
--mv-shadow-md: 0 4px 6px -1px rgb(15 23 42 / 0.08), 0 2px 4px -2px rgb(15 23 42 / 0.06);
|
|
37
|
+
--mv-shadow: 0 1px 3px 0 rgb(15 23 42 / 0.1), 0 1px 2px -1px rgb(15 23 42 / 0.08);
|
|
38
|
+
--mv-shadow-soft: 0 10px 30px rgb(15 23 42 / 0.08);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Dark overrides only — default `:root` above is the light theme. */
|
|
42
|
+
/* @media (prefers-color-scheme: dark) {
|
|
43
|
+
:root {
|
|
44
|
+
color-scheme: dark;
|
|
45
|
+
|
|
46
|
+
--mv-color-background: #18181b;
|
|
47
|
+
--mv-color-surface: #27272a;
|
|
48
|
+
--mv-color-elevated: #3f3f46;
|
|
49
|
+
--mv-color-border: #52525b;
|
|
50
|
+
--mv-color-text: #f4f4f5;
|
|
51
|
+
--mv-color-muted: #d4d4d8;
|
|
52
|
+
--mv-color-primary: #e4e4e7;
|
|
53
|
+
--mv-color-success: #86efac;
|
|
54
|
+
--mv-color-warning: #fdba74;
|
|
55
|
+
--mv-color-danger: #fca5a5;
|
|
56
|
+
--mv-color-header: #09090b;
|
|
57
|
+
--mv-color-header-active: #27272a;
|
|
58
|
+
--mv-color-header-text: #f4f4f5;
|
|
59
|
+
--mv-color-pill: #71717a;
|
|
60
|
+
|
|
61
|
+
--mv-color-faint: #a1a1aa;
|
|
62
|
+
--mv-color-placeholder: #71717a;
|
|
63
|
+
|
|
64
|
+
--mv-color-success-surface: rgb(34 197 94 / 0.16);
|
|
65
|
+
--mv-color-success-outline: #22c55e;
|
|
66
|
+
--mv-color-warning-surface: rgb(251 146 60 / 0.18);
|
|
67
|
+
--mv-color-warning-outline: #fb923c;
|
|
68
|
+
--mv-color-danger-surface: rgb(248 113 113 / 0.14);
|
|
69
|
+
--mv-color-danger-outline: #f87171;
|
|
70
|
+
|
|
71
|
+
--mv-color-ring: rgb(228 228 231 / 0.45);
|
|
72
|
+
|
|
73
|
+
--mv-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.28);
|
|
74
|
+
--mv-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.28);
|
|
75
|
+
--mv-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.38), 0 1px 2px -1px rgb(0 0 0 / 0.32);
|
|
76
|
+
--mv-shadow-soft: 0 10px 30px rgb(0 0 0 / 0.45);
|
|
77
|
+
}
|
|
78
|
+
} */
|
|
79
|
+
|
|
80
|
+
body {
|
|
81
|
+
margin: 0;
|
|
82
|
+
background: var(--mv-color-background);
|
|
83
|
+
color: var(--mv-color-text);
|
|
84
|
+
font-family:
|
|
85
|
+
ui-sans-serif,
|
|
86
|
+
system-ui,
|
|
87
|
+
-apple-system,
|
|
88
|
+
BlinkMacSystemFont,
|
|
89
|
+
"Segoe UI",
|
|
90
|
+
sans-serif;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
a {
|
|
94
|
+
color: inherit;
|
|
95
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const themeTokens = {
|
|
2
|
+
color: {
|
|
3
|
+
background: "background",
|
|
4
|
+
surface: "surface",
|
|
5
|
+
elevated: "elevated",
|
|
6
|
+
border: "border",
|
|
7
|
+
text: "text",
|
|
8
|
+
muted: "muted",
|
|
9
|
+
faint: "faint",
|
|
10
|
+
placeholder: "placeholder",
|
|
11
|
+
primary: "primary",
|
|
12
|
+
success: "success",
|
|
13
|
+
warning: "warning",
|
|
14
|
+
danger: "danger",
|
|
15
|
+
header: "header",
|
|
16
|
+
headerActive: "header-active",
|
|
17
|
+
headerText: "header-text",
|
|
18
|
+
pill: "pill",
|
|
19
|
+
successSurface: "success-surface",
|
|
20
|
+
successOutline: "success-outline",
|
|
21
|
+
warningSurface: "warning-surface",
|
|
22
|
+
warningOutline: "warning-outline",
|
|
23
|
+
dangerSurface: "danger-surface",
|
|
24
|
+
dangerOutline: "danger-outline",
|
|
25
|
+
ring: "ring",
|
|
26
|
+
},
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const themeClasses = {
|
|
30
|
+
page: "min-h-screen bg-background text-text",
|
|
31
|
+
pageShell: "m-8 mx-auto max-w-5xl",
|
|
32
|
+
panel: "rounded border border-border bg-surface shadow",
|
|
33
|
+
subtlePanel: "rounded border border-border bg-elevated",
|
|
34
|
+
link: "font-medium text-primary hover:underline",
|
|
35
|
+
muted: "text-muted",
|
|
36
|
+
} as const;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export type EntityType = "locale" | "message" | "attribute" | "segment" | "target";
|
|
2
|
+
|
|
3
|
+
export type EntityPath = "locales" | "messages" | "attributes" | "segments" | "targets";
|
|
4
|
+
export type GitProvider = "github" | "gitlab" | "bitbucket";
|
|
5
|
+
export type DevEditorId = "cursor" | "vscode";
|
|
6
|
+
|
|
7
|
+
export interface DevEditor {
|
|
8
|
+
id: DevEditorId;
|
|
9
|
+
label: string;
|
|
10
|
+
icon: DevEditorId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LastModified {
|
|
14
|
+
commit: string;
|
|
15
|
+
author: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EntitySummary {
|
|
20
|
+
key: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
archived?: boolean;
|
|
23
|
+
deprecated?: boolean;
|
|
24
|
+
targets?: string[];
|
|
25
|
+
messageCount?: number;
|
|
26
|
+
locales?: string[];
|
|
27
|
+
overrideLocales?: string[];
|
|
28
|
+
lastModified?: LastModified;
|
|
29
|
+
href: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CatalogValueSource = "direct" | "inherited" | "target" | "missing";
|
|
33
|
+
|
|
34
|
+
export interface FormatRow {
|
|
35
|
+
path: string;
|
|
36
|
+
value: unknown;
|
|
37
|
+
source: CatalogValueSource;
|
|
38
|
+
from?: string;
|
|
39
|
+
/** Sample output from Intl for `number` / `date` / `time` presets (catalog build). */
|
|
40
|
+
examplePreview?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TranslationRow {
|
|
44
|
+
locale: string;
|
|
45
|
+
value: string;
|
|
46
|
+
source: CatalogValueSource;
|
|
47
|
+
from?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DuplicateTranslationSource {
|
|
51
|
+
messageKey: string;
|
|
52
|
+
locale: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DuplicateTranslationValue {
|
|
56
|
+
value: string;
|
|
57
|
+
messageKeys: string[];
|
|
58
|
+
sources: DuplicateTranslationSource[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LocaleDuplicates {
|
|
62
|
+
locale: string;
|
|
63
|
+
summary: {
|
|
64
|
+
duplicateValues: number;
|
|
65
|
+
duplicateMessageKeys: number;
|
|
66
|
+
};
|
|
67
|
+
duplicateValues: DuplicateTranslationValue[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CatalogIndex {
|
|
71
|
+
set: string;
|
|
72
|
+
counts: Record<EntityType, number>;
|
|
73
|
+
entities: Record<EntityType, EntitySummary[]>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CatalogManifest {
|
|
77
|
+
schemaVersion: string;
|
|
78
|
+
generatedAt: string;
|
|
79
|
+
router?: "hash" | "browser";
|
|
80
|
+
sets: boolean;
|
|
81
|
+
setKeys: string[];
|
|
82
|
+
dev?: {
|
|
83
|
+
editors: DevEditor[];
|
|
84
|
+
};
|
|
85
|
+
links?: {
|
|
86
|
+
provider?: GitProvider;
|
|
87
|
+
repository?: string;
|
|
88
|
+
source: string;
|
|
89
|
+
commit: string;
|
|
90
|
+
};
|
|
91
|
+
paths: {
|
|
92
|
+
projectHistory: string;
|
|
93
|
+
root?: string;
|
|
94
|
+
sets?: Record<string, string>;
|
|
95
|
+
};
|
|
96
|
+
counts: Record<string, Record<EntityType, number>>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface HistoryEntity {
|
|
100
|
+
type: EntityType | "test";
|
|
101
|
+
key: string;
|
|
102
|
+
set?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface HistoryEntry {
|
|
106
|
+
commit: string;
|
|
107
|
+
author: string;
|
|
108
|
+
timestamp: string;
|
|
109
|
+
entities: HistoryEntity[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface HistoryPage {
|
|
113
|
+
page: number;
|
|
114
|
+
pageSize: number;
|
|
115
|
+
totalPages: number;
|
|
116
|
+
entries: HistoryEntry[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface EntityDetail<T = Record<string, unknown>> {
|
|
120
|
+
type: EntityType;
|
|
121
|
+
key: string;
|
|
122
|
+
entity: T;
|
|
123
|
+
sourcePath?: string;
|
|
124
|
+
editLinks?: Partial<Record<DevEditorId, string>>;
|
|
125
|
+
lastModified?: LastModified;
|
|
126
|
+
[key: string]: unknown;
|
|
127
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function getOrdinal(day: number) {
|
|
2
|
+
const remainder = day % 100;
|
|
3
|
+
|
|
4
|
+
if (remainder >= 11 && remainder <= 13) {
|
|
5
|
+
return `${day}th`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
switch (day % 10) {
|
|
9
|
+
case 1:
|
|
10
|
+
return `${day}st`;
|
|
11
|
+
case 2:
|
|
12
|
+
return `${day}nd`;
|
|
13
|
+
case 3:
|
|
14
|
+
return `${day}rd`;
|
|
15
|
+
default:
|
|
16
|
+
return `${day}th`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getLocalTimeZoneLabel(date: Date) {
|
|
21
|
+
const formatter = new Intl.DateTimeFormat(undefined, {
|
|
22
|
+
timeZoneName: "short",
|
|
23
|
+
});
|
|
24
|
+
const parts = formatter.formatToParts(date);
|
|
25
|
+
const timeZoneName = parts.find((part) => part.type === "timeZoneName")?.value;
|
|
26
|
+
|
|
27
|
+
if (timeZoneName && timeZoneName.trim()) {
|
|
28
|
+
return timeZoneName;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const localTimestamp = Date.UTC(
|
|
32
|
+
date.getFullYear(),
|
|
33
|
+
date.getMonth(),
|
|
34
|
+
date.getDate(),
|
|
35
|
+
date.getHours(),
|
|
36
|
+
date.getMinutes(),
|
|
37
|
+
date.getSeconds(),
|
|
38
|
+
date.getMilliseconds(),
|
|
39
|
+
);
|
|
40
|
+
const offset = Math.round((localTimestamp - date.getTime()) / 60000);
|
|
41
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
42
|
+
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, "0");
|
|
43
|
+
const minutes = String(Math.abs(offset) % 60).padStart(2, "0");
|
|
44
|
+
|
|
45
|
+
return `UTC${sign}${hours}:${minutes}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatCatalogTimestamp(value: string) {
|
|
49
|
+
const date = new Date(value);
|
|
50
|
+
|
|
51
|
+
if (Number.isNaN(date.getTime())) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parts = new Intl.DateTimeFormat(undefined, {
|
|
56
|
+
month: "long",
|
|
57
|
+
day: "numeric",
|
|
58
|
+
year: "numeric",
|
|
59
|
+
hour: "2-digit",
|
|
60
|
+
minute: "2-digit",
|
|
61
|
+
second: "2-digit",
|
|
62
|
+
hour12: false,
|
|
63
|
+
}).formatToParts(date);
|
|
64
|
+
|
|
65
|
+
const month = parts.find((part) => part.type === "month")?.value;
|
|
66
|
+
const day = parts.find((part) => part.type === "day")?.value;
|
|
67
|
+
const year = parts.find((part) => part.type === "year")?.value;
|
|
68
|
+
const hour = parts.find((part) => part.type === "hour")?.value;
|
|
69
|
+
const minute = parts.find((part) => part.type === "minute")?.value;
|
|
70
|
+
const second = parts.find((part) => part.type === "second")?.value;
|
|
71
|
+
|
|
72
|
+
if (!month || !day || !year || !hour || !minute || !second) {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return `${month} ${getOrdinal(Number(day))}, ${year} at ${hour}:${minute}:${second} ${getLocalTimeZoneLabel(date)}`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { hashTranslationValue } from "./hashTranslationValue";
|
|
2
|
+
|
|
3
|
+
describe("hashTranslationValue", function () {
|
|
4
|
+
it("returns a stable alphanumeric hash for the same value", function () {
|
|
5
|
+
const value = "Hello, world!";
|
|
6
|
+
|
|
7
|
+
expect(hashTranslationValue(value)).toBe(hashTranslationValue(value));
|
|
8
|
+
expect(hashTranslationValue(value)).toMatch(/^dup[0-9a-z]+$/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("produces different hashes for whitespace differences", function () {
|
|
12
|
+
expect(hashTranslationValue("hello world")).not.toBe(hashTranslationValue("hello world"));
|
|
13
|
+
expect(hashTranslationValue("hello")).not.toBe(hashTranslationValue("hello\n"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("produces different hashes for special-character differences", function () {
|
|
17
|
+
expect(hashTranslationValue("café")).not.toBe(hashTranslationValue("cafe"));
|
|
18
|
+
expect(hashTranslationValue("a&b")).not.toBe(hashTranslationValue("a b"));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** URL fragment prefix for locale duplicate translation values. */
|
|
2
|
+
const DUPLICATE_VALUE_HASH_PREFIX = "dup";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic alphanumeric hash of a translation value for permalink fragments.
|
|
6
|
+
* Hashes the full string (including whitespace and special characters).
|
|
7
|
+
*/
|
|
8
|
+
export function hashTranslationValue(value: string): string {
|
|
9
|
+
let h1 = 0xdeadbeef;
|
|
10
|
+
let h2 = 0x41c6ce57;
|
|
11
|
+
|
|
12
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
13
|
+
const code = value.charCodeAt(index);
|
|
14
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
15
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
19
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
20
|
+
|
|
21
|
+
return `${DUPLICATE_VALUE_HASH_PREFIX}${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Shared advanced search tokenization (used by entity lists, format tables, etc.). */
|
|
2
|
+
|
|
3
|
+
export interface ParsedQuery {
|
|
4
|
+
freeText: string[];
|
|
5
|
+
qualifiers: Array<{ key: string; value: string }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function tokenize(raw: string): string[] {
|
|
9
|
+
const tokens: string[] = [];
|
|
10
|
+
let current = "";
|
|
11
|
+
let inQuote = false;
|
|
12
|
+
for (const ch of raw) {
|
|
13
|
+
if (ch === '"') {
|
|
14
|
+
inQuote = !inQuote;
|
|
15
|
+
current += ch;
|
|
16
|
+
} else if (ch === " " && !inQuote) {
|
|
17
|
+
if (current) tokens.push(current);
|
|
18
|
+
current = "";
|
|
19
|
+
} else {
|
|
20
|
+
current += ch;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (current) tokens.push(current);
|
|
24
|
+
return tokens;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseQuery(raw: string): ParsedQuery {
|
|
28
|
+
const freeText: string[] = [];
|
|
29
|
+
const qualifiers: Array<{ key: string; value: string }> = [];
|
|
30
|
+
|
|
31
|
+
for (const token of tokenize(raw.trim())) {
|
|
32
|
+
const colonIdx = token.indexOf(":");
|
|
33
|
+
if (colonIdx > 0) {
|
|
34
|
+
const key = token.slice(0, colonIdx).toLowerCase();
|
|
35
|
+
let value = token.slice(colonIdx + 1);
|
|
36
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length > 2) {
|
|
37
|
+
value = value.slice(1, -1);
|
|
38
|
+
}
|
|
39
|
+
if (value) qualifiers.push({ key, value });
|
|
40
|
+
} else {
|
|
41
|
+
freeText.push(token.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { freeText, qualifiers };
|
|
46
|
+
}
|