refactor(model): extract dashboard model package

This commit is contained in:
vince 2026-06-20 06:26:19 +02:00
parent 22f50d64f9
commit 7a6ad8ab0e
35 changed files with 164 additions and 57 deletions

View file

@ -0,0 +1,34 @@
{
"name": "@dimensionlab/dashboard-model",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./fixtures": {
"types": "./dist/fixtures/index.d.ts",
"default": "./dist/fixtures/index.js"
}
},
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"check": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/node": "^25.9.3",
"bun-types": "^1.3.14",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}

View file

@ -0,0 +1,91 @@
import {
DASHBOARD_SCHEMA_VERSION,
type DashboardDocument,
} from "../schema";
export const genericDashboardFixture: DashboardDocument = {
schemaVersion: DASHBOARD_SCHEMA_VERSION,
metadata: {
title: "Operations Console",
subtitle: "Generic environment",
description: "Portable fixture for component and renderer tests.",
timezone: "UTC",
refreshIntervalSeconds: 30,
},
layout: {
density: "dense",
telemetry: ["service-uptime", "queue-depth"],
serviceGroups: ["core-services"],
statusStrips: ["runtime"],
modules: ["ambient"],
},
telemetry: [
{
id: "service-uptime",
label: "Service Uptime",
value: { kind: "percent", value: 99.9, precision: 1 },
detail: "last 30 days",
severity: "ok",
datasource: { type: "static", label: "fixture" },
sparkline: [99.7, 99.8, 99.9, 99.9],
},
{
id: "queue-depth",
label: "Queue Depth",
value: { kind: "number", value: 18 },
detail: "pending jobs",
severity: "warning",
thresholds: { warning: 15, danger: 50 },
datasource: { type: "placeholder", reason: "adapter pending" },
sparkline: [6, 11, 13, 18],
},
],
serviceGroups: [
{
id: "core-services",
title: "Core Services",
layout: "list",
services: [
{
id: "identity",
label: "Identity",
description: "Authentication and profile service",
icon: "mdi:account-key-outline",
severity: "ok",
detail: "ready",
datasource: { type: "static", label: "fixture" },
},
{
id: "scheduler",
label: "Scheduler",
description: "Background task coordinator",
icon: "mdi:calendar-clock",
severity: "warning",
detail: "delayed",
datasource: { type: "placeholder", reason: "health adapter pending" },
},
],
},
],
statusStrips: [
{
id: "runtime",
items: [
{ id: "status", label: "System Status", value: "Degraded", severity: "warning" },
{ id: "sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" },
],
},
],
modules: [
{
id: "ambient",
kind: "summary",
title: "Environment",
value: "Nominal",
detail: "static fixture",
icon: "mdi:radar",
severity: "ok",
datasource: { type: "static", label: "fixture" },
},
],
};

View file

@ -0,0 +1 @@
export { genericDashboardFixture } from "./generic";

View file

@ -0,0 +1,29 @@
export {
DASHBOARD_SCHEMA_VERSION,
DashboardDocumentSchema,
DatasourceReferenceSchema,
ServiceEntrySchema,
ServiceGroupSchema,
TelemetryCardSchema,
ThresholdSchema,
dashboardDocumentJsonSchema,
type DashboardDocument,
type DashboardModule,
type DatasourceReference,
type MetricValue,
type ServiceEntry,
type ServiceGroup,
type Severity,
type StatusItem,
type StatusStrip,
type TelemetryCard,
} from "./schema";
export {
assertDashboardDocument,
formatValidationErrors,
isDashboardDocument,
validateDashboardDocument,
type DashboardValidationFailure,
type DashboardValidationResult,
type DashboardValidationSuccess,
} from "./validation";

View file

