@a11y-skills/audit 0.2.0 → 0.3.1

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.
@@ -7,11 +7,31 @@
7
7
  *
8
8
  * The caller is responsible for navigating the page before calling this.
9
9
  */
10
- import { TEXT_SPACING_CSS, TEXT_SPACING_CLIP_TOLERANCE, TEXT_SPACING_CHECK_SELECTOR, DEFAULT_TEXT_SPACING_RESULT_FILE, DEFAULT_TEXT_SPACING_SCREENSHOT_FILE, } from '../constants.js';
10
+ import { TEXT_SPACING_CSS, TEXT_SPACING_CLIP_TOLERANCE, TEXT_SPACING_CHECK_SELECTOR, DEFAULT_TEXT_SPACING_RESULT_FILE, DEFAULT_TEXT_SPACING_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
11
+ import { buildAuditResult, normalizeTextSpacingCheck, } from '../utils/axe-format.js';
11
12
  import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
12
13
  /** Collect metrics for elements with hidden overflow (browser context). */
13
14
  function collectElementMetrics(args) {
14
- const { checkSelector } = args;
15
+ const { checkSelector, htmlSnippetMaxLength } = args;
16
+ function getHtmlSnippet(element) {
17
+ let html = '';
18
+ try {
19
+ html = element.outerHTML || '';
20
+ }
21
+ catch {
22
+ html = '';
23
+ }
24
+ if (!html) {
25
+ return {
26
+ html: `<${element.tagName.toLowerCase()}>`,
27
+ htmlTruncated: false,
28
+ };
29
+ }
30
+ if (html.length > htmlSnippetMaxLength) {
31
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
32
+ }
33
+ return { html, htmlTruncated: false };
34
+ }
15
35
  function getUniqueSelector(element, elementIndex) {
16
36
  if (element.id) {
17
37
  return `#${element.id}`;
@@ -28,7 +48,9 @@ function collectElementMetrics(args) {
28
48
  path.unshift(selector);
29
49
  current = parent;
30
50
  }
31
- return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
51
+ return path.length > 0
52
+ ? path.join(' > ')
53
+ : `[data-index="${elementIndex}"]`;
32
54
  }
33
55
  function isVisible(element) {
34
56
  const style = window.getComputedStyle(element);
@@ -56,6 +78,7 @@ function collectElementMetrics(args) {
56
78
  metrics.push({
57
79
  selector: getUniqueSelector(element, index),
58
80
  tagName: element.tagName.toLowerCase(),
81
+ ...getHtmlSnippet(element),
59
82
  scrollWidth: element.scrollWidth,
60
83
  scrollHeight: element.scrollHeight,
61
84
  clientWidth: element.clientWidth,
@@ -70,13 +93,32 @@ function collectElementMetrics(args) {
70
93
  }
71
94
  /** Inject text spacing CSS and re-collect metrics (browser context). */
72
95
  function injectSpacingAndCollect(args) {
73
- const { css, checkSelector } = args;
96
+ const { css, checkSelector, htmlSnippetMaxLength } = args;
74
97
  const styleEl = document.createElement('style');
75
98
  styleEl.id = 'wcag-text-spacing-override';
76
99
  styleEl.textContent = css;
77
100
  document.head.appendChild(styleEl);
78
101
  // Force reflow
79
102
  void document.body.offsetHeight;
103
+ function getHtmlSnippet(element) {
104
+ let html = '';
105
+ try {
106
+ html = element.outerHTML || '';
107
+ }
108
+ catch {
109
+ html = '';
110
+ }
111
+ if (!html) {
112
+ return {
113
+ html: `<${element.tagName.toLowerCase()}>`,
114
+ htmlTruncated: false,
115
+ };
116
+ }
117
+ if (html.length > htmlSnippetMaxLength) {
118
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
119
+ }
120
+ return { html, htmlTruncated: false };
121
+ }
80
122
  function getUniqueSelector(element, elementIndex) {
81
123
  if (element.id) {
82
124
  return `#${element.id}`;
@@ -93,7 +135,9 @@ function injectSpacingAndCollect(args) {
93
135
  path.unshift(selector);
94
136
  current = parent;
95
137
  }
96
- return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
138
+ return path.length > 0
139
+ ? path.join(' > ')
140
+ : `[data-index="${elementIndex}"]`;
97
141
  }
98
142
  function isVisible(element) {
99
143
  const style = window.getComputedStyle(element);
@@ -121,6 +165,7 @@ function injectSpacingAndCollect(args) {
121
165
  metrics.push({
122
166
  selector: getUniqueSelector(element, index),
123
167
  tagName: element.tagName.toLowerCase(),
168
+ ...getHtmlSnippet(element),
124
169
  scrollWidth: element.scrollWidth,
125
170
  scrollHeight: element.scrollHeight,
126
171
  clientWidth: element.clientWidth,
@@ -175,6 +220,8 @@ function detectClippingIssues(beforeMetrics, afterMetrics, tolerance) {
175
220
  issues.push({
176
221
  selector: after.selector,
177
222
  tagName: after.tagName,
223
+ html: after.html,
224
+ htmlTruncated: after.htmlTruncated,
178
225
  beforeMetrics: {
179
226
  scrollWidth: beforeData.scrollWidth,
180
227
  scrollHeight: beforeData.scrollHeight,
@@ -204,23 +251,30 @@ export async function runTextSpacingCheck(options) {
204
251
  const { page, tolerance = TEXT_SPACING_CLIP_TOLERANCE, screenshot = false, ...location } = options;
205
252
  const beforeMetrics = await page.evaluate(collectElementMetrics, {
206
253
  checkSelector: TEXT_SPACING_CHECK_SELECTOR,
254
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
207
255
  });
208
256
  const afterMetrics = await page.evaluate(injectSpacingAndCollect, {
209
257
  css: TEXT_SPACING_CSS,
210
258
  checkSelector: TEXT_SPACING_CHECK_SELECTOR,
259
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
211
260
  });
212
261
  const clippedElements = detectClippingIssues(beforeMetrics, afterMetrics, tolerance);
213
- const result = {
214
- url: page.url(),
262
+ const details = {
215
263
  clippedElements,
216
264
  totalElementsChecked: afterMetrics.length,
217
265
  };
266
+ const result = buildAuditResult({
267
+ source: 'text-spacing-check',
268
+ url: page.url(),
269
+ details,
270
+ buckets: normalizeTextSpacingCheck(details),
271
+ });
218
272
  logAuditHeader('Text Spacing Check Results', 'WCAG 1.4.12', result.url);
219
273
  logSummary({
220
- 'Elements with overflow:hidden checked': result.totalElementsChecked,
221
- 'Elements with clipping issues': result.clippedElements.length,
274
+ 'Elements with overflow:hidden checked': details.totalElementsChecked,
275
+ 'Elements with clipping issues': details.clippedElements.length,
222
276
  });
223
- logIssueList('Clipped Elements', result.clippedElements, (el, i) => [
277
+ logIssueList('Clipped Elements', details.clippedElements, (el, i) => [
224
278
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
225
279
  ` Issue: ${el.issueType}`,
226
280
  ` Before: ${el.beforeMetrics.scrollWidth}x${el.beforeMetrics.scrollHeight} in ${el.beforeMetrics.clientWidth}x${el.beforeMetrics.clientHeight}`,
@@ -10,7 +10,8 @@
10
10
  * (option → `TEST_PAGE` env → required). Unlike the focus check it does not
11
11
  * need a fresh context per attempt, so it stays page-based.
12
12
  */
13
- import { TIME_LIMIT_KEYWORDS, TIME_LIMIT_THRESHOLD_MS, TIME_LIMIT_MIN_MS, DEFAULT_TIME_LIMIT_RESULT_FILE, } from '../constants.js';
13
+ import { TIME_LIMIT_KEYWORDS, TIME_LIMIT_THRESHOLD_MS, TIME_LIMIT_MIN_MS, DEFAULT_TIME_LIMIT_RESULT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
14
+ import { buildAuditResult, normalizeTimeLimitDetector, } from '../utils/axe-format.js';
14
15
  import { saveAuditResult, requireTargetUrl, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
15
16
  /** Timer hook injected before page load (browser context). */
16
17
  function createTimerHookScript(args) {
@@ -29,13 +30,13 @@ function createTimerHookScript(args) {
29
30
  callStack =
30
31
  e.stack?.split('\n').slice(2, 5).join('\n') || null;
31
32
  }
32
- capturedTimers.push({ type: 'setTimeout', delayMs: actualDelay, callStack });
33
+ capturedTimers.push({
34
+ type: 'setTimeout',
35
+ delayMs: actualDelay,
36
+ callStack,
37
+ });
33
38
  }
34
- return originalSetTimeout.apply(window, [
35
- callback,
36
- delay,
37
- ...rest,
38
- ]);
39
+ return originalSetTimeout.apply(window, [callback, delay, ...rest]);
39
40
  };
40
41
  window.setInterval = function (callback, delay, ...rest) {
41
42
  const actualDelay = delay || 0;
@@ -48,19 +49,39 @@ function createTimerHookScript(args) {
48
49
  callStack =
49
50
  e.stack?.split('\n').slice(2, 5).join('\n') || null;
50
51
  }
51
- capturedTimers.push({ type: 'setInterval', delayMs: actualDelay, callStack });
52
+ capturedTimers.push({
53
+ type: 'setInterval',
54
+ delayMs: actualDelay,
55
+ callStack,
56
+ });
52
57
  }
53
- return originalSetInterval.apply(window, [
54
- callback,
55
- delay,
56
- ...rest,
57
- ]);
58
+ return originalSetInterval.apply(window, [callback, delay, ...rest]);
58
59
  };
59
- window.__capturedTimers = capturedTimers;
60
+ window.__capturedTimers =
61
+ capturedTimers;
60
62
  }
61
63
  /** Detect meta refresh + countdown indicators (browser context). */
62
64
  function detectTimeLimitIndicators(args) {
63
- const { keywords } = args;
65
+ const { keywords, htmlSnippetMaxLength } = args;
66
+ function getHtmlSnippet(element) {
67
+ let html = '';
68
+ try {
69
+ html = element.outerHTML || '';
70
+ }
71
+ catch {
72
+ html = '';
73
+ }
74
+ if (!html) {
75
+ return {
76
+ html: `<${element.tagName.toLowerCase()}>`,
77
+ htmlTruncated: false,
78
+ };
79
+ }
80
+ if (html.length > htmlSnippetMaxLength) {
81
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
82
+ }
83
+ return { html, htmlTruncated: false };
84
+ }
64
85
  function getUniqueSelector(element, elementIndex) {
65
86
  if (element.id) {
66
87
  return `#${element.id}`;
@@ -77,7 +98,9 @@ function detectTimeLimitIndicators(args) {
77
98
  path.unshift(selector);
78
99
  current = parent;
79
100
  }
80
- return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
101
+ return path.length > 0
102
+ ? path.join(' > ')
103
+ : `[data-index="${elementIndex}"]`;
81
104
  }
82
105
  const metaRefresh = [];
83
106
  const metaTags = document.querySelectorAll('meta[http-equiv="refresh"]');
@@ -91,6 +114,7 @@ function detectTimeLimitIndicators(args) {
91
114
  content,
92
115
  seconds: parseInt(match[1] ?? '0', 10),
93
116
  url: match[2]?.trim() || null,
117
+ ...getHtmlSnippet(meta),
94
118
  });
95
119
  }
96
120
  }
@@ -116,6 +140,7 @@ function detectTimeLimitIndicators(args) {
116
140
  selector: getUniqueSelector(parent, elementIndex),
117
141
  text: fullText,
118
142
  tagName: parent.tagName.toLowerCase(),
143
+ ...getHtmlSnippet(parent),
119
144
  });
120
145
  }
121
146
  }
@@ -139,25 +164,31 @@ export async function runTimeLimitDetector(options) {
139
164
  });
140
165
  const indicators = await page.evaluate(detectTimeLimitIndicators, {
141
166
  keywords: [...TIME_LIMIT_KEYWORDS],
167
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
142
168
  });
143
169
  const hasTimeLimits = indicators.metaRefresh.length > 0 ||
144
170
  timers.length > 0 ||
145
171
  indicators.countdownIndicators.length > 0;
146
- const result = {
147
- url: page.url(),
172
+ const details = {
148
173
  metaRefresh: indicators.metaRefresh,
149
174
  timers,
150
175
  countdownIndicators: indicators.countdownIndicators,
151
176
  hasTimeLimits,
152
177
  };
178
+ const result = buildAuditResult({
179
+ source: 'time-limit-detector',
180
+ url: page.url(),
181
+ details,
182
+ buckets: normalizeTimeLimitDetector(details),
183
+ });
153
184
  logAuditHeader('Time Limit Detection Results', 'WCAG 2.2.1', result.url);
154
185
  logSummary({
155
- 'Meta refresh tags': result.metaRefresh.length,
156
- [`Timers detected (${minMs / 1000}s - ${maxMs / 1000}s)`]: result.timers.length,
157
- 'Countdown text indicators': result.countdownIndicators.length,
158
- 'Time limits detected': result.hasTimeLimits,
186
+ 'Meta refresh tags': details.metaRefresh.length,
187
+ [`Timers detected (${minMs / 1000}s - ${maxMs / 1000}s)`]: details.timers.length,
188
+ 'Countdown text indicators': details.countdownIndicators.length,
189
+ 'Time limits detected': details.hasTimeLimits,
159
190
  });
160
- logIssueList('Meta Refresh', result.metaRefresh, (meta, i) => {
191
+ logIssueList('Meta Refresh', details.metaRefresh, (meta, i) => {
161
192
  const lines = [
162
193
  `${i + 1}. content="${meta.content}"`,
163
194
  ` Refresh in ${meta.seconds} seconds`,
@@ -167,7 +198,7 @@ export async function runTimeLimitDetector(options) {
167
198
  }
168
199
  return lines;
169
200
  });
170
- logIssueList('Detected Timers', result.timers, (timer, i) => {
201
+ logIssueList('Detected Timers', details.timers, (timer, i) => {
171
202
  const lines = [
172
203
  `${i + 1}. ${timer.type} - ${timer.delayMs}ms (${(timer.delayMs / 1000).toFixed(1)}s)`,
173
204
  ];
@@ -176,7 +207,7 @@ export async function runTimeLimitDetector(options) {
176
207
  }
177
208
  return lines;
178
209
  });
179
- logIssueList('Countdown Indicators', result.countdownIndicators, (indicator, i) => {
210
+ logIssueList('Countdown Indicators', details.countdownIndicators, (indicator, i) => {
180
211
  const truncatedText = indicator.text.length > 80
181
212
  ? indicator.text.slice(0, 80) + '...'
182
213
  : indicator.text;
@@ -15,14 +15,33 @@
15
15
  * - Does not verify responsive breakpoint behavior
16
16
  * - Manual verification needed for complex interactions at zoom
17
17
  */
18
- import { ZOOM_FACTOR, ZOOM_BASE_VIEWPORT, ZOOM_CLIP_TOLERANCE, REFLOW_CHECK_SELECTOR, DEFAULT_ZOOM_RESULT_FILE, DEFAULT_ZOOM_SCREENSHOT_FILE, } from '../constants.js';
18
+ import { ZOOM_FACTOR, ZOOM_BASE_VIEWPORT, ZOOM_CLIP_TOLERANCE, REFLOW_CHECK_SELECTOR, DEFAULT_ZOOM_RESULT_FILE, DEFAULT_ZOOM_SCREENSHOT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
19
+ import { buildAuditResult, normalizeZoomCheck } from '../utils/axe-format.js';
19
20
  import { saveAuditResult, takeAuditScreenshot, resolveScreenshotPath, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
20
21
  /** Apply zoom and detect issues in browser context. */
21
22
  function applyZoomAndCheck(args) {
22
- const { checkSelector, tolerance } = args;
23
+ const { checkSelector, tolerance, htmlSnippetMaxLength } = args;
24
+ function getHtmlSnippet(element) {
25
+ let html = '';
26
+ try {
27
+ html = element.outerHTML || '';
28
+ }
29
+ catch {
30
+ html = '';
31
+ }
32
+ if (!html) {
33
+ return {
34
+ html: `<${element.tagName.toLowerCase()}>`,
35
+ htmlTruncated: false,
36
+ };
37
+ }
38
+ if (html.length > htmlSnippetMaxLength) {
39
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
40
+ }
41
+ return { html, htmlTruncated: false };
42
+ }
23
43
  // Apply CSS zoom
24
- document.documentElement.style.zoom =
25
- '200%';
44
+ document.documentElement.style.zoom = '200%';
26
45
  // Force reflow
27
46
  void document.body.offsetHeight;
28
47
  function getUniqueSelector(element, elementIndex) {
@@ -41,7 +60,9 @@ function applyZoomAndCheck(args) {
41
60
  path.unshift(selector);
42
61
  current = parent;
43
62
  }
44
- return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
63
+ return path.length > 0
64
+ ? path.join(' > ')
65
+ : `[data-index="${elementIndex}"]`;
45
66
  }
46
67
  function isVisible(element) {
47
68
  const style = window.getComputedStyle(element);
@@ -83,6 +104,7 @@ function applyZoomAndCheck(args) {
83
104
  clippedElements.push({
84
105
  selector: getUniqueSelector(element, index),
85
106
  tagName: element.tagName.toLowerCase(),
107
+ ...getHtmlSnippet(element),
86
108
  scrollWidth,
87
109
  clientWidth,
88
110
  scrollHeight,
@@ -104,7 +126,10 @@ function applyZoomAndCheck(args) {
104
126
  */
105
127
  export async function runZoomCheck(options) {
106
128
  const { page, targetUrl: targetUrlOption, viewport = ZOOM_BASE_VIEWPORT, screenshot = false, ...location } = options;
107
- await page.setViewportSize({ width: viewport.width, height: viewport.height });
129
+ await page.setViewportSize({
130
+ width: viewport.width,
131
+ height: viewport.height,
132
+ });
108
133
  // If a URL is available, navigate at the base viewport (legacy ordering).
109
134
  const targetUrl = targetUrlOption ?? process.env.TEST_PAGE;
110
135
  if (targetUrl) {
@@ -113,24 +138,30 @@ export async function runZoomCheck(options) {
113
138
  const zoomResult = await page.evaluate(applyZoomAndCheck, {
114
139
  checkSelector: REFLOW_CHECK_SELECTOR,
115
140
  tolerance: ZOOM_CLIP_TOLERANCE,
141
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
116
142
  });
117
- const result = {
118
- url: page.url(),
143
+ const details = {
119
144
  zoomFactor: ZOOM_FACTOR,
120
145
  viewport: { width: viewport.width, height: viewport.height },
121
146
  ...zoomResult,
122
147
  };
148
+ const result = buildAuditResult({
149
+ source: 'zoom-200-check',
150
+ url: page.url(),
151
+ details,
152
+ buckets: normalizeZoomCheck(details),
153
+ });
123
154
  // Output results
124
155
  logAuditHeader('Zoom 200% Check Results', 'WCAG 1.4.4', result.url);
125
156
  logSummary({
126
- 'Zoom factor': `${result.zoomFactor}x`,
127
- 'Base viewport': `${result.viewport.width}x${result.viewport.height}`,
128
- 'Document scroll width': `${result.documentScrollWidth}px`,
129
- 'Document client width': `${result.documentClientWidth}px`,
130
- 'Horizontal scroll': result.hasHorizontalScroll,
131
- 'Clipped elements': result.clippedElements.length,
157
+ 'Zoom factor': `${details.zoomFactor}x`,
158
+ 'Base viewport': `${details.viewport.width}x${details.viewport.height}`,
159
+ 'Document scroll width': `${details.documentScrollWidth}px`,
160
+ 'Document client width': `${details.documentClientWidth}px`,
161
+ 'Horizontal scroll': details.hasHorizontalScroll,
162
+ 'Clipped elements': details.clippedElements.length,
132
163
  });
133
- logIssueList('Clipped Elements', result.clippedElements, (el, i) => [
164
+ logIssueList('Clipped Elements', details.clippedElements, (el, i) => [
134
165
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
135
166
  ` scrollWidth: ${el.scrollWidth}px, clientWidth: ${el.clientWidth}px`,
136
167
  ` Issue: ${el.issueType}`,
@@ -6,18 +6,25 @@
6
6
  * (e.g. an issue creator that reads `*-result.json`). They are intentionally
7
7
  * permissive (no `additionalProperties: false`) so that additive changes to a
8
8
  * result shape do not break downstream validation.
9
+ *
10
+ * Every check shares the same envelope (`source` / `url` / `timestamp` / the
11
+ * four normalized buckets / `summary` / `details` / `disclaimer`); the common
12
+ * pieces live in `$defs` and each check schema defines its own `details`.
9
13
  */
10
- export type { AxeViolationNode, AxeViolation, AxeAuditResult, FocusRecord, OnFocusViolation, FocusCheckResult, BoundingRect, FocusObscuredOverlap, FocusObscuredIssue, ReflowIssue, ClippedTextElement, ReflowCheckResult, TargetSizeException, TargetSizeIssue, TargetSizeSummary, TargetSizeCheckResult, TextSpacingIssue, TextSpacingCheckResult, ZoomIssue, ZoomCheckResult, OrientationState, OrientationCheckResult, AutocompleteIssue, AutocompleteAuditResult, MetaRefreshInfo, TimerInfo, CountdownIndicator, TimeLimitDetectorResult, ScreenshotRecord, ComparisonResult, ImageDiffResult, PauseControl, CarouselIndicator, PauseControlInfo, PauseVerificationResult, AutoPlayDetectionResult, } from '../types.js';
14
+ export type { AuditCheckResult, AuditResultSummary, CheckSource, NormalizedImpact, NormalizedNode, NormalizedRuleResult, AxeAuditDetails, AxeAuditResult, FocusRecord, FocusElementRef, OnFocusViolation, FocusCheckDetails, FocusCheckResult, BoundingRect, FocusObscuredOverlap, FocusObscuredIssue, ReflowIssue, ClippedTextElement, ReflowCheckDetails, ReflowCheckResult, TargetSizeException, TargetSizeExceptionAssessment, TargetSizeIssue, TargetSizeSummary, TargetSizeCheckDetails, TargetSizeCheckResult, TextSpacingIssue, TextSpacingCheckDetails, TextSpacingCheckResult, ZoomIssue, ZoomCheckDetails, ZoomCheckResult, OrientationState, OrientationCheckDetails, OrientationCheckResult, AutocompleteIssue, AutocompleteAuditDetails, AutocompleteAuditResult, MetaRefreshInfo, TimerInfo, CountdownIndicator, TimeLimitDetectorDetails, TimeLimitDetectorResult, ScreenshotRecord, ComparisonResult, ImageDiffResult, PauseControl, CarouselIndicator, PauseControlInfo, PauseVerificationResult, AutoPlayDetectionDetails, AutoPlayDetectionResult, } from '../types.js';
11
15
  /** Minimal JSON Schema object shape (Draft 2020-12 compatible subset). */
12
16
  export interface JsonSchema {
13
17
  $schema?: string;
14
18
  $id?: string;
19
+ $ref?: string;
20
+ $defs?: Record<string, JsonSchema>;
15
21
  title?: string;
16
22
  type?: string | string[];
17
23
  properties?: Record<string, JsonSchema>;
18
24
  items?: JsonSchema;
19
25
  required?: string[];
20
26
  enum?: unknown[];
27
+ const?: unknown;
21
28
  description?: string;
22
29
  [key: string]: unknown;
23
30
  }