glossarist 0.4.39 → 0.4.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.4.39",
3
+ "version": "0.4.40",
4
4
  "description": "JavaScript SDK for Glossarist GCR packages — read, write, validate, and manage terminology concepts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,297 @@
1
+ import { RegistrableModel } from './registrable.js';
2
+ import { ConceptSource } from './concept-source.js';
3
+ import type { ConceptSourceJson } from './concept-source.js';
4
+ import { Pronunciation } from './pronunciation.js';
5
+ import type { PronunciationJson } from './pronunciation.js';
6
+ import { GrammarInfo } from './grammar-info.js';
7
+ import type { GrammarInfoJson } from './grammar-info.js';
8
+ import { RelatedConcept } from './related-concept.js';
9
+ import type { RelatedConceptJson } from './related-concept.js';
10
+ import {
11
+ DesignationRelationship,
12
+ DESIGNATION_RELATIONSHIP_TYPES,
13
+ } from './designation-relationship.js';
14
+ import type { DesignationRelationshipJson } from './designation-relationship.js';
15
+
16
+ export type DesignationType =
17
+ | 'expression'
18
+ | 'abbreviation'
19
+ | 'symbol'
20
+ | 'letter_symbol'
21
+ | 'graphical symbol'
22
+ | 'graphical_symbol'
23
+ | string;
24
+
25
+ export type NormativeStatus = 'preferred' | 'deprecated' | 'admitted' | 'deprecated_' | string | null;
26
+
27
+ const SKOS_LABEL_BY_NORMATIVE_STATUS: Readonly<Record<string, string>> = Object.freeze({
28
+ preferred: 'prefLabel',
29
+ deprecated: 'hiddenLabel',
30
+ admitted: 'altLabel',
31
+ deprecated_: 'altLabel',
32
+ });
33
+
34
+ export interface DesignationJson {
35
+ type?: DesignationType;
36
+ designation?: string;
37
+ normative_status?: NormativeStatus;
38
+ normativeStatus?: NormativeStatus;
39
+ absent?: boolean | null;
40
+ field_of_application?: string | null;
41
+ fieldOfApplication?: string | null;
42
+ usage_info?: string | null;
43
+ usageInfo?: string | null;
44
+ geographical_area?: string | null;
45
+ geographicalArea?: string | null;
46
+ language?: string | null;
47
+ script?: string | null;
48
+ system?: string | null;
49
+ international?: boolean | null;
50
+ term_type?: string | null;
51
+ termType?: string | null;
52
+ pronunciation?: ReadonlyArray<PronunciationJson | Pronunciation>;
53
+ sources?: ReadonlyArray<ConceptSourceJson | ConceptSource>;
54
+ related?: ReadonlyArray<DesignationRelationshipJson | DesignationRelationship | RelatedConceptJson | RelatedConcept>;
55
+ prefix?: string | null;
56
+ grammar_info?: ReadonlyArray<GrammarInfoJson | GrammarInfo>;
57
+ acronym?: boolean;
58
+ initialism?: boolean;
59
+ truncation?: boolean;
60
+ text?: string | null;
61
+ image?: string | null;
62
+ }
63
+
64
+ export class Designation extends RegistrableModel {
65
+ readonly designation: string;
66
+ readonly type: DesignationType;
67
+ readonly normativeStatus: NormativeStatus;
68
+ readonly absent: boolean | null;
69
+ readonly fieldOfApplication: string | null;
70
+ readonly usageInfo: string | null;
71
+ readonly geographicalArea: string | null;
72
+ readonly language: string | null;
73
+ readonly script: string | null;
74
+ readonly system: string | null;
75
+ readonly international: boolean | null;
76
+ readonly termType: string | null;
77
+ readonly pronunciations: ReadonlyArray<Pronunciation>;
78
+ readonly sources: ReadonlyArray<ConceptSource>;
79
+ readonly related: ReadonlyArray<DesignationRelationship | RelatedConcept>;
80
+
81
+ constructor(data: DesignationJson = {}) {
82
+ super();
83
+ this.designation = data.designation ?? '';
84
+ this.type = data.type ?? 'expression';
85
+ this.normativeStatus = data.normative_status ?? data.normativeStatus ?? null;
86
+ this.absent = data.absent ?? null;
87
+ this.fieldOfApplication = data.field_of_application ?? data.fieldOfApplication ?? null;
88
+ this.usageInfo = data.usage_info ?? data.usageInfo ?? null;
89
+ this.geographicalArea = data.geographical_area ?? data.geographicalArea ?? null;
90
+ this.language = data.language ?? null;
91
+ this.script = data.script ?? null;
92
+ this.system = data.system ?? null;
93
+ this.international = data.international ?? null;
94
+ this.termType = data.term_type ?? data.termType ?? null;
95
+ this.pronunciations = (data.pronunciation ?? []).map((p) =>
96
+ p instanceof Pronunciation ? p : new Pronunciation(p as PronunciationJson),
97
+ );
98
+ this.sources = (data.sources ?? []).map((s) =>
99
+ s instanceof ConceptSource ? s : new ConceptSource(s as ConceptSourceJson),
100
+ );
101
+ this.related = (data.related ?? []).map((r) => {
102
+ if (r instanceof DesignationRelationship || r instanceof RelatedConcept) return r;
103
+ const hash = r as { type?: string };
104
+ if (hash?.type && DESIGNATION_RELATIONSHIP_TYPES.includes(hash.type)) {
105
+ return DesignationRelationship.fromJSON(hash as DesignationRelationshipJson);
106
+ }
107
+ return RelatedConcept.fromJSON(hash as RelatedConceptJson);
108
+ });
109
+ }
110
+
111
+ override toJSON(): DesignationJson {
112
+ const obj: DesignationJson = { type: this.type, designation: this.designation };
113
+ if (this.normativeStatus != null) obj.normative_status = this.normativeStatus;
114
+ if (this.absent != null) obj.absent = this.absent;
115
+ if (this.fieldOfApplication != null) obj.field_of_application = this.fieldOfApplication;
116
+ if (this.usageInfo != null) obj.usage_info = this.usageInfo;
117
+ if (this.geographicalArea != null) obj.geographical_area = this.geographicalArea;
118
+ if (this.language != null) obj.language = this.language;
119
+ if (this.script != null) obj.script = this.script;
120
+ if (this.system != null) obj.system = this.system;
121
+ if (this.international != null) obj.international = this.international;
122
+ if (this.termType != null) obj.term_type = this.termType;
123
+ if (this.pronunciations.length > 0) {
124
+ obj.pronunciation = this.pronunciations.map((p) => p.toJSON());
125
+ }
126
+ if (this.sources.length > 0) {
127
+ obj.sources = this.sources.map((s) => s.toJSON());
128
+ }
129
+ if (this.related.length > 0) {
130
+ obj.related = this.related.map((r) => r.toJSON() as never);
131
+ }
132
+ return obj;
133
+ }
134
+
135
+ /** RDF class local-name for this designation subtype. */
136
+ rdfClass(): string {
137
+ return 'Expression';
138
+ }
139
+
140
+ /** SKOS label predicate URI appropriate for this designation's normative status. */
141
+ skosLabelPredicate(skosNs: string): string {
142
+ return `${skosNs}${this._skosLabelLocalName()}`;
143
+ }
144
+
145
+ /** SKOS-XL label predicate URI. */
146
+ skosxlLabelPredicate(skosxlNs: string): string {
147
+ return `${skosxlNs}${this._skosLabelLocalName()}`;
148
+ }
149
+
150
+ protected _skosLabelLocalName(): string {
151
+ const statusVal = this.normativeStatus == null ? '' : String(this.normativeStatus);
152
+ const status = statusVal
153
+ .split(/[/#]/)
154
+ .pop() ?? '';
155
+ return SKOS_LABEL_BY_NORMATIVE_STATUS[status] ?? 'altLabel';
156
+ }
157
+
158
+ static identityOf(value: { type?: string; designation?: string } | null | undefined): string {
159
+ const v = value ?? {};
160
+ const type = v.type ?? 'expression';
161
+ const text = String(v.designation ?? '').toLowerCase().trim();
162
+ return `${type}|${text}`;
163
+ }
164
+
165
+ override identity(): string {
166
+ return Designation.identityOf(this);
167
+ }
168
+
169
+ static override fromJSON(data: DesignationJson): Designation {
170
+ return Designation.fromData(data);
171
+ }
172
+ }
173
+
174
+ export class Expression extends Designation {
175
+ readonly prefix: string | null;
176
+ readonly grammarInfo: ReadonlyArray<GrammarInfo>;
177
+
178
+ constructor(data: DesignationJson = {}) {
179
+ super(data);
180
+ this.prefix = data.prefix ?? null;
181
+ this.grammarInfo = (data.grammar_info ?? []).map((g) =>
182
+ g instanceof GrammarInfo ? g : new GrammarInfo(g as GrammarInfoJson),
183
+ );
184
+ }
185
+
186
+ override toJSON(): DesignationJson {
187
+ const obj = super.toJSON();
188
+ if (this.prefix != null) obj.prefix = this.prefix;
189
+ if (this.grammarInfo.length > 0) {
190
+ obj.grammar_info = this.grammarInfo.map((g) => g.toJSON());
191
+ }
192
+ return obj;
193
+ }
194
+
195
+ static override fromJSON(data: DesignationJson): Expression {
196
+ return new Expression(data);
197
+ }
198
+ }
199
+
200
+ Designation.register('expression', Expression);
201
+
202
+ export class Abbreviation extends Expression {
203
+ readonly acronym: boolean;
204
+ readonly initialism: boolean;
205
+ readonly truncation: boolean;
206
+
207
+ constructor(data: DesignationJson = {}) {
208
+ super(data);
209
+ this.acronym = data.acronym ?? false;
210
+ this.initialism = data.initialism ?? false;
211
+ this.truncation = data.truncation ?? false;
212
+ }
213
+
214
+ override rdfClass(): string {
215
+ return 'Abbreviation';
216
+ }
217
+
218
+ override toJSON(): DesignationJson {
219
+ const obj = super.toJSON();
220
+ if (this.acronym) obj.acronym = true;
221
+ if (this.initialism) obj.initialism = true;
222
+ if (this.truncation) obj.truncation = true;
223
+ return obj;
224
+ }
225
+
226
+ static override fromJSON(data: DesignationJson): Abbreviation {
227
+ return new Abbreviation(data);
228
+ }
229
+ }
230
+
231
+ Designation.register('abbreviation', Abbreviation);
232
+
233
+ export class Symbol extends Designation {
234
+ override rdfClass(): string {
235
+ return 'Symbol';
236
+ }
237
+
238
+ static override fromJSON(data: DesignationJson): Symbol {
239
+ return new Symbol(data);
240
+ }
241
+ }
242
+
243
+ Designation.register('symbol', Symbol);
244
+
245
+ export class LetterSymbol extends Symbol {
246
+ readonly text: string | null;
247
+
248
+ constructor(data: DesignationJson = {}) {
249
+ super(data);
250
+ this.text = data.text ?? null;
251
+ }
252
+
253
+ override rdfClass(): string {
254
+ return 'LetterSymbol';
255
+ }
256
+
257
+ override toJSON(): DesignationJson {
258
+ const obj = super.toJSON();
259
+ if (this.text != null) obj.text = this.text;
260
+ return obj;
261
+ }
262
+
263
+ static override fromJSON(data: DesignationJson): LetterSymbol {
264
+ return new LetterSymbol(data);
265
+ }
266
+ }
267
+
268
+ Designation.register('letter_symbol', LetterSymbol);
269
+
270
+ export class GraphicalSymbol extends Symbol {
271
+ readonly text: string | null;
272
+ readonly image: string | null;
273
+
274
+ constructor(data: DesignationJson = {}) {
275
+ super(data);
276
+ this.text = data.text ?? null;
277
+ this.image = data.image ?? null;
278
+ }
279
+
280
+ override rdfClass(): string {
281
+ return 'GraphicalSymbol';
282
+ }
283
+
284
+ override toJSON(): DesignationJson {
285
+ const obj = super.toJSON();
286
+ if (this.text != null) obj.text = this.text;
287
+ if (this.image != null) obj.image = this.image;
288
+ return obj;
289
+ }
290
+
291
+ static override fromJSON(data: DesignationJson): GraphicalSymbol {
292
+ return new GraphicalSymbol(data);
293
+ }
294
+ }
295
+
296
+ Designation.register('graphical symbol', GraphicalSymbol);
297
+ Designation.register('graphical_symbol', GraphicalSymbol);
@@ -0,0 +1,118 @@
1
+ import * as yaml from 'js-yaml';
2
+ import { GlossaristModel } from './base.js';
3
+ import { GcrStatistics } from './gcr-statistics.js';
4
+ import type { GcrStatisticsJson } from './gcr-statistics.js';
5
+
6
+ export interface GcrMetadataJson {
7
+ shortname?: string | null;
8
+ version?: string | null;
9
+ title?: string | null;
10
+ description?: string | null;
11
+ owner?: string | null;
12
+ tags?: ReadonlyArray<string>;
13
+ concept_count?: number;
14
+ conceptCount?: number;
15
+ languages?: ReadonlyArray<string>;
16
+ created_at?: string | null;
17
+ createdAt?: string | null;
18
+ glossarist_version?: string | null;
19
+ glossaristVersion?: string | null;
20
+ schema_version?: string;
21
+ schemaVersion?: string;
22
+ homepage?: string | null;
23
+ repository?: string | null;
24
+ license?: string | null;
25
+ uri_prefix?: string | null;
26
+ uriPrefix?: string | null;
27
+ concept_uri_template?: string | null;
28
+ conceptUriTemplate?: string | null;
29
+ compiled_formats?: ReadonlyArray<string>;
30
+ compiledFormats?: ReadonlyArray<string>;
31
+ statistics?: GcrStatisticsJson | GcrStatistics | null;
32
+ }
33
+
34
+ export class GcrMetadata extends GlossaristModel {
35
+ readonly shortname: string | null;
36
+ readonly version: string | null;
37
+ readonly title: string | null;
38
+ readonly description: string | null;
39
+ readonly owner: string | null;
40
+ readonly tags: ReadonlyArray<string>;
41
+ readonly conceptCount: number;
42
+ readonly languages: ReadonlyArray<string>;
43
+ readonly createdAt: string | null;
44
+ readonly glossaristVersion: string | null;
45
+ readonly schemaVersion: string;
46
+ readonly homepage: string | null;
47
+ readonly repository: string | null;
48
+ readonly license: string | null;
49
+ readonly uriPrefix: string | null;
50
+ readonly conceptUriTemplate: string | null;
51
+ readonly compiledFormats: ReadonlyArray<string>;
52
+ readonly statistics: GcrStatistics | null;
53
+
54
+ constructor(data: GcrMetadataJson = {}) {
55
+ super();
56
+ this.shortname = data.shortname ?? null;
57
+ this.version = data.version ?? null;
58
+ this.title = data.title ?? null;
59
+ this.description = data.description ?? null;
60
+ this.owner = data.owner ?? null;
61
+ this.tags = (data.tags as ReadonlyArray<string>) ?? [];
62
+ this.conceptCount = data.concept_count ?? data.conceptCount ?? 0;
63
+ this.languages = (data.languages as ReadonlyArray<string>) ?? [];
64
+ this.createdAt = data.created_at ?? data.createdAt ?? null;
65
+ this.glossaristVersion = data.glossarist_version ?? data.glossaristVersion ?? null;
66
+ this.schemaVersion = data.schema_version ?? data.schemaVersion ?? '1';
67
+ this.homepage = data.homepage ?? null;
68
+ this.repository = data.repository ?? null;
69
+ this.license = data.license ?? null;
70
+ this.uriPrefix = data.uri_prefix ?? data.uriPrefix ?? null;
71
+ this.conceptUriTemplate = data.concept_uri_template ?? data.conceptUriTemplate ?? null;
72
+ this.compiledFormats = data.compiled_formats ?? data.compiledFormats ?? [];
73
+ this.statistics = data.statistics
74
+ ? data.statistics instanceof GcrStatistics
75
+ ? data.statistics
76
+ : new GcrStatistics(data.statistics as GcrStatisticsJson)
77
+ : null;
78
+ }
79
+
80
+ get concept_count(): number { return this.conceptCount; }
81
+ get created_at(): string | null { return this.createdAt; }
82
+ get glossarist_version(): string | null { return this.glossaristVersion; }
83
+ get schema_version(): string { return this.schemaVersion; }
84
+ get uri_prefix(): string | null { return this.uriPrefix; }
85
+ get concept_uri_template(): string | null { return this.conceptUriTemplate; }
86
+ get compiled_formats(): ReadonlyArray<string> { return this.compiledFormats; }
87
+
88
+ override toJSON(): GcrMetadataJson {
89
+ const obj: GcrMetadataJson = {};
90
+ if (this.shortname != null) obj.shortname = this.shortname;
91
+ if (this.version != null) obj.version = this.version;
92
+ if (this.title != null) obj.title = this.title;
93
+ if (this.description != null) obj.description = this.description;
94
+ if (this.owner != null) obj.owner = this.owner;
95
+ if (this.tags.length > 0) obj.tags = [...this.tags];
96
+ if (this.conceptCount > 0) obj.concept_count = this.conceptCount;
97
+ if (this.languages.length > 0) obj.languages = [...this.languages];
98
+ if (this.createdAt != null) obj.created_at = this.createdAt;
99
+ if (this.glossaristVersion != null) obj.glossarist_version = this.glossaristVersion;
100
+ if (this.schemaVersion != null) obj.schema_version = this.schemaVersion;
101
+ if (this.homepage != null) obj.homepage = this.homepage;
102
+ if (this.repository != null) obj.repository = this.repository;
103
+ if (this.license != null) obj.license = this.license;
104
+ if (this.uriPrefix != null) obj.uri_prefix = this.uriPrefix;
105
+ if (this.conceptUriTemplate != null) obj.concept_uri_template = this.conceptUriTemplate;
106
+ if (this.compiledFormats.length > 0) obj.compiled_formats = [...this.compiledFormats];
107
+ if (this.statistics != null) obj.statistics = this.statistics.toJSON();
108
+ return obj;
109
+ }
110
+
111
+ static override fromJSON(data: GcrMetadataJson): GcrMetadata {
112
+ return new GcrMetadata(data);
113
+ }
114
+
115
+ static fromYaml(yamlString: string): GcrMetadata {
116
+ return new GcrMetadata(yaml.load(yamlString) as GcrMetadataJson);
117
+ }
118
+ }
@@ -0,0 +1,76 @@
1
+ import { GlossaristModel } from './base.js';
2
+
3
+ export interface GcrStatisticsJson {
4
+ total_concepts?: number;
5
+ totalConcepts?: number;
6
+ concepts_with_definitions?: number;
7
+ conceptsWithDefinitions?: number;
8
+ concepts_by_status?: Record<string, number>;
9
+ conceptsByStatus?: Record<string, number>;
10
+ }
11
+
12
+ interface ConceptLike {
13
+ languages: ReadonlyArray<string>;
14
+ localization(lang: string): {
15
+ definitions: ReadonlyArray<unknown>;
16
+ entryStatus?: string | null;
17
+ } | null | undefined;
18
+ }
19
+
20
+ export class GcrStatistics extends GlossaristModel {
21
+ readonly totalConcepts: number;
22
+ readonly conceptsWithDefinitions: number;
23
+ readonly conceptsByStatus: Record<string, number>;
24
+
25
+ constructor(data: GcrStatisticsJson = {}) {
26
+ super();
27
+ this.totalConcepts = data.total_concepts ?? data.totalConcepts ?? 0;
28
+ this.conceptsWithDefinitions =
29
+ data.concepts_with_definitions ?? data.conceptsWithDefinitions ?? 0;
30
+ this.conceptsByStatus =
31
+ (data.concepts_by_status as Record<string, number>) ??
32
+ (data.conceptsByStatus as Record<string, number>) ??
33
+ {};
34
+ }
35
+
36
+ get total_concepts(): number { return this.totalConcepts; }
37
+ get concepts_with_definitions(): number { return this.conceptsWithDefinitions; }
38
+ get concepts_by_status(): Record<string, number> { return this.conceptsByStatus; }
39
+
40
+ override toJSON(): GcrStatisticsJson {
41
+ const obj: GcrStatisticsJson = { total_concepts: this.totalConcepts };
42
+ if (this.conceptsWithDefinitions > 0) {
43
+ obj.concepts_with_definitions = this.conceptsWithDefinitions;
44
+ }
45
+ if (Object.keys(this.conceptsByStatus).length > 0) {
46
+ obj.concepts_by_status = this.conceptsByStatus;
47
+ }
48
+ return obj;
49
+ }
50
+
51
+ static override fromJSON(data: GcrStatisticsJson): GcrStatistics {
52
+ return new GcrStatistics(data);
53
+ }
54
+
55
+ static fromConcepts(concepts: ReadonlyArray<ConceptLike>): GcrStatistics {
56
+ const langs = new Set<string>();
57
+ let withDefs = 0;
58
+ const byStatus: Record<string, number> = {};
59
+
60
+ for (const concept of concepts) {
61
+ for (const lang of concept.languages) {
62
+ langs.add(lang);
63
+ const lc = concept.localization(lang);
64
+ if (lc && lc.definitions.length > 0) withDefs++;
65
+ const status = lc?.entryStatus ?? 'unknown';
66
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
67
+ }
68
+ }
69
+
70
+ return new GcrStatistics({
71
+ total_concepts: concepts.length,
72
+ concepts_with_definitions: withDefs,
73
+ concepts_by_status: byStatus,
74
+ });
75
+ }
76
+ }
@@ -0,0 +1,87 @@
1
+ // Hyperedge color resolver — resolves display colors for a
2
+ // PartitiveHyperedge based on its enumeration and the carrying
3
+ // concept's category color.
4
+
5
+ import { categoryColorPair } from './relation-colors.js';
6
+ import type { ColorPair } from './relation-colors.js';
7
+
8
+ const ENUMERATION_INTENSITY: Readonly<Record<string, number>> = Object.freeze({
9
+ closed: 1.0,
10
+ open: 0.55,
11
+ });
12
+
13
+ const DEFAULT_CATEGORY = 'hierarchical';
14
+
15
+ export interface ResolveHyperedgeColorOptions {
16
+ category?: string;
17
+ overrides?: { byCategory?: Record<string, Partial<ColorPair>> };
18
+ }
19
+
20
+ export interface ResolvedHyperedgeColor {
21
+ light: string;
22
+ dark: string;
23
+ pattern: 'dashed' | 'double' | null;
24
+ }
25
+
26
+ interface HyperedgeLike {
27
+ enumeration?: string;
28
+ markers?: ReadonlyArray<string>;
29
+ }
30
+
31
+ export function resolveHyperedgeColor(
32
+ hyperedge: HyperedgeLike | null,
33
+ options: ResolveHyperedgeColorOptions = {},
34
+ ): ResolvedHyperedgeColor | null {
35
+ if (!hyperedge) return null;
36
+
37
+ const category = options.category ?? DEFAULT_CATEGORY;
38
+ const base = categoryColorPair(category, options.overrides);
39
+ if (!base) return null;
40
+
41
+ const intensity = ENUMERATION_INTENSITY[hyperedge.enumeration ?? ''] ?? 1.0;
42
+ const pattern = derivePattern(hyperedge.markers);
43
+
44
+ return {
45
+ light: tint(base.light, intensity),
46
+ dark: tint(base.dark, intensity),
47
+ pattern,
48
+ };
49
+ }
50
+
51
+ function derivePattern(
52
+ markers: ReadonlyArray<string> | null | undefined,
53
+ ): 'dashed' | 'double' | null {
54
+ const arr = markers ?? [];
55
+ if (arr.includes('dashed')) return 'dashed';
56
+ if (arr.includes('double')) return 'double';
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Linear interpolation between the base color and white (light mode)
62
+ * or between the base and a neutral (dark mode). Pure-string inputs
63
+ * return the original color when intensity === 1.0.
64
+ */
65
+ function tint(hex: string, ratio: number): string {
66
+ if (ratio >= 1.0) return hex;
67
+ const rgb = parseHex(hex);
68
+ if (!rgb) return hex;
69
+
70
+ const target: ReadonlyArray<number> = [255, 255, 255];
71
+ const mixed = rgb.map((c, i) =>
72
+ Math.round(c + ((target[i] ?? 255) - c) * (1 - ratio)),
73
+ );
74
+ return '#' + mixed.map((c) => c.toString(16).padStart(2, '0')).join('');
75
+ }
76
+
77
+ function parseHex(s: string): [number, number, number] | null {
78
+ if (typeof s !== 'string') return null;
79
+ const m = s.match(/^#?([0-9a-f]{6})$/i);
80
+ if (!m) return null;
81
+ const n = m[1] as string;
82
+ return [
83
+ parseInt(n.slice(0, 2), 16),
84
+ parseInt(n.slice(2, 4), 16),
85
+ parseInt(n.slice(4, 6), 16),
86
+ ];
87
+ }