@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
DASHBOARD_SCHEMA_VERSION,
dashboardDocumentJsonSchema,
validateDashboardDocument,
} from ".";
import {
genericDashboardFixture,
} from "./fixtures";
describe("dashboard model validation", () => {
it("accepts the generic dashboard fixture", () => {
const result = validateDashboardDocument(genericDashboardFixture);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.data.schemaVersion).toBe(DASHBOARD_SCHEMA_VERSION);
}
});
it("rejects documents with an unsupported schema version", () => {
const invalid = {
...genericDashboardFixture,
schemaVersion: "dashboard.v0",
};
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("must be equal to constant");
expect(result.details.length).toBeGreaterThan(0);
}
});
it("returns actionable field paths for invalid documents", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
delete invalid.metadata.title;
invalid.telemetry[0].severity = "fine";
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.some((error) => error.includes("/metadata"))).toBe(true);
expect(result.errors.some((error) => error.includes("/telemetry/0/severity"))).toBe(true);
}
});
it("includes offending additional property names in errors", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.metadata.unexpected = true;
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("unexpected");
}
});
it("rejects non-finite numbers that cannot roundtrip through JSON", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].value.value = Number.NaN;
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("finite JSON number");
}
});
it("rejects dangling layout references", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.layout.telemetry.push("missing-card");
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("missing-card");
expect(result.errors.join(" ")).toContain("must reference an existing item");
}
});
it("rejects duplicate IDs within collections", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[1].id = invalid.telemetry[0].id;
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("must be unique");
expect(result.errors.join(" ")).toContain(invalid.telemetry[0].id);
}
});
it("rejects duplicate status strip item IDs", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.statusStrips[0].items.push({
...invalid.statusStrips[0].items[0],
});
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("must be unique");
expect(result.errors.join(" ")).toContain(invalid.statusStrips[0].items[0].id);
}
});
it("rejects string values for numeric metric kinds", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].value.value = "not a percent";
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
});
it("rejects percent values outside 0 to 100", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].value.value = 150;
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
});
it("rejects negative values for nonnegative metric kinds", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].value = { kind: "latency", value: -20 };
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
});
it("rejects contradictory warning and danger thresholds", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].thresholds = { warning: 90, danger: 80 };
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("warning threshold");
}
});
it("rejects percent thresholds above 100", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].thresholds = { warning: 99, danger: 999 };
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("percent thresholds");
}
});
it("rejects thresholds on text metric values", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.telemetry[0].value = { kind: "text", value: "available" };
invalid.telemetry[0].thresholds = { warning: 10 };
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("text metric values");
}
});
it("rejects undefined properties because they are not JSON values", () => {
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
invalid.serviceGroups[0].services[0].link = undefined;
const result = validateDashboardDocument(invalid);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.errors.join(" ")).toContain("must be omitted instead of undefined");
}
});
it("exports JSON Schema for external tool contracts", () => {
expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1");
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion");
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("telemetry");
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("serviceGroups");
});
});

View file

