spec-up-t-healthcheck 1.1.0 → 1.1.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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * @fileoverview Markdown table validation health check module
3
+ *
4
+ * This module validates markdown tables in specification files, checking for:
5
+ * - Proper table structure (header, separator, body rows)
6
+ * - Consistent column counts across all rows
7
+ * - Valid separator line syntax
8
+ * - Problematic characters that can break table parsing
9
+ * - Mismatched quotes or backticks in table cells
10
+ *
11
+ * This validator catches errors that can cause issues with markdown-it-attrs
12
+ * and other markdown table parsing plugins.
13
+ *
14
+ * @author spec-up-t-healthcheck
15
+ */
16
+
17
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
18
+
19
+ /**
20
+ * The identifier for this health check, used in reports and registries.
21
+ * @type {string}
22
+ */
23
+ export const CHECK_ID = 'markdown-tables';
24
+
25
+ /**
26
+ * Human-readable name for this health check.
27
+ * @type {string}
28
+ */
29
+ export const CHECK_NAME = 'Markdown Table Validation';
30
+
31
+ /**
32
+ * Description of what this health check validates.
33
+ * @type {string}
34
+ */
35
+ export const CHECK_DESCRIPTION = 'Validates markdown table structure and syntax';
36
+
37
+ /**
38
+ * Represents a table found in a markdown file.
39
+ * @typedef {Object} TableInfo
40
+ * @property {string} file - Path to the file containing the table
41
+ * @property {number} startLine - Line number where the table starts
42
+ * @property {number} endLine - Line number where the table ends
43
+ * @property {string[]} content - Array of table lines
44
+ * @property {number} headerColumns - Number of columns in header
45
+ * @property {number} separatorColumns - Number of columns in separator
46
+ * @property {boolean} hasSeparator - Whether table has a separator line
47
+ * @property {boolean} columnMismatch - Whether column counts don't match
48
+ * @property {Object[]} issues - Array of specific issues found
49
+ */
50
+
51
+ /**
52
+ * Extracts all tables from markdown content.
53
+ *
54
+ * This function parses markdown content line by line and identifies table structures.
55
+ * Tables are detected by lines containing pipe characters (|).
56
+ *
57
+ * @param {string} content - The markdown content to parse
58
+ * @param {string} filePath - Path to the file (for error reporting)
59
+ * @returns {TableInfo[]} Array of table information objects
60
+ */
61
+ function extractTables(content, filePath) {
62
+ const lines = content.split('\n');
63
+ const tables = [];
64
+ let inTable = false;
65
+ let tableStartLine = 0;
66
+ let tableContent = [];
67
+
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i];
70
+ const trimmedLine = line.trim();
71
+
72
+ // Detect table start (line with pipes)
73
+ if (trimmedLine.match(/^\|.*\|/)) {
74
+ if (!inTable) {
75
+ inTable = true;
76
+ tableStartLine = i + 1; // 1-indexed for user display
77
+ tableContent = [];
78
+ }
79
+ tableContent.push({ lineNum: i + 1, content: line });
80
+ } else if (inTable && (trimmedLine === '' || !line.includes('|'))) {
81
+ // Table ended - analyze and store it
82
+ if (tableContent.length > 0) {
83
+ const tableInfo = analyzeTable(tableContent, tableStartLine, filePath);
84
+ tables.push(tableInfo);
85
+ }
86
+ inTable = false;
87
+ } else if (inTable && line.includes('|')) {
88
+ // Continuation of table (might be in code block or other context)
89
+ tableContent.push({ lineNum: i + 1, content: line });
90
+ }
91
+ }
92
+
93
+ // Handle table at end of file
94
+ if (inTable && tableContent.length > 0) {
95
+ const tableInfo = analyzeTable(tableContent, tableStartLine, filePath);
96
+ tables.push(tableInfo);
97
+ }
98
+
99
+ return tables;
100
+ }
101
+
102
+ /**
103
+ * Analyzes a single table for structural issues.
104
+ *
105
+ * This function performs comprehensive validation of table structure:
106
+ * - Counts columns in each row
107
+ * - Validates separator line format
108
+ * - Checks for problematic characters
109
+ * - Detects mismatched quotes and backticks
110
+ *
111
+ * @param {Array<{lineNum: number, content: string}>} tableLines - Array of table lines
112
+ * @param {number} startLine - Starting line number
113
+ * @param {string} filePath - Path to the file
114
+ * @returns {TableInfo} Analyzed table information
115
+ */
116
+ function analyzeTable(tableLines, startLine, filePath) {
117
+ const issues = [];
118
+
119
+ // Extract header and separator
120
+ const headerLine = tableLines[0];
121
+ const separatorLine = tableLines.length > 1 ? tableLines[1] : null;
122
+
123
+ // Count columns
124
+ const headerColumns = countColumns(headerLine.content);
125
+ const separatorColumns = separatorLine ? countColumns(separatorLine.content) : 0;
126
+ const hasSeparator = separatorLine && isSeparatorLine(separatorLine.content);
127
+ const columnMismatch = hasSeparator && (headerColumns !== separatorColumns);
128
+
129
+ // Check each row for issues
130
+ tableLines.forEach((line, idx) => {
131
+ const rowIssues = validateTableRow(line.content, line.lineNum, idx === 1);
132
+ issues.push(...rowIssues);
133
+ });
134
+
135
+ // Validate separator line specifically
136
+ if (separatorLine && !hasSeparator) {
137
+ issues.push({
138
+ type: 'missing-separator',
139
+ line: separatorLine.lineNum,
140
+ message: 'Table separator line is missing or malformed (should contain only |, -, and :)',
141
+ content: separatorLine.content
142
+ });
143
+ }
144
+
145
+ // Check for column count mismatches
146
+ if (columnMismatch) {
147
+ issues.push({
148
+ type: 'column-mismatch',
149
+ line: startLine,
150
+ message: `Column count mismatch: header has ${headerColumns} columns, separator has ${separatorColumns}`,
151
+ content: `Header: ${headerLine.content}\nSeparator: ${separatorLine.content}`
152
+ });
153
+ }
154
+
155
+ // Validate body row column counts
156
+ if (hasSeparator && tableLines.length > 2) {
157
+ const expectedColumns = headerColumns;
158
+ for (let i = 2; i < tableLines.length; i++) {
159
+ const rowColumns = countColumns(tableLines[i].content);
160
+ if (rowColumns !== expectedColumns) {
161
+ issues.push({
162
+ type: 'row-column-mismatch',
163
+ line: tableLines[i].lineNum,
164
+ message: `Row has ${rowColumns} columns, expected ${expectedColumns}`,
165
+ content: tableLines[i].content
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ return {
172
+ file: filePath,
173
+ startLine,
174
+ endLine: tableLines[tableLines.length - 1].lineNum,
175
+ content: tableLines.map(l => l.content),
176
+ headerColumns,
177
+ separatorColumns,
178
+ hasSeparator,
179
+ columnMismatch,
180
+ issues
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Counts the number of columns in a table row.
186
+ *
187
+ * Columns are delimited by pipe characters (|). The count excludes
188
+ * leading and trailing pipes.
189
+ *
190
+ * @param {string} line - The table row line
191
+ * @returns {number} Number of columns
192
+ */
193
+ function countColumns(line) {
194
+ // Remove leading/trailing whitespace and pipes
195
+ const trimmed = line.trim();
196
+ if (!trimmed) return 0;
197
+
198
+ // Count pipe separators (excluding leading/trailing)
199
+ const withoutEnds = trimmed.replace(/^\|/, '').replace(/\|$/, '');
200
+ const pipes = (withoutEnds.match(/\|/g) || []).length;
201
+
202
+ return pipes + 1;
203
+ }
204
+
205
+ /**
206
+ * Checks if a line is a valid table separator.
207
+ *
208
+ * A valid separator line contains only pipes, hyphens, colons, and whitespace.
209
+ * Format: |:---:|:---:| or |---|---| etc.
210
+ *
211
+ * @param {string} line - The line to check
212
+ * @returns {boolean} True if the line is a valid separator
213
+ */
214
+ function isSeparatorLine(line) {
215
+ const trimmed = line.trim();
216
+ // Valid separator contains only: |, -, :, and whitespace
217
+ return /^[\s|:\-]+$/.test(trimmed) && trimmed.includes('-');
218
+ }
219
+
220
+ /**
221
+ * Validates a single table row for common issues.
222
+ *
223
+ * This function checks for:
224
+ * - Mismatched quotes (opening quote without closing)
225
+ * - Mismatched backticks
226
+ * - Problematic character combinations
227
+ * - Extra quotes inside code spans
228
+ *
229
+ * @param {string} line - The table row line
230
+ * @param {number} lineNum - Line number in file
231
+ * @param {boolean} isSeparator - Whether this is the separator row
232
+ * @returns {Array<Object>} Array of issues found
233
+ */
234
+ function validateTableRow(line, lineNum, isSeparator) {
235
+ const issues = [];
236
+
237
+ // Skip validation for separator lines
238
+ if (isSeparator) {
239
+ return issues;
240
+ }
241
+
242
+ // Split line into cells
243
+ const cells = line.split('|').slice(1, -1); // Remove first/last empty elements
244
+
245
+ cells.forEach((cell, cellIdx) => {
246
+ const trimmedCell = cell.trim();
247
+
248
+ // Check for problematic quote patterns in code spans
249
+ // Pattern: `'text or `text'
250
+ const backtickQuotePattern = /`['"]|['"]`/g;
251
+ const backtickWithQuote = trimmedCell.match(backtickQuotePattern);
252
+ if (backtickWithQuote) {
253
+ issues.push({
254
+ type: 'quote-in-code',
255
+ line: lineNum,
256
+ cell: cellIdx + 1,
257
+ message: `Cell ${cellIdx + 1} contains potentially problematic quote/backtick combination: ${backtickWithQuote.join(', ')}`,
258
+ content: cell,
259
+ severity: 'warning'
260
+ });
261
+ }
262
+
263
+ // Check for mismatched backticks in cell
264
+ const backticks = (trimmedCell.match(/`/g) || []).length;
265
+ if (backticks % 2 !== 0) {
266
+ issues.push({
267
+ type: 'mismatched-backticks',
268
+ line: lineNum,
269
+ cell: cellIdx + 1,
270
+ message: `Cell ${cellIdx + 1} has mismatched backticks`,
271
+ content: cell,
272
+ severity: 'error'
273
+ });
274
+ }
275
+
276
+ // Check for quote patterns that might indicate typos
277
+ // Pattern: opening quote at start of backtick span
278
+ const codeSpanStartQuote = /`'[^'`]*'?`/g;
279
+ const matches = trimmedCell.match(codeSpanStartQuote);
280
+ if (matches) {
281
+ // Check if any match has quotes both at start AND end inside backticks
282
+ matches.forEach(match => {
283
+ if (match.match(/`'.*'`/)) {
284
+ // This is likely intentional (quoted string in code)
285
+ return;
286
+ }
287
+ // Only opening quote inside backticks - likely error
288
+ if (match.match(/`'[^']*`$/)) {
289
+ issues.push({
290
+ type: 'likely-typo',
291
+ line: lineNum,
292
+ cell: cellIdx + 1,
293
+ message: `Cell ${cellIdx + 1} has likely typo: opening quote inside backticks without matching closing quote`,
294
+ content: cell,
295
+ example: match,
296
+ severity: 'error'
297
+ });
298
+ }
299
+ });
300
+ }
301
+
302
+ // Check for attributes syntax {...} which requires careful table structure
303
+ if (trimmedCell.includes('{') && trimmedCell.includes('}')) {
304
+ const hasAttributes = /\{[.#][^\}]+\}/.test(trimmedCell);
305
+ if (hasAttributes) {
306
+ issues.push({
307
+ type: 'has-attributes',
308
+ line: lineNum,
309
+ cell: cellIdx + 1,
310
+ message: `Cell ${cellIdx + 1} contains attribute syntax {.class} or {#id} - ensure table structure is correct`,
311
+ content: cell,
312
+ severity: 'info'
313
+ });
314
+ }
315
+ }
316
+ });
317
+
318
+ return issues;
319
+ }
320
+
321
+ /**
322
+ * Checks markdown tables in specification files.
323
+ *
324
+ * This health check validates all markdown tables found in specification files,
325
+ * ensuring proper structure and catching common errors that can cause parsing issues.
326
+ *
327
+ * The check performs the following validations:
328
+ * - Table header and separator presence
329
+ * - Consistent column counts across rows
330
+ * - Valid separator line syntax
331
+ * - Detection of problematic characters
332
+ * - Validation of quotes and backticks in cells
333
+ *
334
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
335
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} The health check result
336
+ *
337
+ * @example
338
+ * ```javascript
339
+ * const provider = createLocalProvider('/path/to/repo');
340
+ * const result = await checkMarkdownTables(provider);
341
+ * console.log(result.status); // 'pass', 'warning', or 'error'
342
+ * ```
343
+ */
344
+ export async function checkMarkdownTables(provider) {
345
+ try {
346
+ // Discover specification files
347
+ const specFiles = await discoverSpecificationFiles(provider);
348
+
349
+ if (specFiles.length === 0) {
350
+ return createHealthCheckResult(
351
+ CHECK_ID,
352
+ 'warn',
353
+ 'No specification files found to validate tables',
354
+ {
355
+ filesChecked: 0,
356
+ tablesFound: 0,
357
+ tablesWithIssues: 0
358
+ }
359
+ );
360
+ }
361
+
362
+ let totalTables = 0;
363
+ let tablesWithIssues = 0;
364
+ let totalIssues = 0;
365
+ const fileResults = [];
366
+
367
+ // Check each file for tables
368
+ for (const filePath of specFiles) {
369
+ try {
370
+ const content = await provider.readFile(filePath);
371
+ const tables = extractTables(content, filePath);
372
+
373
+ totalTables += tables.length;
374
+
375
+ // Analyze tables for issues
376
+ const tablesWithProblems = tables.filter(t => t.issues.length > 0);
377
+ tablesWithIssues += tablesWithProblems.length;
378
+ totalIssues += tablesWithProblems.reduce((sum, t) => sum + t.issues.length, 0);
379
+
380
+ if (tablesWithProblems.length > 0) {
381
+ fileResults.push({
382
+ file: filePath,
383
+ tablesCount: tables.length,
384
+ tablesWithIssues: tablesWithProblems.length,
385
+ tables: tablesWithProblems
386
+ });
387
+ }
388
+ } catch (error) {
389
+ console.warn(`Could not read file ${filePath}:`, error.message);
390
+ }
391
+ }
392
+
393
+ // Determine status
394
+ let status = 'pass';
395
+ let message = `All ${totalTables} tables are valid`;
396
+
397
+ if (tablesWithIssues > 0) {
398
+ // Check if any issues are errors vs warnings
399
+ const hasErrors = fileResults.some(fr =>
400
+ fr.tables.some(t =>
401
+ t.issues.some(i => i.severity === 'error' || i.type === 'column-mismatch' || i.type === 'missing-separator')
402
+ )
403
+ );
404
+
405
+ status = hasErrors ? 'fail' : 'warn';
406
+ message = `Found ${totalIssues} issue(s) in ${tablesWithIssues} of ${totalTables} tables`;
407
+ }
408
+
409
+ return createHealthCheckResult(
410
+ CHECK_ID,
411
+ status,
412
+ message,
413
+ {
414
+ filesChecked: specFiles.length,
415
+ tablesFound: totalTables,
416
+ tablesWithIssues,
417
+ totalIssues,
418
+ details: fileResults
419
+ }
420
+ );
421
+
422
+ } catch (error) {
423
+ return createErrorResult(
424
+ CHECK_ID,
425
+ `Failed to validate markdown tables: ${error.message}`,
426
+ { error: error.message, stack: error.stack }
427
+ );
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Discovers specification files to check for tables.
433
+ *
434
+ * This function searches for markdown files in common spec directories
435
+ * and the repository root.
436
+ *
437
+ * @param {import('../providers.js').Provider} provider - The provider instance
438
+ * @returns {Promise<string[]>} Array of file paths to check
439
+ * @private
440
+ */
441
+ async function discoverSpecificationFiles(provider) {
442
+ const files = [];
443
+ const searchPaths = ['spec/', 'specs/', 'docs/', ''];
444
+
445
+ for (const searchPath of searchPaths) {
446
+ try {
447
+ const entries = await provider.listFiles(searchPath);
448
+
449
+ // Filter for markdown files only (not subdirectories)
450
+ const mdFiles = entries.filter(entry =>
451
+ entry.isFile &&
452
+ (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))
453
+ );
454
+
455
+ // Use the full path from the entry
456
+ files.push(...mdFiles.map(entry => entry.path));
457
+ } catch (error) {
458
+ // Directory doesn't exist or can't be read, continue
459
+ }
460
+ }
461
+
462
+ return [...new Set(files)]; // Remove duplicates
463
+ }
@@ -341,17 +341,14 @@ function validateFieldTypes(spec, results) {
341
341
 
342
342
  // Validate source object structure
343
343
  if (spec.source && typeof spec.source === 'object') {
344
- const requiredSourceFields = ['host', 'account', 'repo', 'branch'];
344
+ // Note: 'branch' field is no longer required as it's obtained from the spec-up-t:github-repo-info meta tag
345
+ const requiredSourceFields = ['host', 'account', 'repo'];
345
346
  let sourceValid = true;
346
347
 
347
348
  requiredSourceFields.forEach(field => {
348
349
  if (!(field in spec.source) || !spec.source[field]) {
349
- if (field === 'branch') {
350
- results.warnings.push(`Source field "${field}" is missing or empty`);
351
- } else {
352
- results.errors.push(`Source field "${field}" is missing or empty`);
353
- sourceValid = false;
354
- }
350
+ results.errors.push(`Source field "${field}" is missing or empty`);
351
+ sourceValid = false;
355
352
  }
356
353
  });
357
354
 
@@ -364,7 +361,7 @@ function validateFieldTypes(spec, results) {
364
361
  if (spec.external_specs) {
365
362
  if (Array.isArray(spec.external_specs)) {
366
363
  spec.external_specs.forEach((extSpec, index) => {
367
- const requiredExtFields = ['external_spec', 'gh_page', 'url', 'terms_dir'];
364
+ const requiredExtFields = ['external_spec', 'gh_page', 'url'];
368
365
  requiredExtFields.forEach(field => {
369
366
  if (!(field in extSpec) || !extSpec[field]) {
370
367
  results.errors.push(`External spec ${index} missing "${field}"`);
@@ -156,6 +156,12 @@ export function formatResultDetails(details) {
156
156
  return html;
157
157
  }
158
158
 
159
+ // Special handling for markdown-tables check
160
+ if (details.details && Array.isArray(details.details)) {
161
+ html += formatMarkdownTablesDetails(details);
162
+ return html;
163
+ }
164
+
159
165
  // Display errors array with clickable URLs
160
166
  // Errors are shown with strong red styling to draw immediate attention
161
167
  if (details.errors && details.errors.length > 0) {
@@ -372,6 +378,54 @@ export function formatConsoleMessagesTable(details) {
372
378
  return html;
373
379
  }
374
380
 
381
+ /**
382
+ * Formats markdown tables details into HTML.
383
+ *
384
+ * @param {Object} details - Details object containing markdown tables data
385
+ * @returns {string} HTML string with formatted table issues
386
+ */
387
+ export function formatMarkdownTablesDetails(details) {
388
+ const { details: fileResults } = details;
389
+
390
+ let html = '<div class="mt-2">';
391
+ html += '<div class="table-responsive">';
392
+ html += '<table class="table table-sm table-striped">';
393
+ html += '<thead class="table-light">';
394
+ html += '<tr>';
395
+ html += '<th>File</th>';
396
+ html += '<th>Line</th>';
397
+ html += '<th>Issue</th>';
398
+ html += '<th>Content</th>';
399
+ html += '</tr>';
400
+ html += '</thead>';
401
+ html += '<tbody>';
402
+
403
+ fileResults.forEach(fileResult => {
404
+ fileResult.tables.forEach(table => {
405
+ table.issues.forEach(issue => {
406
+ const severityClass = issue.severity === 'error' ? 'text-danger' :
407
+ issue.severity === 'warning' ? 'text-warning' : 'text-info';
408
+ const severityIcon = issue.severity === 'error' ? 'bi-exclamation-triangle-fill' :
409
+ issue.severity === 'warning' ? 'bi-exclamation-circle-fill' : 'bi-info-circle-fill';
410
+
411
+ html += '<tr>';
412
+ html += `<td><small><code>${escapeHtml(fileResult.file)}</code></small></td>`;
413
+ html += `<td><small>${issue.line}</small></td>`;
414
+ html += `<td><small class="${severityClass}"><i class="bi ${severityIcon}"></i> ${escapeHtml(issue.message)}</small></td>`;
415
+ html += `<td><small class="text-muted">${escapeHtml(issue.content || '')}</small></td>`;
416
+ html += '</tr>';
417
+ });
418
+ });
419
+ });
420
+
421
+ html += '</tbody>';
422
+ html += '</table>';
423
+ html += '</div>';
424
+ html += '</div>';
425
+
426
+ return html;
427
+ }
428
+
375
429
  /**
376
430
  * Formats a single health check result into a table row HTML string.
377
431
  *
package/lib/formatters.js CHANGED
@@ -98,6 +98,20 @@ export function formatResultsAsText(healthCheckOutput, useColors = false) {
98
98
  if (result.details.packageData) {
99
99
  output.push(` Package: ${result.details.packageData.name}@${result.details.packageData.version}`);
100
100
  }
101
+ // Special handling for markdown-tables check
102
+ if (result.check === 'markdown-tables' && result.details.details) {
103
+ const fileResults = result.details.details;
104
+ fileResults.forEach(fileResult => {
105
+ output.push(` 📄 ${fileResult.file}:`);
106
+ fileResult.tables.forEach(table => {
107
+ output.push(` Table at line ${table.startLine}:`);
108
+ table.issues.forEach(issue => {
109
+ const severityIcon = issue.severity === 'error' ? '❌' : issue.severity === 'warning' ? 'âš ī¸' : 'â„šī¸';
110
+ output.push(` ${severityIcon} Line ${issue.line}: ${issue.message}`);
111
+ });
112
+ });
113
+ });
114
+ }
101
115
  }
102
116
 
103
117
  output.push('');
@@ -253,6 +253,7 @@ export class HealthCheckRegistry {
253
253
  const gitignoreModule = await import('./checks/gitignore.js');
254
254
  const specDirectoryAndFilesModule = await import('./checks/spec-directory-and-files.js');
255
255
  const consoleMessagesModule = await import('./checks/console-messages.js');
256
+ const markdownTablesModule = await import('./checks/markdown-tables.js');
256
257
 
257
258
  // Only import link-checker in Node.js environments (not browsers)
258
259
  // Link checker requires linkinator which uses Node.js streams
@@ -349,6 +350,18 @@ export class HealthCheckRegistry {
349
350
  });
350
351
  }
351
352
 
353
+ // Register markdown tables check
354
+ if (markdownTablesModule.checkMarkdownTables && markdownTablesModule.CHECK_ID) {
355
+ this.register({
356
+ id: markdownTablesModule.CHECK_ID,
357
+ name: markdownTablesModule.CHECK_NAME || 'Markdown Table Validation',
358
+ description: markdownTablesModule.CHECK_DESCRIPTION || 'Validates markdown table structure and syntax',
359
+ checkFunction: markdownTablesModule.checkMarkdownTables,
360
+ category: 'content',
361
+ priority: 25 // Run after spec files discovery, before external validations
362
+ });
363
+ }
364
+
352
365
  // Register link checker (Node.js only - not available in browsers)
353
366
  if (linkCheckerModule && linkCheckerModule.checkLinks && linkCheckerModule.CHECK_ID) {
354
367
  this.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-up-t-healthcheck",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Modular health check tool for spec-up-t repositories - works in Node.js, browsers, and CLI",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",