@chartcoach/catalog 0.1.3
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 +71 -0
- package/dist/catalog/artifacts.d.ts +27 -0
- package/dist/catalog/artifacts.d.ts.map +1 -0
- package/dist/catalog/artifacts.js +90 -0
- package/dist/catalog/errors.d.ts +4 -0
- package/dist/catalog/errors.d.ts.map +1 -0
- package/dist/catalog/errors.js +6 -0
- package/dist/catalog/labels.d.ts +9 -0
- package/dist/catalog/labels.d.ts.map +1 -0
- package/dist/catalog/labels.js +23 -0
- package/dist/catalog/load-parquet-core.d.ts +15 -0
- package/dist/catalog/load-parquet-core.d.ts.map +1 -0
- package/dist/catalog/load-parquet-core.js +47 -0
- package/dist/catalog/manifest.d.ts +15 -0
- package/dist/catalog/manifest.d.ts.map +1 -0
- package/dist/catalog/manifest.js +144 -0
- package/dist/catalog/markdown.d.ts +3 -0
- package/dist/catalog/markdown.d.ts.map +1 -0
- package/dist/catalog/markdown.js +13 -0
- package/dist/catalog/model.d.ts +33 -0
- package/dist/catalog/model.d.ts.map +1 -0
- package/dist/catalog/model.js +67 -0
- package/dist/catalog/wire.d.ts +18 -0
- package/dist/catalog/wire.d.ts.map +1 -0
- package/dist/catalog/wire.js +78 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/package.json +49 -0
- package/src/catalog/artifacts.ts +127 -0
- package/src/catalog/errors.ts +6 -0
- package/src/catalog/labels.ts +32 -0
- package/src/catalog/load-parquet-core.ts +81 -0
- package/src/catalog/manifest.ts +186 -0
- package/src/catalog/markdown.ts +22 -0
- package/src/catalog/model.ts +105 -0
- package/src/catalog/wire.ts +93 -0
- package/src/index.ts +25 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CatalogError } from "./errors.js";
|
|
2
|
+
import { normalizeLabel } from "./labels.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
5
|
+
}
|
|
6
|
+
function isStringArray(value) {
|
|
7
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
8
|
+
}
|
|
9
|
+
function isGuidelineSection(value) {
|
|
10
|
+
return (isRecord(value) &&
|
|
11
|
+
typeof value.role === "string" &&
|
|
12
|
+
value.role.trim().length > 0 &&
|
|
13
|
+
typeof value.title === "string" &&
|
|
14
|
+
typeof value.content === "string");
|
|
15
|
+
}
|
|
16
|
+
function isSectionArray(value) {
|
|
17
|
+
return Array.isArray(value) && value.every(isGuidelineSection);
|
|
18
|
+
}
|
|
19
|
+
function sectionsFromWire(value) {
|
|
20
|
+
return value.map((section) => ({
|
|
21
|
+
role: section.role.trim(),
|
|
22
|
+
title: section.title,
|
|
23
|
+
content: section.content,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function isCatalogRowWire(value) {
|
|
27
|
+
if (!isRecord(value))
|
|
28
|
+
return false;
|
|
29
|
+
if (typeof value.id !== "string" || value.id.length === 0)
|
|
30
|
+
return false;
|
|
31
|
+
if (!isRecord(value.guideline))
|
|
32
|
+
return false;
|
|
33
|
+
if (value.guideline.id !== value.id)
|
|
34
|
+
return false;
|
|
35
|
+
if (typeof value.guideline.title !== "string")
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof value.guideline.description !== "string")
|
|
38
|
+
return false;
|
|
39
|
+
if (typeof value.guideline.body !== "string")
|
|
40
|
+
return false;
|
|
41
|
+
if (!isStringArray(value.guideline.labels))
|
|
42
|
+
return false;
|
|
43
|
+
if (!isSectionArray(value.guideline.sections))
|
|
44
|
+
return false;
|
|
45
|
+
if (!isStringArray(value.references))
|
|
46
|
+
return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
export function guidelineFromWire(value) {
|
|
50
|
+
if (!isCatalogRowWire(value))
|
|
51
|
+
return null;
|
|
52
|
+
const { guideline, references } = value;
|
|
53
|
+
const sections = sectionsFromWire(guideline.sections);
|
|
54
|
+
let labels;
|
|
55
|
+
try {
|
|
56
|
+
labels = guideline.labels.map((label) => normalizeLabel(label, "guideline label"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
id: guideline.id,
|
|
63
|
+
title: guideline.title,
|
|
64
|
+
bibliography: typeof guideline.bibliography === "string" ? guideline.bibliography : undefined,
|
|
65
|
+
description: guideline.description,
|
|
66
|
+
labels,
|
|
67
|
+
body: guideline.body,
|
|
68
|
+
sections,
|
|
69
|
+
references: [...references],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function requireGuidelineFromWire(value, context) {
|
|
73
|
+
const guideline = guidelineFromWire(value);
|
|
74
|
+
if (guideline)
|
|
75
|
+
return guideline;
|
|
76
|
+
const suffix = context ? ` (${context})` : "";
|
|
77
|
+
throw new CatalogError(`Invalid catalog row format${suffix}.`);
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Catalog, type CatalogOptions, type Guideline, type GuidelineSection } from "./catalog/model.js";
|
|
2
|
+
export { CatalogError } from "./catalog/errors.js";
|
|
3
|
+
export { DEFAULT_CATALOG, catalogArtifact, catalogArtifactUrl, parseCatalogReleaseMetadata, type ArtifactDescriptor, type ArtifactKind, type CatalogReleaseMetadata, } from "./catalog/artifacts.js";
|
|
4
|
+
export { loadCatalog, type AsyncBuffer, type CatalogBytes, type LoadCatalogInput, type ParquetBytes, } from "./catalog/load-parquet-core.js";
|
|
5
|
+
export { type CatalogManifest, type ManifestDefinition, parseCatalogManifest, } from "./catalog/manifest.js";
|
|
6
|
+
export { parseLabel, type CatalogLabel } from "./catalog/labels.js";
|
|
7
|
+
export { toMarkdown } from "./catalog/markdown.js";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACtG,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EACL,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,2BAA2B,EAC3B,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,WAAW,EACX,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,KAAK,YAAY,GAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Catalog } from "./catalog/model.js";
|
|
2
|
+
export { CatalogError } from "./catalog/errors.js";
|
|
3
|
+
export { DEFAULT_CATALOG, catalogArtifact, catalogArtifactUrl, parseCatalogReleaseMetadata, } from "./catalog/artifacts.js";
|
|
4
|
+
export { loadCatalog, } from "./catalog/load-parquet-core.js";
|
|
5
|
+
export { parseCatalogManifest, } from "./catalog/manifest.js";
|
|
6
|
+
export { parseLabel } from "./catalog/labels.js";
|
|
7
|
+
export { toMarkdown } from "./catalog/markdown.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chartcoach/catalog",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "JavaScript models and artifact parsers for the chartcoach visualization guideline catalog.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Péter Ferenc Gyarmati <dev.petergy@gmail.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chartcoach/chartcoach.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/chartcoach/chartcoach/issues"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"source": "./src/index.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -b tsconfig.build.json --force && tsc-alias -p tsconfig.build.json --resolve-full-paths --resolve-full-extension .js",
|
|
33
|
+
"lint": "oxlint . && pnpm run lint:imports",
|
|
34
|
+
"lint:imports": "node scripts/check-extensionless-imports.mjs",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"hyparquet": "^1.26.1",
|
|
40
|
+
"hyparquet-compressors": "^1.1.1",
|
|
41
|
+
"yaml": "^2.9.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.19.21",
|
|
45
|
+
"tsc-alias": "^1.8.17",
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"vitest": "^3.2.6"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CatalogError } from "./errors";
|
|
2
|
+
|
|
3
|
+
const defaultCatalogArtifactBaseUrl = "https://artifacts.chartcoach.dev";
|
|
4
|
+
const defaultCatalogDigest =
|
|
5
|
+
"7cfd43ee820be252b8ae9058c4c36109a9c8415c6b3a5ff8a9127117b4a10c19";
|
|
6
|
+
const defaultCatalogVersion = "0.1.3";
|
|
7
|
+
const defaultCatalogReleaseRootUrl = new URL(
|
|
8
|
+
`catalog/releases/${defaultCatalogVersion}/${defaultCatalogDigest}/`,
|
|
9
|
+
`${defaultCatalogArtifactBaseUrl}/`,
|
|
10
|
+
).toString();
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_CATALOG = {
|
|
13
|
+
version: defaultCatalogVersion,
|
|
14
|
+
digest: defaultCatalogDigest,
|
|
15
|
+
releaseRootUrl: defaultCatalogReleaseRootUrl,
|
|
16
|
+
metadataUrl: new URL("metadata.json", defaultCatalogReleaseRootUrl).toString(),
|
|
17
|
+
entriesUrl: new URL("entries.parquet", defaultCatalogReleaseRootUrl).toString(),
|
|
18
|
+
manifestUrl: new URL("MANIFEST.md", defaultCatalogReleaseRootUrl).toString(),
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type ArtifactKind = "manifest" | "entries" | "lancedb-index";
|
|
22
|
+
|
|
23
|
+
export type ArtifactDescriptor = {
|
|
24
|
+
kind: ArtifactKind;
|
|
25
|
+
path: string;
|
|
26
|
+
digest: string;
|
|
27
|
+
bytes: number;
|
|
28
|
+
format?: string;
|
|
29
|
+
rows?: number;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type CatalogReleaseMetadata = {
|
|
34
|
+
version: string;
|
|
35
|
+
digest: string;
|
|
36
|
+
artifacts: ArtifactDescriptor[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function parseCatalogReleaseMetadata(value: unknown): CatalogReleaseMetadata {
|
|
40
|
+
if (!isRecord(value)) {
|
|
41
|
+
throw new CatalogError("Catalog release metadata must be an object.");
|
|
42
|
+
}
|
|
43
|
+
const artifacts = value.artifacts;
|
|
44
|
+
if (!Array.isArray(artifacts)) {
|
|
45
|
+
throw new CatalogError("Catalog release metadata artifacts must be a list.");
|
|
46
|
+
}
|
|
47
|
+
const metadata = {
|
|
48
|
+
version: requiredString(value, "version"),
|
|
49
|
+
digest: requiredString(value, "digest"),
|
|
50
|
+
artifacts: artifacts.map(parseArtifactDescriptor),
|
|
51
|
+
};
|
|
52
|
+
catalogArtifact(metadata, "manifest");
|
|
53
|
+
catalogArtifact(metadata, "entries");
|
|
54
|
+
return metadata;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function catalogArtifactUrl(
|
|
58
|
+
baseUrl: string | URL,
|
|
59
|
+
descriptor: ArtifactDescriptor,
|
|
60
|
+
): string {
|
|
61
|
+
return new URL(descriptor.path, baseUrl).toString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function catalogArtifact(
|
|
65
|
+
metadata: CatalogReleaseMetadata,
|
|
66
|
+
kind: ArtifactKind,
|
|
67
|
+
): ArtifactDescriptor {
|
|
68
|
+
const descriptor = metadata.artifacts.find((item) => item.kind === kind);
|
|
69
|
+
if (!descriptor) {
|
|
70
|
+
throw new CatalogError(`Catalog release metadata is missing a ${kind} artifact.`);
|
|
71
|
+
}
|
|
72
|
+
return descriptor;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseArtifactDescriptor(value: unknown): ArtifactDescriptor {
|
|
76
|
+
if (!isRecord(value)) {
|
|
77
|
+
throw new CatalogError("Catalog artifact descriptor must be an object.");
|
|
78
|
+
}
|
|
79
|
+
const kind = value.kind;
|
|
80
|
+
if (kind !== "manifest" && kind !== "entries" && kind !== "lancedb-index") {
|
|
81
|
+
throw new CatalogError(`Unsupported catalog artifact kind: ${String(kind)}`);
|
|
82
|
+
}
|
|
83
|
+
const path = requiredString(value, "path");
|
|
84
|
+
validateRelativePath(path);
|
|
85
|
+
const descriptor: ArtifactDescriptor = {
|
|
86
|
+
kind,
|
|
87
|
+
path,
|
|
88
|
+
digest: requiredString(value, "digest"),
|
|
89
|
+
bytes: requiredInteger(value, "bytes"),
|
|
90
|
+
};
|
|
91
|
+
if (typeof value.format === "string") descriptor.format = value.format;
|
|
92
|
+
if (typeof value.rows === "number" && Number.isInteger(value.rows)) {
|
|
93
|
+
descriptor.rows = value.rows;
|
|
94
|
+
}
|
|
95
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
96
|
+
if (!["kind", "path", "digest", "bytes", "format", "rows"].includes(key)) {
|
|
97
|
+
descriptor[key] = rawValue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return descriptor;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateRelativePath(path: string): void {
|
|
104
|
+
if (path.startsWith("/") || path.split("/").includes("..")) {
|
|
105
|
+
throw new CatalogError(`Catalog artifact path must be relative: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requiredString(value: Record<string, unknown>, key: string): string {
|
|
110
|
+
const raw = value[key];
|
|
111
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
112
|
+
throw new CatalogError(`Catalog release metadata ${key} must be a string.`);
|
|
113
|
+
}
|
|
114
|
+
return raw;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function requiredInteger(value: Record<string, unknown>, key: string): number {
|
|
118
|
+
const raw = value[key];
|
|
119
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
120
|
+
throw new CatalogError(`Catalog release metadata ${key} must be a non-negative integer.`);
|
|
121
|
+
}
|
|
122
|
+
return raw;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
126
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
127
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CatalogError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export type CatalogLabel = {
|
|
4
|
+
value: string;
|
|
5
|
+
family: string;
|
|
6
|
+
category: string;
|
|
7
|
+
modifier?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseLabel(value: unknown, context = "label"): CatalogLabel {
|
|
11
|
+
if (typeof value !== "string") {
|
|
12
|
+
throw new CatalogError(`${context} must be a string.`);
|
|
13
|
+
}
|
|
14
|
+
const parts = value
|
|
15
|
+
.trim()
|
|
16
|
+
.split(":")
|
|
17
|
+
.map((part) => part.trim());
|
|
18
|
+
if ((parts.length !== 2 && parts.length !== 3) || parts.some((part) => part.length === 0)) {
|
|
19
|
+
throw new CatalogError(`${context} must use <family>:<category> or <family>:<category>:<modifier>.`);
|
|
20
|
+
}
|
|
21
|
+
const [family, category, modifier] = parts as [string, string, string | undefined];
|
|
22
|
+
return {
|
|
23
|
+
value: modifier === undefined ? `${family}:${category}` : `${family}:${category}:${modifier}`,
|
|
24
|
+
family,
|
|
25
|
+
category,
|
|
26
|
+
modifier,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeLabel(value: unknown, context = "label"): string {
|
|
31
|
+
return parseLabel(value, context).value;
|
|
32
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parquetReadObjects } from "hyparquet";
|
|
2
|
+
import { compressors } from "hyparquet-compressors";
|
|
3
|
+
import { Catalog, type Guideline } from "./model";
|
|
4
|
+
import { CatalogError } from "./errors";
|
|
5
|
+
import type { CatalogManifest } from "./manifest";
|
|
6
|
+
import { parseCatalogManifest } from "./manifest";
|
|
7
|
+
import { requireGuidelineFromWire } from "./wire";
|
|
8
|
+
|
|
9
|
+
export type AsyncBuffer = {
|
|
10
|
+
byteLength: number;
|
|
11
|
+
slice(start: number, end?: number): ArrayBuffer | Promise<ArrayBuffer>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ParquetBytes = ArrayBuffer | ArrayBufferView;
|
|
15
|
+
export type CatalogBytes = ParquetBytes | AsyncBuffer;
|
|
16
|
+
export type LoadCatalogInput = CatalogBytes | {
|
|
17
|
+
entries: CatalogBytes;
|
|
18
|
+
manifest?: CatalogManifest;
|
|
19
|
+
manifestText?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function normalizeParquetBytes(bytes: ParquetBytes): ArrayBuffer {
|
|
23
|
+
if (bytes instanceof ArrayBuffer) return bytes;
|
|
24
|
+
|
|
25
|
+
// TypedArray/DataView may be a view into a larger ArrayBuffer (or SharedArrayBuffer).
|
|
26
|
+
// Copy to a standalone ArrayBuffer covering exactly the view range.
|
|
27
|
+
const u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
28
|
+
return u8.slice().buffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadCatalog(
|
|
32
|
+
input: LoadCatalogInput,
|
|
33
|
+
): Promise<Catalog> {
|
|
34
|
+
const { entries, manifest } = resolveLoadCatalogInput(input);
|
|
35
|
+
const normalizedFile =
|
|
36
|
+
entries instanceof ArrayBuffer || ArrayBuffer.isView(entries)
|
|
37
|
+
? normalizeParquetBytes(entries as ParquetBytes)
|
|
38
|
+
: entries;
|
|
39
|
+
|
|
40
|
+
const rows = (await parquetReadObjects({
|
|
41
|
+
file: normalizedFile,
|
|
42
|
+
compressors,
|
|
43
|
+
})) as Array<Record<string, unknown>>;
|
|
44
|
+
|
|
45
|
+
const guidelines: Guideline[] = rows.map((row, index) =>
|
|
46
|
+
requireGuidelineFromWire(row, `parquet row ${index}`),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return new Catalog(guidelines, { manifest });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveLoadCatalogInput(input: LoadCatalogInput): {
|
|
53
|
+
entries: CatalogBytes;
|
|
54
|
+
manifest?: CatalogManifest;
|
|
55
|
+
} {
|
|
56
|
+
if (isCatalogBytes(input)) return { entries: input };
|
|
57
|
+
|
|
58
|
+
if (input.manifest && input.manifestText !== undefined) {
|
|
59
|
+
throw new CatalogError("Pass manifest or manifestText, not both.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
entries: input.entries,
|
|
64
|
+
manifest: input.manifestText === undefined
|
|
65
|
+
? input.manifest
|
|
66
|
+
: parseCatalogManifest(input.manifestText),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isCatalogBytes(value: LoadCatalogInput): value is CatalogBytes {
|
|
71
|
+
return (
|
|
72
|
+
value instanceof ArrayBuffer ||
|
|
73
|
+
ArrayBuffer.isView(value) ||
|
|
74
|
+
(
|
|
75
|
+
typeof value === "object" &&
|
|
76
|
+
value !== null &&
|
|
77
|
+
typeof (value as AsyncBuffer).byteLength === "number" &&
|
|
78
|
+
typeof (value as AsyncBuffer).slice === "function"
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { CatalogError } from "./errors";
|
|
2
|
+
import { parseLabel } from "./labels";
|
|
3
|
+
import type { Guideline } from "./model";
|
|
4
|
+
|
|
5
|
+
export const REQUIRED_MANIFEST_HEADINGS = ["Section Roles", "Label Families"] as const;
|
|
6
|
+
|
|
7
|
+
export type ManifestDefinition = {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
examples: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CatalogManifest = {
|
|
14
|
+
markdown: string;
|
|
15
|
+
sectionRoles: Record<string, ManifestDefinition>;
|
|
16
|
+
labelFamilies: Record<string, ManifestDefinition>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const headingPattern = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
|
|
20
|
+
const codeSpanPattern = /`([^`\n]+)`/g;
|
|
21
|
+
|
|
22
|
+
export function parseCatalogManifest(markdown: string): CatalogManifest {
|
|
23
|
+
const requiredSeen = new Set<string>();
|
|
24
|
+
const definitions: {
|
|
25
|
+
"Section Roles": Record<string, ManifestDefinition>;
|
|
26
|
+
"Label Families": Record<string, ManifestDefinition>;
|
|
27
|
+
} = {
|
|
28
|
+
"Section Roles": {},
|
|
29
|
+
"Label Families": {},
|
|
30
|
+
};
|
|
31
|
+
let currentHeading: keyof typeof definitions | string | undefined;
|
|
32
|
+
let currentName: string | undefined;
|
|
33
|
+
let currentLines: string[] = [];
|
|
34
|
+
|
|
35
|
+
function flushDefinition() {
|
|
36
|
+
if (
|
|
37
|
+
currentHeading !== "Section Roles" &&
|
|
38
|
+
currentHeading !== "Label Families"
|
|
39
|
+
) {
|
|
40
|
+
currentLines = [];
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (currentName === undefined) {
|
|
44
|
+
currentLines = [];
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const description = currentLines.join("\n").trim();
|
|
49
|
+
if (!description) {
|
|
50
|
+
throw new CatalogError(
|
|
51
|
+
`Manifest definition ${currentHeading}/${currentName} must include prose.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
definitions[currentHeading][currentName] = {
|
|
55
|
+
name: currentName,
|
|
56
|
+
description,
|
|
57
|
+
examples: Array.from(description.matchAll(codeSpanPattern), (match) => match[1] ?? ""),
|
|
58
|
+
};
|
|
59
|
+
currentName = undefined;
|
|
60
|
+
currentLines = [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const line of markdown.split("\n")) {
|
|
64
|
+
const match = headingPattern.exec(line);
|
|
65
|
+
if (!match) {
|
|
66
|
+
if (currentName !== undefined) currentLines.push(line);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const level = match[1]!.length;
|
|
71
|
+
const title = match[2]!.trim();
|
|
72
|
+
|
|
73
|
+
if (level === 2) {
|
|
74
|
+
flushDefinition();
|
|
75
|
+
currentHeading = title;
|
|
76
|
+
currentName = undefined;
|
|
77
|
+
currentLines = [];
|
|
78
|
+
if (title === "Section Roles" || title === "Label Families") {
|
|
79
|
+
requiredSeen.add(title);
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
level === 3 &&
|
|
86
|
+
(currentHeading === "Section Roles" || currentHeading === "Label Families")
|
|
87
|
+
) {
|
|
88
|
+
flushDefinition();
|
|
89
|
+
if (!title) {
|
|
90
|
+
throw new CatalogError(
|
|
91
|
+
`Manifest heading ${currentHeading} contains an empty subheading.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
currentName = title;
|
|
95
|
+
currentLines = [];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (currentName !== undefined) currentLines.push(line);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
flushDefinition();
|
|
103
|
+
|
|
104
|
+
const missing = REQUIRED_MANIFEST_HEADINGS.filter((heading) => !requiredSeen.has(heading));
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
throw new CatalogError(`MANIFEST.md is missing required heading(s): ${missing.join(", ")}.`);
|
|
107
|
+
}
|
|
108
|
+
for (const heading of REQUIRED_MANIFEST_HEADINGS) {
|
|
109
|
+
if (Object.keys(definitions[heading]).length === 0) {
|
|
110
|
+
throw new CatalogError(`Manifest heading ${heading} must define entries.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
validateLabelFamilyExamples(Object.values(definitions["Label Families"]));
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
markdown: markdown.endsWith("\n") ? markdown : `${markdown}\n`,
|
|
117
|
+
sectionRoles: definitions["Section Roles"],
|
|
118
|
+
labelFamilies: definitions["Label Families"],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function validateManifestCoverage(guidelines: readonly Guideline[], manifest: CatalogManifest) {
|
|
123
|
+
const usedRoles = new Set<string>();
|
|
124
|
+
const usedFamilies = new Set<string>();
|
|
125
|
+
|
|
126
|
+
for (const guideline of guidelines) {
|
|
127
|
+
for (const section of guideline.sections) {
|
|
128
|
+
const role = section.role.trim();
|
|
129
|
+
if (!role) {
|
|
130
|
+
throw new CatalogError("Section role values must not be empty.");
|
|
131
|
+
}
|
|
132
|
+
usedRoles.add(role);
|
|
133
|
+
}
|
|
134
|
+
for (const label of guideline.labels) {
|
|
135
|
+
usedFamilies.add(parseLabel(label, `label ${JSON.stringify(label)}`).family);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const missingRoles = Array.from(usedRoles)
|
|
140
|
+
.filter((role) => manifest.sectionRoles[role] === undefined)
|
|
141
|
+
.sort();
|
|
142
|
+
const missingFamilies = Array.from(usedFamilies)
|
|
143
|
+
.filter((family) => manifest.labelFamilies[family] === undefined)
|
|
144
|
+
.sort();
|
|
145
|
+
|
|
146
|
+
const errors: string[] = [];
|
|
147
|
+
if (missingRoles.length > 0) {
|
|
148
|
+
errors.push(`undefined section role(s): ${missingRoles.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
if (missingFamilies.length > 0) {
|
|
151
|
+
errors.push(`undefined label family/families: ${missingFamilies.join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
if (errors.length > 0) {
|
|
154
|
+
throw new CatalogError(`Catalog manifest validation failed: ${errors.join("; ")}.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateLabelFamilyExamples(definitions: ManifestDefinition[]) {
|
|
159
|
+
for (const definition of definitions) {
|
|
160
|
+
const familyExamples: string[] = [];
|
|
161
|
+
const invalidExamples: string[] = [];
|
|
162
|
+
for (const example of definition.examples) {
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
parsed = parseLabel(example, `manifest label example ${JSON.stringify(example)}`);
|
|
166
|
+
} catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (parsed.family === definition.name) {
|
|
170
|
+
familyExamples.push(parsed.value);
|
|
171
|
+
} else if (example.includes(":")) {
|
|
172
|
+
invalidExamples.push(example);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (invalidExamples.length > 0) {
|
|
176
|
+
throw new CatalogError(
|
|
177
|
+
`Label family ${definition.name} has example(s) from another family: ${invalidExamples.join(", ")}.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (familyExamples.length === 0) {
|
|
181
|
+
throw new CatalogError(
|
|
182
|
+
`Label family ${definition.name} must include at least one label example.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
2
|
+
|
|
3
|
+
import type { Guideline } from "./model";
|
|
4
|
+
|
|
5
|
+
export function toMarkdown(
|
|
6
|
+
guideline: Pick<
|
|
7
|
+
Guideline,
|
|
8
|
+
"id" | "title" | "bibliography" | "description" | "labels" | "body"
|
|
9
|
+
>,
|
|
10
|
+
): string {
|
|
11
|
+
const frontmatter = {
|
|
12
|
+
id: guideline.id,
|
|
13
|
+
title: guideline.title,
|
|
14
|
+
...(guideline.bibliography ? { bibliography: guideline.bibliography } : {}),
|
|
15
|
+
description: guideline.description,
|
|
16
|
+
labels: [...guideline.labels],
|
|
17
|
+
};
|
|
18
|
+
const frontmatterYaml = stringifyYaml(frontmatter, { sortMapEntries: false }).trim();
|
|
19
|
+
const body = guideline.body.trim();
|
|
20
|
+
|
|
21
|
+
return ["---", frontmatterYaml, "---", "", body, ""].join("\n");
|
|
22
|
+
}
|