jamdesk 1.0.19 → 1.0.21

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.
Files changed (47) hide show
  1. package/dist/__tests__/unit/openapi.test.js +12 -0
  2. package/dist/__tests__/unit/openapi.test.js.map +1 -1
  3. package/dist/commands/openapi-check.d.ts.map +1 -1
  4. package/dist/commands/openapi-check.js +7 -13
  5. package/dist/commands/openapi-check.js.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deps.js +1 -1
  9. package/dist/lib/openapi/errors.d.ts.map +1 -1
  10. package/dist/lib/openapi/errors.js +17 -0
  11. package/dist/lib/openapi/errors.js.map +1 -1
  12. package/dist/lib/openapi/validator.d.ts +2 -2
  13. package/dist/lib/openapi/validator.d.ts.map +1 -1
  14. package/dist/lib/openapi/validator.js +52 -15
  15. package/dist/lib/openapi/validator.js.map +1 -1
  16. package/package.json +2 -2
  17. package/vendored/app/[[...slug]]/page.tsx +105 -28
  18. package/vendored/app/api/ev/route.ts +4 -1
  19. package/vendored/app/api/indexnow/[key]/route.ts +34 -0
  20. package/vendored/app/api/search-ev/route.ts +69 -0
  21. package/vendored/app/layout.tsx +70 -12
  22. package/vendored/components/chat/ChatInput.tsx +1 -1
  23. package/vendored/components/chat/ChatPanel.tsx +60 -11
  24. package/vendored/components/mdx/Update.tsx +7 -7
  25. package/vendored/components/navigation/Sidebar.tsx +2 -10
  26. package/vendored/components/navigation/TableOfContents.tsx +48 -35
  27. package/vendored/components/search/SearchModal.tsx +9 -5
  28. package/vendored/components/ui/CodePanelModal.tsx +5 -14
  29. package/vendored/hooks/useBodyScrollLock.ts +37 -0
  30. package/vendored/lib/analytics-client.ts +17 -7
  31. package/vendored/lib/docs-types.ts +3 -2
  32. package/vendored/lib/email-templates/components/base-layout.tsx +15 -15
  33. package/vendored/lib/extract-highlights.ts +2 -0
  34. package/vendored/lib/heading-extractor.ts +2 -2
  35. package/vendored/lib/indexnow.ts +77 -0
  36. package/vendored/lib/json-ld.ts +171 -0
  37. package/vendored/lib/middleware-helpers.ts +2 -0
  38. package/vendored/lib/openapi/errors.ts +21 -1
  39. package/vendored/lib/openapi/validator.ts +70 -23
  40. package/vendored/lib/route-helpers.ts +7 -2
  41. package/vendored/lib/search-client.ts +81 -36
  42. package/vendored/lib/seo.ts +77 -0
  43. package/vendored/lib/static-artifacts.ts +204 -5
  44. package/vendored/lib/static-file-route.ts +10 -5
  45. package/vendored/lib/validate-config.ts +1 -0
  46. package/vendored/schema/docs-schema.json +130 -8
  47. package/vendored/scripts/validate-links.cjs +1 -1
@@ -11,8 +11,7 @@ export interface SearchResult {
11
11
  type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
12
12
  }
13
13
 
