Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | 65x 65x 65x 65x | import type { LintIssue, LintResult } from '@/layers/interfaces'
// Mirrors api/internal/handlers/project_config.go LintProjectConfig exactly:
// same rule semantics, same message strings, same emission order (keys are
// visited in sorted order like the API's sortedObjectKeys; required-key
// issues are emitted before per-key checks like the API's loop order). The
// lint is wired to the project `config` field, so `parsed` is the full
// project config object ({ vikunja?: { url?, project_id?, view_id? } }).
// Undeclared root keys, a non-object vikunja value, undeclared vikunja
// keys, missing required keys, non-string (including null) or empty url /
// project_id values, url values that are not absolute http(s) URLs with a
// non-empty hostname, non-numeric project_id values, and non-positive-
// integer view_id values are errors. A config without vikunja yields no
// issues. The API's "config: must be valid JSON" issue cannot occur here —
// the YAML editor parses before linting.
const CONFIG_ROOT_KEYS = new Set(['vikunja'])
const VIKUNJA_CONFIG_KEYS = ['url', 'project_id', 'view_id'] as const
const VIKUNJA_REQUIRED_KEYS = ['project_id', 'url', 'view_id'] as const
const PROJECT_ID_PATTERN = /^[0-9]+$/
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function error(field: string, message: string): LintIssue {
return { field, message }
}
// Mirrors the API's url lint: a parsed absolute http(s) URL with a
// non-empty hostname. URL parsing normalizes the scheme, so the raw value
// must start with the parsed scheme + "://" — "HTTP://x" must not pass
// even though its protocol parses as http.
function isAbsoluteHttpUrl(value: string): boolean {
let parsed: URL
try {
parsed = new URL(value)
} catch {
return false
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
if (parsed.hostname === '') return false
return value.startsWith(parsed.protocol.slice(0, -1) + '://')
}
export function projectConfigLint(parsed: unknown, _raw: string): LintResult {
const errors: LintIssue[] = []
const warnings: LintIssue[] = []
if (!isPlainObject(parsed)) {
return { errors: [error('config', 'config: must be a JSON object')], warnings }
}
for (const key of Object.keys(parsed).sort()) {
if (!CONFIG_ROOT_KEYS.has(key)) {
errors.push(error('config', `config: unknown key "${key}"`))
}
}
const vikunja = parsed['vikunja']
if (vikunja === undefined) {
return { errors, warnings }
}
if (!isPlainObject(vikunja)) {
errors.push(error('vikunja', 'vikunja: must be an object'))
return { errors, warnings }
}
for (const key of Object.keys(vikunja).sort()) {
if (!VIKUNJA_CONFIG_KEYS.includes(key as (typeof VIKUNJA_CONFIG_KEYS)[number])) {
errors.push(error('vikunja', `vikunja: unknown key "${key}"`))
}
}
for (const key of VIKUNJA_REQUIRED_KEYS) {
if (vikunja[key] === undefined) {
errors.push(error(`vikunja.${key}`, `vikunja.${key}: required`))
}
}
for (const key of VIKUNJA_CONFIG_KEYS) {
const value = vikunja[key]
if (value === undefined) continue
if (key === 'view_id') {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
errors.push(error('vikunja.view_id', 'vikunja.view_id: must be a positive integer'))
}
continue
}
if (typeof value !== 'string') {
errors.push(error(`vikunja.${key}`, `vikunja.${key}: must be a string`))
} else if (value === '') {
errors.push(error(`vikunja.${key}`, `vikunja.${key}: must be a non-empty string`))
} else if (key === 'url' && !isAbsoluteHttpUrl(value)) {
errors.push(error('vikunja.url', 'vikunja.url: must be an absolute http(s) URL'))
} else if (key === 'project_id' && !PROJECT_ID_PATTERN.test(value)) {
errors.push(error('vikunja.project_id', 'vikunja.project_id: must be a numeric string'))
}
}
return { errors, warnings }
}
|