// Name: Cron Expression Validator // Description: Validates and helps you build Crontab expressions // Shortcode: cron // Author: @JosXa, loosely based on Ricardo Gonçalves Bassete's version import "@johnlindquist/kit" import { computed, effect, signal } from "@preact/signals-core" import cronstrue from "cronstrue" import { markdownTable } from "markdown-table" const FONT_SIZE = "0.8em" const allowedCharsTable = markdownTable( [ ["Character", "Meaning"], ["`*`", "any value"], ["`,`", "value list separator"], ["`-`", "range of values"], ["`/`", 'step values (e.g. `*/5 * * * *` for "every 5 minutes")'], ], { align: "l" }, ) const tableHtml = md(allowedCharsTable).replace('<th align="left">', '<th align="left" style="width: 17%">') const input = signal("* * * * *") const parts = computed(() => input.value.split(" ")) const parsedExpression = computed(() => { try { return cronstrue.toString(input.value) } catch (err) { return undefined } }) const isValid = computed(() => !!parsedExpression.value) const asciiHint = computed(() => { if (!input.value) { return ` | | | | | | | | | +----- day of the week (0 - 7) (Sunday = 0 or 7) | | | +------- month (1 - 12) | | +--------- day of the month (1 - 31) | +----------- hour (0 - 23) +------------- minute (0 - 59) `.trim() } const hasSecond = parts.value.length >= 6 const names = [ "second (0 - 59)", "minute (0 - 59)", "hour (0 - 23)", "day of the month (1 - 31)", "month (1 - 12)", "day of the week (0 - 7) (Sunday = 0 or 7)", ] if (!hasSecond) { names.splice(0, 1) } /* 0 1 2 3 4 5 👉 partIdx 0 | | | | | +----- day of the week (0 - 7) (Sunday = 0 or 7) 1 | | | | +------- month (1 - 12) 2 | | | +--------- day of the month (1 - 31) 3 | | +----------- hour (0 - 23) 4 | +------------- minute (0 - 59) 5 +--------------- second (0 - 59) 👇 lineIdx */ const columns = parts.value.reduce( (agg, part, partIdx) => { const prev = agg[partIdx - 1] const startCol = prev ? prev.endCol + 1 : 0 const endCol = startCol + part.length const name = names[partIdx]! agg.push({ partIdx, startCol, endCol, part, gapToPrevious: Math.max(0, endCol - startCol), name, }) return agg }, [] as Array<{ startCol: number endCol: number part: string gapToPrevious: number partIdx: number name: string }>, ) const lines: string[] = [] const maxLen = columns.slice(-1)[0]!.endCol + 5 for (let lineIdx = -1; lineIdx < columns.length; lineIdx++) { let line = "" for (const { gapToPrevious, partIdx, name } of columns) { if (lineIdx === -1) { line += "|" line += " ".repeat(gapToPrevious) continue } if (partIdx + lineIdx === columns.length - 1) { line += "+" line = line.padEnd(maxLen, "-") line += ` ${name}` break } line += "|" line += " ".repeat(gapToPrevious) } lines.push(line) } return lines.join("\n") }) const asciiHintHtml = computed(() => `<div style="font-size: ${FONT_SIZE};" class="px-4"><pre>${asciiHint}</pre></div>`.trim(), ) const resultMessage = computed(() => { if (!input.value) { return "" } return ( "<br>" + (parsedExpression.value ? `<h3 class="px-4" style="color: rgba(var(--color-primary), var(--tw-text-opacity))">👉 ${parsedExpression.value}</h3>` : `<h3 class="px-4" style="color: #f65671">❌ The expression "${input.value}" cannot be parsed.</h3>`) ) }) const enter = computed(() => (isValid.value ? "Copy" : "")) const panel = computed(() => `<div>${asciiHintHtml.value}${resultMessage.value}<br><hr>${tableHtml}</div>`) const cleanup: Array<() => void> = [] await arg({ placeholder: "Type a Crontab expression", input: input.value, className: "p-0", inputClassName: "font-mono", css: ` #input { min-width: 250px !important; font-size: ${FONT_SIZE} !important; } `, onInit() { cleanup.push(effect(() => setEnter(enter.value))) cleanup.push(effect(() => setPanel(panel.value))) }, onInput(val) { if (!val) { input.value = "" return } let sanitized = val // Replace duplicate spaces .replaceAll(/\s{2,}/g, " ") // Leading whitespace .replaceAll(/^\s+/g, "") const s = sanitized.split(" ") // Ensure maximum of 6 parts if (s.length > 6) { sanitized = s.slice(0, 6).join(" ") } if (val !== sanitized) { setInput(sanitized) } input.value = sanitized }, enter: enter.value, alwaysOnTop: true, }) cleanup.forEach((fn) => fn()) await clipboard.writeText(input.value)