@ -0,0 +1,252 @@
import { Type, type Static } from "@sinclair/typebox";
export const DASHBOARD_SCHEMA_VERSION = "dashboard.v1" as const;
const IdentifierSchema = Type.String({
minLength: 1,
pattern: "^[a-z0-9][a-z0-9-_.:]*$",
});
const SeveritySchema = Type.Union([
Type.Literal("neutral"),
Type.Literal("ok"),
Type.Literal("warning"),
Type.Literal("danger"),
Type.Literal("stale"),
Type.Literal("unavailable"),
]);
const IconReferenceSchema = Type.String({ minLength: 1 });
const LinkSchema = Type.Object(
{
href: Type.String({ format: "uri" }),
label: Type.Optional(Type.String({ minLength: 1 })),
external: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const StaticDatasourceSchema = Type.Object(
{
type: Type.Literal("static"),
label: Type.Optional(Type.String({ minLength: 1 })),
updatedAt: Type.Optional(Type.String({ format: "date-time" })),
},
{ additionalProperties: false },
);
export const PlaceholderDatasourceSchema = Type.Object(
{
type: Type.Literal("placeholder"),
reason: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
export const ExternalDatasourceSchema = Type.Object(
{
type: Type.Literal("external"),
adapter: Type.Union([
Type.Literal("prometheus"),
Type.Literal("http-status"),
Type.Literal("weather"),
Type.Literal("custom"),
]),
reference: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
);
export const DatasourceReferenceSchema = Type.Union([
StaticDatasourceSchema,
PlaceholderDatasourceSchema,
ExternalDatasourceSchema,
]);
const PercentMetricValueSchema = Type.Object(
{
kind: Type.Literal("percent"),
value: Type.Number({ minimum: 0, maximum: 100 }),
unit: Type.Optional(Type.String({ minLength: 1 })),
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
},
{ additionalProperties: false },
);
const NonNegativeMetricValueSchema = Type.Object(
{
kind: Type.Union([
Type.Literal("bytes"),
Type.Literal("temperature"),
Type.Literal("latency"),
Type.Literal("number"),
]),
value: Type.Number({ minimum: 0 }),
unit: Type.Optional(Type.String({ minLength: 1 })),
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
},
{ additionalProperties: false },
);
const TextMetricValueSchema = Type.Object(
{
kind: Type.Literal("text"),
value: Type.String(),
unit: Type.Optional(Type.String({ minLength: 1 })),
},
{ additionalProperties: false },
);
export const MetricValueSchema = Type.Union([
PercentMetricValueSchema,
NonNegativeMetricValueSchema,
TextMetricValueSchema,
]);
export const ThresholdSchema = Type.Object(
{
warning: Type.Optional(Type.Number({ minimum: 0 })),
danger: Type.Optional(Type.Number({ minimum: 0 })),
},
{ additionalProperties: false, minProperties: 1 },
);
export const TelemetryCardSchema = Type.Object(
{
id: IdentifierSchema,
label: Type.String({ minLength: 1 }),
description: Type.Optional(Type.String()),
icon: Type.Optional(IconReferenceSchema),
value: MetricValueSchema,
detail: Type.Optional(Type.String()),
severity: SeveritySchema,
thresholds: Type.Optional(ThresholdSchema),
datasource: Type.Optional(DatasourceReferenceSchema),
sparkline: Type.Optional(Type.Array(Type.Number())),
},
{ additionalProperties: false },
);
export const ServiceEntrySchema = Type.Object(
{
id: IdentifierSchema,
label: Type.String({ minLength: 1 }),
description: Type.String(),
icon: Type.Optional(IconReferenceSchema),
link: Type.Optional(LinkSchema),
severity: SeveritySchema,
detail: Type.Optional(Type.String()),
datasource: Type.Optional(DatasourceReferenceSchema),
},
{ additionalProperties: false },
);
export const ServiceGroupSchema = Type.Object(
{
id: IdentifierSchema,
title: Type.String({ minLength: 1 }),
layout: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("grid")])),
services: Type.Array(ServiceEntrySchema),
},
{ additionalProperties: false },
);
export const StatusItemSchema = Type.Object(
{
id: IdentifierSchema,
label: Type.String({ minLength: 1 }),
value: Type.String(),
severity: Type.Optional(SeveritySchema),
link: Type.Optional(LinkSchema),
},
{ additionalProperties: false },
);
export const StatusStripSchema = Type.Object(
{
id: IdentifierSchema,
title: Type.Optional(Type.String({ minLength: 1 })),
items: Type.Array(StatusItemSchema),
},
{ additionalProperties: false },
);
export const DashboardModuleSchema = Type.Object(
{
id: IdentifierSchema,
kind: Type.Union([
Type.Literal("summary"),
Type.Literal("weather"),
Type.Literal("custom"),
]),
title: Type.Optional(Type.String({ minLength: 1 })),
label: Type.Optional(Type.String()),
value: Type.Optional(Type.String()),
detail: Type.Optional(Type.String()),
icon: Type.Optional(IconReferenceSchema),
severity: Type.Optional(SeveritySchema),
datasource: Type.Optional(DatasourceReferenceSchema),
},
{ additionalProperties: false },
);
const LayoutSchema = Type.Object(
{
density: Type.Optional(Type.Union([Type.Literal("compact"), Type.Literal("dense")])),
telemetry: Type.Array(IdentifierSchema),
serviceGroups: Type.Array(IdentifierSchema),
statusStrips: Type.Array(IdentifierSchema),
modules: Type.Optional(Type.Array(IdentifierSchema)),
},
{ additionalProperties: false },
);
const MetadataSchema = Type.Object(
{
title: Type.String({ minLength: 1 }),
subtitle: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
timezone: Type.Optional(Type.String({ minLength: 1 })),
refreshIntervalSeconds: Type.Optional(Type.Integer({ minimum: 5 })),
},
{ additionalProperties: false },
);
export const DashboardDocumentSchema = Type.Object(
{
schemaVersion: Type.Literal(DASHBOARD_SCHEMA_VERSION),
metadata: MetadataSchema,
layout: LayoutSchema,
telemetry: Type.Array(TelemetryCardSchema),
serviceGroups: Type.Array(ServiceGroupSchema),
statusStrips: Type.Array(StatusStripSchema),
modules: Type.Optional(Type.Array(DashboardModuleSchema)),
migration: Type.Optional(
Type.Object(
{
previousVersion: Type.Optional(Type.String({ minLength: 1 })),
notes: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
},
{
$id: "https://dimensionlab.net/schemas/dashboard-document.v1.json",
additionalProperties: false,
},
);
export type Severity = Static<typeof SeveritySchema>;
export type DatasourceReference = Static<typeof DatasourceReferenceSchema>;
export type MetricValue = Static<typeof MetricValueSchema>;
export type TelemetryCard = Static<typeof TelemetryCardSchema>;
export type ServiceEntry = Static<typeof ServiceEntrySchema>;
export type ServiceGroup = Static<typeof ServiceGroupSchema>;
export type StatusItem = Static<typeof StatusItemSchema>;
export type StatusStrip = Static<typeof StatusStripSchema>;
export type DashboardModule = Static<typeof DashboardModuleSchema>;
export type DashboardDocument = Static<typeof DashboardDocumentSchema>;
export const dashboardDocumentJsonSchema = DashboardDocumentSchema;

View file

@ -0,0 +1,322 @@
import Ajv, { type ErrorObject } from "ajv";
import addFormats from "ajv-formats";
import {
DashboardDocumentSchema,
type DashboardDocument,
} from "./schema";
type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;
interface SemanticValidationIssue {
instancePath: string;
schemaPath: string;
keyword: "semantic";
params: Record<string, unknown>;
message: string;
}
export interface DashboardValidationFailure {
valid: false;
errors: string[];
details: DashboardValidationIssue[];
}
export interface DashboardValidationSuccess {
valid: true;
data: DashboardDocument;
}
export type DashboardValidationResult =
| DashboardValidationFailure
| DashboardValidationSuccess;
const ajv = addFormats(
new Ajv({
allErrors: true,
strict: false,
strictNumbers: true,
}),
);
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
export function formatValidationErrors(
errors: DashboardValidationIssue[] = [],
): string[] {
return errors.map((error) => {
const path = error.instancePath || "/";
const suffix = formatErrorParams(error);
const message = error.message || "is invalid";
return `${path} ${message}${suffix}`;
});
}
export function validateDashboardDocument(
value: unknown,
): DashboardValidationResult {
const finiteNumberIssues: SemanticValidationIssue[] = [];
const undefinedIssues: SemanticValidationIssue[] = [];
collectFiniteNumberIssues(value, "", finiteNumberIssues);
collectUndefinedIssues(value, "", undefinedIssues);
if (validateDashboard(value)) {
const semanticIssues = [
...finiteNumberIssues,
...undefinedIssues,
...validateSemanticRules(value),
];
if (semanticIssues.length === 0) {
return { valid: true, data: value };
}
return {
valid: false,
errors: formatValidationErrors(semanticIssues),
details: semanticIssues,
};
}
const details = [
...(validateDashboard.errors || []),
...finiteNumberIssues,
...undefinedIssues,
];
return {
valid: false,
errors: formatValidationErrors(details),
details,
};
}
export function assertDashboardDocument(
value: unknown,
): asserts value is DashboardDocument {
const result = validateDashboardDocument(value);
if (!result.valid) {
throw new Error(`Invalid dashboard document: ${result.errors.join("; ")}`);
}
}
export function isDashboardDocument(value: unknown): value is DashboardDocument {
return validateDashboardDocument(value).valid;
}
function formatErrorParams(error: DashboardValidationIssue): string {
if (error.keyword === "additionalProperties") {
const additionalProperty = error.params.additionalProperty;
return typeof additionalProperty === "string"
? `: ${additionalProperty}`
: "";
}
if (error.keyword === "semantic") {
const id = error.params.id;
const ref = error.params.ref;
if (typeof id === "string") return `: ${id}`;
if (typeof ref === "string") return `: ${ref}`;
}
return "";
}
function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
const issues: SemanticValidationIssue[] = [];
collectDuplicateIdIssues("telemetry", document.telemetry, issues);
collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
collectDuplicateIdIssues("modules", document.modules || [], issues);
document.serviceGroups.forEach((group, groupIndex) => {
collectDuplicateIdIssues(
`serviceGroups/${groupIndex}/services`,
group.services,
issues,
);
});
document.statusStrips.forEach((strip, stripIndex) => {
collectDuplicateIdIssues(
`statusStrips/${stripIndex}/items`,
strip.items,
issues,
);
});
collectMissingReferenceIssues(
"layout/telemetry",
document.layout.telemetry,
new Set(document.telemetry.map((item) => item.id)),
issues,
);
collectMissingReferenceIssues(
"layout/serviceGroups",
document.layout.serviceGroups,
new Set(document.serviceGroups.map((item) => item.id)),
issues,
);
collectMissingReferenceIssues(
"layout/statusStrips",
document.layout.statusStrips,
new Set(document.statusStrips.map((item) => item.id)),
issues,
);
collectMissingReferenceIssues(
"layout/modules",
document.layout.modules || [],
new Set((document.modules || []).map((item) => item.id)),
issues,
);
document.telemetry.forEach((card, index) => {
if (card.value.kind === "text" && card.thresholds !== undefined) {
issues.push(
semanticIssue(
`/telemetry/${index}/thresholds`,
"must not be set for text metric values",
{ id: card.id },
),
);
}
if (
card.value.kind === "percent" &&
((card.thresholds?.warning !== undefined && card.thresholds.warning > 100) ||
(card.thresholds?.danger !== undefined && card.thresholds.danger > 100))
) {
issues.push(
semanticIssue(
`/telemetry/${index}/thresholds`,
"percent thresholds must be between 0 and 100",
{ id: card.id },
),
);
}
const warning = card.thresholds?.warning;
const danger = card.thresholds?.danger;
if (warning !== undefined && danger !== undefined && warning > danger) {
issues.push(
semanticIssue(
`/telemetry/${index}/thresholds`,
"warning threshold must be less than or equal to danger threshold",
{ id: card.id },
),
);
}
});
return issues;
}
function collectUndefinedIssues(
value: unknown,
path: string,
issues: SemanticValidationIssue[],
) {
if (value === undefined) {
issues.push(
semanticIssue(path || "/", "must be omitted instead of undefined", {}),
);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
collectUndefinedIssues(item, `${path}/${index}`, issues);
});
return;
}
if (value && typeof value === "object") {
Object.entries(value).forEach(([key, item]) => {
collectUndefinedIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
});
}
}
function collectFiniteNumberIssues(
value: unknown,
path: string,
issues: SemanticValidationIssue[],
) {
if (typeof value === "number") {
if (!Number.isFinite(value)) {
issues.push(
semanticIssue(path || "/", "must be a finite JSON number", {}),
);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
collectFiniteNumberIssues(item, `${path}/${index}`, issues);
});
return;
}
if (value && typeof value === "object") {
Object.entries(value).forEach(([key, item]) => {
collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
});
}
}
function collectDuplicateIdIssues(
collectionPath: string,
items: Array<{ id: string }>,
issues: SemanticValidationIssue[],
) {
const seen = new Set<string>();
items.forEach((item, index) => {
if (seen.has(item.id)) {
issues.push(
semanticIssue(
`/${collectionPath}/${index}/id`,
"must be unique within its collection",
{ id: item.id },
),
);
return;
}
seen.add(item.id);
});
}
function collectMissingReferenceIssues(
layoutPath: string,
refs: string[],
validIds: Set<string>,
issues: SemanticValidationIssue[],
) {
refs.forEach((ref, index) => {
if (!validIds.has(ref)) {
issues.push(
semanticIssue(
`/${layoutPath}/${index}`,
"must reference an existing item",
{ ref },
),
);
}
});
}
function semanticIssue(
instancePath: string,
message: string,
params: Record<string, unknown>,
): SemanticValidationIssue {
return {
instancePath,
schemaPath: "",
keyword: "semantic",
params,
message,
};
}
function escapeJsonPointer(value: string): string {
return value.replaceAll("~", "~0").replaceAll("/", "~1");
}

View file

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false,
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "src/**/*.test.ts"]
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"types": ["node", "bun-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}