14
- // Orama database instance
15
- let db: Orama<{
14
+ type OramaDb = Orama<{
16
15
  id: 'string';
17
16
  title: 'string';
18
17
  description: 'string';
@@ -20,47 +19,91 @@ let db: Orama<{
20
19
  slug: 'string';
21
20
  section: 'string';
22
21
  type: 'string';
23
- }> | null = null;
22
+ }>;
24
23
 
24
+ // Orama database instance
25
+ let db: OramaDb | null = null;
26
+ // Fingerprint of the data that is currently indexed (committed) or null if not yet built
27
+ let committedFingerprint: string | null = null;
28
+ // Fingerprint of the data currently being built (in-flight), to deduplicate concurrent calls
29
+ let buildingFingerprint: string | null = null;
25
30
  let initPromise: Promise<void> | null = null;
31
+ // ETag of the last fetched search-data.json and the parsed data it produced.
32
+ // Lets the modal skip response.json() when the CDN returns the same ETag.
33
+ let lastEtag = '';
34
+ let lastParsedData: SearchResult[] | null = null;
35
+
36
+ /**
37
+ * Cheap fingerprint: count + first/last IDs + a sample of content lengths.
38
+ * Detects new/removed pages AND content edits (which change content length).
39
+ */
40
+ function fingerprint(data: SearchResult[]): string {
41
+ if (data.length === 0) return '0';
42
+ const first = data[0].id;
43
+ const last = data[data.length - 1].id;
44
+ const step = Math.max(1, Math.floor(data.length / 8));
45
+ let contentSig = '';
46
+ // Sample up to 8 evenly-spaced items' content lengths to detect edits
47
+ for (let i = 0; i < data.length; i += step) {
48
+ contentSig += data[i].content.length + ',';
49
+ }
50
+ return `${data.length}:${first}:${last}:${contentSig}`;
51
+ }
52
+
53
+ async function buildIndex(data: SearchResult[], etag: string): Promise<void> {
54
+ db = await create({
55
+ schema: {
56
+ id: 'string',
57
+ title: 'string',
58
+ description: 'string',
59
+ content: 'string',
60
+ slug: 'string',
61
+ section: 'string',
62
+ type: 'string',
63
+ },
64
+ });
26
65
 
27
- export async function initializeSearch(data: SearchResult[]): Promise<void> {
28
- // Only initialize once
29
- if (db) return;
30
-
31
- // Return existing promise if already initializing
32
- if (initPromise) return initPromise;
33
-
34
- initPromise = (async () => {
35
- db = await create({
36
- schema: {
37
- id: 'string',
38
- title: 'string',
39
- description: 'string',
40
- content: 'string',
41
- slug: 'string',
42
- section: 'string',
43
- type: 'string',
44
- },
45
- });
46
-
47
- // Normalize data - ensure all fields are strings
48
- const normalizedData = data.map(item => ({
49
- id: item.id,
50
- title: item.title,
51
- description: item.description || '',
52
- content: item.content,
53
- slug: item.slug,
54
- section: item.section || '',
55
- type: item.type || 'guide',
56
- }));
57
-
58
- await insertMultiple(db, normalizedData);
59
- })();
66
+ const normalizedData = data.map(item => ({
67
+ id: item.id,
68
+ title: item.title,
69
+ description: item.description || '',
70
+ content: item.content,
71
+ slug: item.slug,
72
+ section: item.section || '',
73
+ type: item.type || 'guide',
74
+ }));
60
75
 
76
+ await insertMultiple(db, normalizedData);
77
+ committedFingerprint = buildingFingerprint;
78
+ lastParsedData = data;
79
+ lastEtag = etag;
80
+ }
81
+
82
+ export async function initializeSearch(data: SearchResult[], etag = ''): Promise<void> {
83
+ const fp = fingerprint(data);
84
+
85
+ // Skip rebuild if the committed index already matches
86
+ if (fp === committedFingerprint) return;
87
+
88
+ // If a build with this exact data is already in flight, wait on it
89
+ if (initPromise && fp === buildingFingerprint) return initPromise;
90
+
91
+ // New data available (or first init) — rebuild the index
92
+ buildingFingerprint = fp;
93
+ initPromise = buildIndex(data, etag);
61
94
  return initPromise;
62
95
  }
63
96
 
97
+ /**
98
+ * Returns the previously parsed search data if the given ETag matches the
99
+ * last successful fetch, so the modal can skip response.json() on unchanged data.
100
+ * Returns null when the ETag is empty, unknown, or has changed.
101
+ */
102
+ export function getLastData(etag: string | null): SearchResult[] | null {
103
+ if (!etag || etag !== lastEtag || !lastParsedData) return null;
104
+ return lastParsedData;
105
+ }
106
+
64
107
  export async function search(query: string, limit = 10): Promise<SearchResult[]> {
65
108
  if (!db) {
66
109
  console.warn('Search database not initialized');
@@ -86,6 +129,8 @@ export async function search(query: string, limit = 10): Promise<SearchResult[]>
86
129
  return results.hits.map(hit => hit.document as unknown as SearchResult);
87
130
  }
88
131
 
132
+ /** @internal Used by tests only */
89
133
  export function isInitialized(): boolean {
90
134
  return db !== null;
91
135
  }
136
+
@@ -11,6 +11,18 @@ import type { Metadata } from 'next';
11
11
  import type { DocsConfig, Logo, LogoConfig, Favicon, FaviconConfig, LanguageConfig, LanguageCode } from './docs-types';
12
12
  import { transformLanguagePath, extractLanguageFromPath, isValidLanguageCode } from './language-utils';
13
13
 
14
+ const HAS_DOCS_SUFFIX = /\b(?:Documentation|Docs)\s*$/i;
15
+
16
+ /**
17
+ * Build a display title for the site, appending "Documentation" only when
18
+ * config.name doesn't already end with a docs-related word.
19
+ */
20
+ export function buildSiteTitle(configName: string): string {
21
+ return HAS_DOCS_SUFFIX.test(configName)
22
+ ? configName
23
+ : `${configName} Documentation`;
24
+ }
25
+
14
26
  /**
15
27
  * Build the OG image URL for a page using the proxy's /api/og endpoint.
16
28
  * Returns undefined if og:image is explicitly set in metatags.
@@ -288,3 +300,68 @@ export function buildSeoMetadata(
288
300
 
289
301
  return metadata;
290
302
  }
303
+
304
+ const MAX_DESCRIPTION_LENGTH = 155;
305
+
306
+ /** Patterns that indicate a paragraph is not prose (headings, components, images, comments, etc). */
307
+ const NON_PROSE_PATTERNS = [
308
+ /^#{1,6}\s/, // headings
309
+ /^<[A-Z]/, // MDX components
310
+ /^!\[/, // images
311
+ /^<!--/, // HTML comments
312
+ /^>/, // blockquotes
313
+ /^[-*_]{3,}\s*$/, // horizontal rules (---, ***, ___)
314
+ ];
315
+
316
+ function isNonProseParagraph(text: string): boolean {
317
+ return NON_PROSE_PATTERNS.some((pattern) => pattern.test(text));
318
+ }
319
+
320
+ /** Strip inline markdown formatting, preserving text content. */
321
+ function stripInlineMarkdown(text: string): string {
322
+ return text
323
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // inline images
324
+ .replace(/`[^`]+`/g, '') // inline code
325
+ .replace(/~~(.+?)~~/g, '$1') // strikethrough
326
+ .replace(/\*\*(.+?)\*\*/g, '$1') // bold (non-greedy to handle nested *italic*)
327
+ .replace(/\*([^*]+)\*/g, '$1') // italic
328
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links (keep text)
329
+ .replace(/<[^>]+>/g, '') // HTML tags
330
+ .replace(/\n/g, ' ') // join multi-line paragraph
331
+ .replace(/\s+/g, ' ') // collapse whitespace
332
+ .trim();
333
+ }
334
+
335
+ /**
336
+ * Extract first prose paragraph from MDX content for auto-description.
337
+ * Skips non-prose blocks (headings, components, code), strips markdown
338
+ * formatting, and truncates to 155 chars on a word boundary.
339
+ */
340
+ export function generateAutoDescription(rawContent: string): string {
341
+ if (!rawContent?.trim()) return '';
342
+
343
+ // Strip frontmatter, import/export statements, and code blocks
344
+ const content = rawContent
345
+ .replace(/^---[\s\S]*?---\n*/m, '')
346
+ .replace(/^(import|export)\s+.*$/gm, '')
347
+ .replace(/```[\s\S]*?```/g, '');
348
+
349
+ const paragraphs = content.split(/\n\s*\n/);
350
+
351
+ for (const para of paragraphs) {
352
+ const trimmed = para.trim();
353
+ if (!trimmed || isNonProseParagraph(trimmed)) continue;
354
+
355
+ const stripped = stripInlineMarkdown(trimmed);
356
+ if (!stripped) continue;
357
+
358
+ if (stripped.length <= MAX_DESCRIPTION_LENGTH) return stripped;
359
+
360
+ // Truncate on word boundary
361
+ const truncated = stripped.slice(0, MAX_DESCRIPTION_LENGTH);
362
+ const lastSpace = truncated.lastIndexOf(' ');
363
+ return truncated.slice(0, lastSpace > 0 ? lastSpace : MAX_DESCRIPTION_LENGTH) + '...';
364
+ }
365
+
366
+ return '';
367
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Static Artifact Generator for ISR Mode
3
3
  *
4
- * Generates sitemap.xml, llms.txt, and robots.txt for ISR projects.
5
- * These are uploaded to R2 alongside the MDX content.
4
+ * Generates sitemap.xml, llms.txt, robots.txt, feed.xml, and search-data.json
5
+ * for ISR projects. These are uploaded to R2 alongside the MDX content.
6
6
  */
7
7
 
8
8
  /**
@@ -178,6 +178,7 @@ export interface RawPageInfo {
178
178
  noindex?: boolean;
179
179
  hidden?: boolean;
180
180
  lastModified?: string;
181
+ rss?: boolean;
181
182
  seo?: {
182
183
  noindex?: boolean;
183
184
  };
@@ -223,6 +224,8 @@ export interface GenerateAllOptions {
223
224
  hostAtDocs?: boolean;
224
225
  /** Block all crawlers (seo.metatags.robots: "noindex") */
225
226
  noindex?: boolean;
227
+ /** Pages with rss: true frontmatter (for RSS feed generation) */
228
+ rssPages?: RssPageInfo[];
226
229
  }
227
230
 
228
231
  /**
@@ -232,6 +235,7 @@ export interface GeneratedArtifacts {
232
235
  sitemap: string;
233
236
  llmsTxt: string;
234
237
  robotsTxt: string;
238
+ rssFeed: string | null;
235
239
  }
236
240
 
237
241
  /**
@@ -241,15 +245,210 @@ export interface GeneratedArtifacts {
241
245
  * @returns Object with all generated artifacts
242
246
  */
243
247
  export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArtifacts {
244
- const { baseUrl, name, description, pages, hostAtDocs, noindex } = options;
248
+ const {
249
+ baseUrl, name, description, pages, hostAtDocs, noindex, rssPages,
250
+ } = options;
245
251
 
246
252
  const sitemap = generateSitemap({ baseUrl, pages, hostAtDocs, noindex });
247
- const llmsTxt = generateLlmsTxt({ name, description, baseUrl, pages, hostAtDocs, noindex });
253
+ const llmsTxt = generateLlmsTxt({
254
+ name, description, baseUrl, pages, hostAtDocs, noindex,
255
+ });
248
256
  const robotsTxt = generateRobotsTxt({ baseUrl, hostAtDocs, noindex });
249
257
 
250
- return { sitemap, llmsTxt, robotsTxt };
258
+ // Generate RSS feed if any pages have rss: true
259
+ let rssFeed: string | null = null;
260
+ if (rssPages && rssPages.length > 0) {
261
+ const updates = extractUpdatesFromPages(rssPages);
262
+ if (updates.length > 0) {
263
+ rssFeed = generateRssFeed({
264
+ name, description, baseUrl, hostAtDocs, updates,
265
+ });
266
+ }
267
+ }
268
+
269
+ return { sitemap, llmsTxt, robotsTxt, rssFeed };
270
+ }
271
+
272
+ // =============================================================================
273
+ // RSS FEED GENERATION
274
+ // =============================================================================
275
+
276
+ /**
277
+ * A single update entry extracted from an MDX page.
278
+ */
279
+ export interface UpdateEntry {
280
+ label: string;
281
+ description?: string;
282
+ tags?: string[];
283
+ date?: string;
284
+ content: string;
285
+ pagePath: string;
286
+ pageTitle: string;
287
+ }
288
+
289
+ /**
290
+ * Options for generating an RSS feed.
291
+ */
292
+ export interface RssFeedOptions {
293
+ name: string;
294
+ description?: string;
295
+ baseUrl: string;
296
+ hostAtDocs?: boolean;
297
+ updates: UpdateEntry[];
298
+ }
299
+
300
+ /**
301
+ * Raw page info with content for RSS extraction.
302
+ */
303
+ export interface RssPageInfo {
304
+ path: string;
305
+ content: string;
306
+ frontmatter: {
307
+ title?: string;
308
+ rss?: boolean;
309
+ };
251
310
  }
252
311
 
312
+ /**
313
+ * Escape XML special characters.
314
+ */
315
+ function escapeXml(text: string): string {
316
+ return text
317
+ .replace(/&/g, '&amp;')
318
+ .replace(/</g, '&lt;')
319
+ .replace(/>/g, '&gt;')
320
+ .replace(/"/g, '&quot;')
321
+ .replace(/'/g, '&apos;');
322
+ }
323
+
324
+ /**
325
+ * Generate a URL-friendly slug from a label string.
326
+ * Same algorithm as generateUpdateId in Update.tsx.
327
+ */
328
+ function labelToSlug(label: string): string {
329
+ return label
330
+ .toLowerCase()
331
+ .replace(/[^a-z0-9]+/g, '-')
332
+ .replace(/^-+|-+$/g, '');
333
+ }
334
+
335
+ /**
336
+ * Extract Update component entries from MDX pages with rss: true.
337
+ */
338
+ export function extractUpdatesFromPages(pages: RssPageInfo[]): UpdateEntry[] {
339
+ const updates: UpdateEntry[] = [];
340
+
341
+ for (const page of pages) {
342
+ if (!page.frontmatter.rss) continue;
343
+
344
+ const pagePath = page.path.replace(/\.mdx?$/, '');
345
+ const pageTitle = page.frontmatter.title || pagePath;
346
+
347
+ // Match <Update ...props>content</Update>
348
+ // Quoted strings come first in alternation so > inside quotes is consumed correctly
349
+ const updateRegex = /<Update\s+((?:"[^"]*"|'[^']*'|[^>])*?)>([\s\S]*?)<\/Update>/g;
350
+ let match;
351
+
352
+ while ((match = updateRegex.exec(page.content)) !== null) {
353
+ const attrsStr = match[1];
354
+ const content = match[2].trim();
355
+
356
+ // Parse label
357
+ const labelMatch = attrsStr.match(/label=["']([^"']+)["']/);
358
+ if (!labelMatch) continue; // Skip updates without label
359
+
360
+ const label = labelMatch[1];
361
+
362
+ // Parse description
363
+ const descMatch = attrsStr.match(/description=["']([^"']+)["']/);
364
+ const description = descMatch ? descMatch[1] : undefined;
365
+
366
+ // Parse date
367
+ const dateMatch = attrsStr.match(/date=["']([^"']+)["']/);
368
+ const date = dateMatch ? dateMatch[1] : undefined;
369
+
370
+ // Parse tags - handles tags={["feature", "breaking"]}
371
+ let tags: string[] | undefined;
372
+ const tagsMatch = attrsStr.match(/tags=\{(\[[\s\S]*?\])}/);
373
+ if (tagsMatch) {
374
+ try {
375
+ tags = JSON.parse(tagsMatch[1].replace(/'/g, '"'));
376
+ } catch {
377
+ // Ignore malformed tags
378
+ }
379
+ }
380
+
381
+ updates.push({
382
+ label, description, tags, date, content, pagePath, pageTitle,
383
+ });
384
+ }
385
+ }
386
+
387
+ return updates;
388
+ }
389
+
390
+ /**
391
+ * Generate RSS 2.0 XML feed from update entries.
392
+ */
393
+ export function generateRssFeed(options: RssFeedOptions): string {
394
+ const {
395
+ name, description, baseUrl, hostAtDocs = false, updates,
396
+ } = options;
397
+
398
+ const urlPrefix = hostAtDocs ? '/docs' : '';
399
+ const feedUrl = `${baseUrl}${urlPrefix}/feed.xml`;
400
+ const siteLink = `${baseUrl}${urlPrefix}`;
401
+ const channelTitle = `${escapeXml(name)} Changelog`;
402
+ const channelDesc = description
403
+ ? escapeXml(description)
404
+ : `Updates and changelog for ${escapeXml(name)}`;
405
+
406
+ const items = updates.map(update => {
407
+ const slug = labelToSlug(update.label);
408
+ const link = `${baseUrl}${urlPrefix}/${update.pagePath}#${slug}`;
409
+ const title = update.description
410
+ ? `${escapeXml(update.label)} \u2014 ${escapeXml(update.description)}`
411
+ : escapeXml(update.label);
412
+
413
+ // Strip markdown and truncate content for description
414
+ let desc = stripMarkdown(update.content);
415
+ if (desc.length > 500) {
416
+ desc = desc.substring(0, 497) + '...';
417
+ }
418
+
419
+ let pubDateLine = '';
420
+ if (update.date) {
421
+ const d = new Date(update.date + 'T00:00:00Z');
422
+ if (!isNaN(d.getTime())) {
423
+ pubDateLine =
424
+ ` <pubDate>${d.toUTCString()}</pubDate>\n`;
425
+ }
426
+ }
427
+
428
+ return ` <item>
429
+ <title>${title}</title>
430
+ <link>${link}</link>
431
+ <guid isPermaLink="true">${link}</guid>
432
+ <description>${escapeXml(desc)}</description>
433
+ ${pubDateLine} </item>`;
434
+ });
435
+
436
+ return `<?xml version="1.0" encoding="UTF-8"?>
437
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
438
+ <channel>
439
+ <title>${channelTitle}</title>
440
+ <link>${siteLink}</link>
441
+ <description>${channelDesc}</description>
442
+ <atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />
443
+ ${items.join('\n')}
444
+ </channel>
445
+ </rss>`;
446
+ }
447
+
448
+ // =============================================================================
449
+ // SEARCH INDEX
450
+ // =============================================================================
451
+
253
452
  /**
254
453
  * Search document entry.
255
454
  */
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Shared handler for static file routes (sitemap.xml, robots.txt, llms.txt, search-data.json).
2
+ * Shared handler for static file routes (sitemap.xml, robots.txt, llms.txt, search-data.json, feed.xml).
3
3
  *
4
4
  * All static file routes follow the same pattern: check ISR mode, extract project slug,
5
5
  * fetch from R2, and return with appropriate headers. This helper eliminates the
6
- * duplication across 8 route files (4 at root + 4 at /docs).
6
+ * duplication across 10 route files (5 at root + 5 at /docs).
7
7
  */
8
8
 
9
9
  import { NextRequest, NextResponse } from 'next/server';
@@ -31,12 +31,17 @@ function getContentType(filename: string): string {
31
31
  *
32
32
  * @param filename - The R2 filename (e.g., 'sitemap.xml', 'search-data.json')
33
33
  * @param label - Human-readable label for log messages (e.g., 'Sitemap', 'Search data')
34
+ * @param contentTypeOverride - Override the inferred content type (e.g., 'application/rss+xml')
35
+ * @param cacheControlOverride - Override the default Cache-Control header
34
36
  */
35
37
  export function createStaticFileHandler(
36
38
  filename: string,
37
- label: string
39
+ label: string,
40
+ contentTypeOverride?: string,
41
+ cacheControlOverride?: string
38
42
  ): (request: NextRequest) => Promise<NextResponse> {
39
- const contentType = getContentType(filename);
43
+ const contentType = contentTypeOverride || getContentType(filename);
44
+ const cacheControl = cacheControlOverride || 'public, max-age=3600, s-maxage=86400';
40
45
 
41
46
  return async function GET(request: NextRequest): Promise<NextResponse> {
42
47
  if (!isIsrMode()) {
@@ -61,7 +66,7 @@ export function createStaticFileHandler(
61
66
  return new NextResponse(content, {
62
67
  headers: {
63
68
  'Content-Type': contentType,
64
- 'Cache-Control': 'public, max-age=3600, s-maxage=86400',
69
+ 'Cache-Control': cacheControl,
65
70
  },
66
71
  });
67
72
  } catch (error) {
@@ -31,6 +31,7 @@ export interface DocsConfig {
31
31
  export interface IntegrationsConfig {
32
32
  ga4?: { measurementId: string };
33
33
  gtm?: { tagId: string };
34
+ plausible?: { domain?: string; server?: string; scriptUrl?: string };
34
35
  [key: string]: unknown;
35
36
  }
36
37