diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..5ceb375 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,37 @@ +import esbuild from "esbuild"; +import process from "process"; + +const watch = process.argv.includes("--watch"); + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + format: "cjs", + target: "es2020", + platform: "browser", + sourcemap: false, + outfile: "main.js", + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ], + logLevel: "info", +}); + +if (watch) { + await context.watch(); +} else { + await context.rebuild(); + await context.dispose(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2a84652 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "codeblock-crbasic", + "name": "CodeBlock CRBasic", + "version": "1.0.0", + "minAppVersion": "1.12.7", + "description": "Syntax highlighting for CRBasic (Campbell Scientific) markdown code blocks.", + "author": "samutley", + "authorUrl": "https://github.com/utleysam", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2cb640d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "codeblock-crbasic", + "version": "1.0.0", + "private": true, + "description": "Obsidian plugin for CRBasic syntax highlighting in code blocks", + "scripts": { + "build": "node esbuild.config.mjs", + "dev": "node esbuild.config.mjs --watch" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "esbuild": "^0.25.0", + "obsidian": "latest", + "typescript": "^5.7.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } +} diff --git a/src/cm6-crbasic.ts b/src/cm6-crbasic.ts new file mode 100644 index 0000000..1921ffd --- /dev/null +++ b/src/cm6-crbasic.ts @@ -0,0 +1,90 @@ +/** + * CodeMirror 6 ViewPlugin for CRBasic syntax highlighting in code blocks. + * + * Obsidian uses HyperMD which has different syntax tree node names than + * standard CM6 markdown. Instead of depending on those names, this plugin + * scans the document text for ```crbasic fences and tokenizes the content. + */ + +import { + ViewPlugin, + ViewUpdate, + Decoration, + DecorationSet, + EditorView, +} from "@codemirror/view"; +import { RangeSetBuilder } from "@codemirror/state"; +import { tokenizeLine, cmClass, TokenType } from "./tokenizer"; + +// ── Pre-built decoration cache ────────────────────────────────────── +const decoCache: Record = {}; +function getDeco(type: TokenType): Decoration { + const cls = cmClass(type); + if (!decoCache[cls]) { + decoCache[cls] = Decoration.mark({ class: cls }); + } + return decoCache[cls]; +} + +// ── ViewPlugin ────────────────────────────────────────────────────── + +class CRBasicHighlighter { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const doc = view.state.doc; + + // Scan document lines for ```crbasic blocks + let inCrbasicBlock = false; + const contentLines: { from: number; text: string }[] = []; + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const trimmed = line.text.trim().toLowerCase(); + + if (!inCrbasicBlock) { + // Check for opening fence: ```crbasic (with optional trailing whitespace) + if (/^```crbasic\s*$/i.test(line.text.trim())) { + inCrbasicBlock = true; + contentLines.length = 0; + continue; + } + } else { + // Check for closing fence + if (/^```\s*$/.test(line.text.trim())) { + // Tokenize all collected content lines and add decorations + for (const cl of contentLines) { + const tokens = tokenizeLine(cl.text, cl.from); + for (const tok of tokens) { + if (tok.from < tok.to) { + builder.add(tok.from, tok.to, getDeco(tok.type)); + } + } + } + inCrbasicBlock = false; + continue; + } + + // Collect content line + contentLines.push({ from: line.from, text: line.text }); + } + } + + return builder.finish(); + } +} + +export const crbasicHighlighter = ViewPlugin.fromClass(CRBasicHighlighter, { + decorations: (v) => v.decorations, +}); diff --git a/src/crbasic-tokens.ts b/src/crbasic-tokens.ts new file mode 100644 index 0000000..bc41984 --- /dev/null +++ b/src/crbasic-tokens.ts @@ -0,0 +1,194 @@ +/** + * Shared CRBasic token lists — single source of truth for both + * CodeMirror 6 and highlight.js highlighters. + * + * Ported from the VSCode CRBasic extension's TextMate grammar + * (daiwalkr.cr-basic-ms-vscode, v2026.5.1). + * + * CRBasic is case-insensitive; all matching should use the "i" flag. + */ + +// ── Keywords (keyword.control) ────────────────────────────────────── +export const KEYWORDS = [ + "Alias", "ArrayIndex", "BeginProg", "EndProg", "Call", "CallTable", + "Case", "Is", "Const", "ConstTable", "EndConstTable", "ContinueScan", + "Delay", "Dim", "Do", "Else", "ElseIf", "Then", "EndIf", + "EndSequence", "ESSInitialize", "EssVariables", "Exit", "ExitDo", + "ExitFor", "ExitFunction", "ExitScan", "ExitSub", "FileManage", "To", + "Function", "EndFunction", "If", "IfTime", "IIF", "Include", + "IncludeSection", "Loop", "NewFieldNames", "Optional", "Public", + "ReadOnly", "Scan", "NextScan", "Select", "EndSelect", + "SemaphoreGet", "SemaphoreRelease", "SlowSequence", "Step", "Sub", + "EndSub", "SubScan", "NextSubScan", "TriggerSequence", "Units", + "Until", "WaitDigTrig", "WaitTriggerSequence", "While", "Wend", + "For", "Next", +]; + +// ── Preprocessor directives (keyword.control.preprocessor) ────────── +// Matched WITH the leading "#" +export const PREPROCESSOR = [ + "#If", "#Else", "#ElseIf", "#EndIf", "#IfDev", "#If_no_remove", "#UnDef", +]; + +// ── Logical operators (keyword.operator.logical) ──────────────────── +export const LOGICAL_OPERATORS = [ + "AND", "IMP", "INTDV", "MOD", "NOT", "OR", "XOR", +]; + +// ── Constants (constant.character + constant.language) ─────────────── +export const CONSTANTS = [ + "As", "True", "False", + "LoggerType", "LoggerEndian", "CR_LITTLE_ENDIAN", "CR_BIG_ENDIAN", +]; + +// ── Built-in functions (entity.name.function.*) ───────────────────── +// Each sub-array corresponds to one TextMate category. + +const CDM = [ + "CDM_ACPower", "CDM_Battery", "CDM_BrFull", "CDM_BrFull6W", + "CDM_BrHalf", "CDM_BrHalf3W", "CDM_BrHalf4W", "CDM_BridgeFilt", + "CDM_Delay", "CDM_ExciteI", "CDM_ExciteV", "CDM_FFTFilt", + "CDM_MuxSelect", "CDM_PanelTemp", "CDM_PeriodAvg", "CDM_PulsePort", + "CDM_Resistance", "CDM_Resistance3W", "CDM_SW12", "CDM_SW5", + "CDM_TCComp", "CDM_TCDiff", "CDM_TCSE", "CDM_Therm107", + "CDM_Therm108", "CDM_Therm109", "CDM_VoltDiff", "CDM_VoltFilt", + "CDM_VoltSE", "CDM_VW300Config", "CDM_VW300Dynamic", + "CDM_VW300Rainflow", "CDM_VW300Static", "CPIAddModule", + "CPIFileSend", "CPISpeed", "RainFlowSample", +]; + +const CUSTOM_MENUS = [ + "DisplayLine", "DisplayMenu", "EndMenu", "DisplayValue", "MenuItem", + "MenuPick", "MenuRecompile", "SubMenu", "EndSubMenu", +]; + +const DATA_TABLE = [ + "Average", "CardFlush", "CardOut", "DataEvent", + "DataInterval", "DataTable", "EndTable", "DataTime", "ETsz", + "FFTSample", "FieldClassify", "FieldNames", "FieldOrigin", + "FileMark", "FillStop", "GOESAppend", "GOESField", "GoesTable", + "Histogram", "Histogram4D", "LevelCrossing", "Maximum", "Median", + "Minimum", "Moment", "OpenInterval", "ResetTable", "Sample", + "SampleFieldCal", "SampleMaxMin", "StdDev", "StructureType", + "EndStructureType", "TableFieldNames", "TableFile", "TableHide", + "Totalize", "WindVector", "WorstCase", +]; + +const DATALOGGER_STATUS = [ + "ApplyandRestartSequence", "EndApplyandRestartSequence", "ArrayLength", + "CalFile", "Calibrate", "CheckPort", "ClockChange", "ClockSet", + "ComPortIsActive", "Data", "DataLong", "DaylightSaving", + "DaylightSavingUS", "Debug", "DebugBreak", "Encryption", "Erase", + "EthernetPower", "FieldCal", "FieldCalStrain", "GetRecord", "GPS", + "InstructionTimes", "LineNum", "LoadFieldCal", "MonitorComms", + "Move", "MuxSelect", "NewFieldCal", "NewFile", "PipeLineMode", + "PortBridge", "PortGet", "PortPairConfig", "PortsConfig", "PortSet", + "PreserveVariables", "PulsePort", "PWM", "Read", "ReadIO", + "RealTime", "Restart", "Restore", "SDI12SensorSetup", + "SDI12SensorResponse", "SecsSince1990", "SequentialMode", + "SetSecurity", "SetSetting", "SetStatus", "ShutDownBegin", + "ShutDownEnd", "Signature", "StationName", "SW12", "SWVX", + "TimeIntoInterval", "TimeIsBetween", "Timer", "WatchdogTimer", + "WriteIO", +]; + +const FILE_IO = [ + "FileClose", "FileCopy", "FileEncrypt", "FileList", "FileManage", + "FileOpen", "FileRead", "FileReadLine", "FileRename", "FileSize", + "FileTime", "FileWrite", "GetFile", "GZip", "NewFile", "SendFile", +]; + +const INTERNET = [ + "DHCPRenew", "EmailRecv", "EmailRelay", "EmailSend", "EthernetPower", + "FTPClient", "HTTPGet", "HTTPOut", "HTTPPost", "HTTPPut", "IPInfo", + "IPNetPower", "IPRoute", "IPTrace", "MQTTConnect", "MQTTPublish", + "MQTTPublishConstTable", "MQTTPublishTable", "NetworkTimeProtocol", + "PingIP", "PPPClose", "PPPOpen", "SMSRecv", "SMSSend", + "SNMPVariable", "TCPActiveConnections", "TCPClose", "TCPOpen", + "DNSQuery", "UDPDataGram", "UDPOpen", "UDPSocketClose", + "UDPSocketOpen", "UDPSocketRecv", "UDPSocketSend", "WebPageBegin", + "WebPageEnd", "XMLParse", +]; + +const MATH = [ + "ABS", "ACOS", "AddPrecise", "AngleDegrees", "ASCII", "ASIN", + "ATN", "ATN2", "AvgRun", "AvgSpa", "Ceiling", "CheckSum", "COS", + "COSH", "Covariance", "CovSpa", "CTYPE", "DewPoint", "EXP", "FFT", + "FFTSpa", "FindSpa", "FIX", "Floor", "FRAC", "Hex", "HexToDec", + "INT", "LN", "LOG", "LOG10", "Matrix", "MaxRun", "MaxSpa", + "MinRun", "MinSpa", "MoveBytes", "MovePrecise", "PeakValley", "PRT", + "PRTCalc", "PWR", "Rainflow", "Randomize", "RectPolar", "RMSSpa", + "RND", "Round", "SatVP", "SGN", "SIN", "SINH", "SolarPosition", + "SortSpa", "SortSpaIndexed", "SQR", "StdDevRun", "StdDevSpa", + "StrainCalc", "TAN", "TANH", "TotalRun", "VaporPressure", + "WetDryBulb", +]; + +const MEASUREMENTS = [ + "ACPower", "AM25T", "AVW200", "Battery", "BrFull", "BrFull6W", + "BrHalf", "BrHalf3W", "BrHalf4W", "CS616", "CS7500", "CSAT3", + "CSAT3B", "CSAT3BMonitor", "CurrentSE", "CWB100", + "CWB100Diagnostics", "CWB100Routes", "CWB100RSSI", "EC100", + "EC100Configure", "ExciteI", "ExciteV", "HydraProbe", "LI7200", + "LI7700", "PanelTemp", "PeriodAvg", "PulseCount", "PulseCountReset", + "Quadrature", "Resistance", "Resistance3W", "SDI12Recorder", + "TCDiff", "TCSE", "TDR100", "TDR200", "TGA", "Therm107", + "Therm108", "Therm109", "TimerInput", "VoltDiff", "VoltSE", +]; + +const PAKBUS = [ + "AcceptDataRecords", "Broadcast", "ClockReport", "DataGram", + "EncryptExempt", "GetDataRecord", "GetFile", "GetVariables", + "NetWork", "PakBusCLock", "Route", "RouteNeighbors", "Routes", + "SendData", "SendFile", "SendGetVariables", "SendTableDef", + "SendVariables", "StaticRoute", "TimeUntilTransmit", +]; + +const SDM = [ + "SDMAO4", "SDMAO4A", "SDMBeginPort", "SDMCAN", "SDMCD16AC", + "SDMCD16Mask", "SDMCVO4", "SDMGeneric", "SDMINT8", "SDMIO16", + "SDMSIO2R", "SDMSIO4", "SDMSpeed", "SDMSW8A", "SDMTrigger", + "SDMX50", +]; + +const SPECIAL_COMM = [ + "ArgosData", "ArgosDataRepeat", "ArgosError", "ArgosSetup", + "ArgosTransmit", "DialModem", "DialSequence", "DialEndSequence", + "DNP", "DNPUpdate", "DNPVariable", "GOESData", "GOESGPS", + "GOESSetup", "GOESStatus", "GPS", "I2COpen", "I2CRead", "I2CWrite", + "ModBusClient", "ModBusServer", "ModemCallBack", "ModemHangup", + "EndModemHangup", "SPIOpen", "SPIRead", "SPIWrite", +]; + +const SERIAL_IO = [ + "CheckSum", "ComPortIsActive", "MoveBytes", "SerialBrk", + "SerialClose", "SerialFlush", "SerialIn", "SerialInBlock", + "SerialInChk", "SerialInRecord", "SerialOpen", "SerialOut", + "SerialOutBlock", +]; + +const STRING_MANIP = [ + "ASCII", "Base64Encode", "CHR", "FormatFloat", "FormatLong", + "FormatLongLong", "Hex", "HexToDec", "InStr", "Left", "Len", + "LowerCase", "LTrim", "Mid", "Replace", "Right", "RTrim", + "SplitStr", "Sprintf", "StrComp", "Trim", "TypeOf", "UpperCase", +]; + +// Deduplicated flat list of all built-in functions +export const FUNCTIONS: string[] = Array.from(new Set([ + ...CDM, ...CUSTOM_MENUS, ...DATA_TABLE, ...DATALOGGER_STATUS, + ...FILE_IO, ...INTERNET, ...MATH, ...MEASUREMENTS, ...PAKBUS, + ...SDM, ...SPECIAL_COMM, ...SERIAL_IO, ...STRING_MANIP, +])); + +// ── Helpers ───────────────────────────────────────────────────────── + +/** Build a case-insensitive word-boundary regex from a list of words. */ +export function wordRegex(words: string[]): RegExp { + return new RegExp("\\b(" + words.join("|") + ")\\b", "i"); +} + +/** Build a Set of lower-cased words for fast O(1) lookup. */ +export function wordSet(words: string[]): Set { + return new Set(words.map((w) => w.toLowerCase())); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..724ec41 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,78 @@ +/** + * CodeBlock CRBasic — Obsidian plugin + * + * Adds CRBasic syntax highlighting to markdown fenced code blocks + * in both Edit mode (CodeMirror 6 ViewPlugin) and Reading view + * (registerMarkdownCodeBlockProcessor). + */ + +import { Plugin } from "obsidian"; +import { crbasicHighlighter } from "./cm6-crbasic"; +import { tokenizeLine, TokenType } from "./tokenizer"; + +/** Map token types to PrismJS CSS class arrays for reading view. + * PrismJS tokens need both "token" and the type class. */ +const PRISM_CLASSES: Record = { + keyword: ["token", "keyword"], + builtin: ["token", "function"], + operator: ["token", "operator"], + atom: ["token", "boolean"], + number: ["token", "number"], + string: ["token", "string"], + comment: ["token", "comment"], + meta: ["token", "keyword"], +}; + +export default class CodeBlockCRBasicPlugin extends Plugin { + async onload() { + // ── Edit mode (Live Preview): CM6 ViewPlugin ──────────────── + this.registerEditorExtension(crbasicHighlighter); + + // ── Reading view: code block processor ────────────────────── + this.registerMarkdownCodeBlockProcessor("crbasic", (source, el) => { + // Match Obsidian's expected DOM structure for styled code blocks + const pre = el.createEl("pre", { cls: ["language-crbasic"] }); + const code = pre.createEl("code", { cls: ["language-crbasic", "is-loaded"] }); + + const lines = source.split("\n"); + for (let li = 0; li < lines.length; li++) { + const lineText = lines[li]; + const tokens = tokenizeLine(lineText, 0); + + if (tokens.length === 0) { + // No tokens — plain text line + code.appendText(lineText); + } else { + // Build the line with highlighted spans + let cursor = 0; + for (const tok of tokens) { + // Text before this token + if (tok.from > cursor) { + code.appendText(lineText.slice(cursor, tok.from)); + } + // Token span with PrismJS-compatible classes + code.createEl("span", { + cls: PRISM_CLASSES[tok.type], + text: lineText.slice(tok.from, tok.to), + }); + cursor = tok.to; + } + // Remaining text after last token + if (cursor < lineText.length) { + code.appendText(lineText.slice(cursor)); + } + } + + // Add newline between lines (not after the last line) + if (li < lines.length - 1) { + code.appendText("\n"); + } + } + }); + } + + onunload() { + // CM6 extensions and markdown processors are automatically + // cleaned up by Obsidian's plugin lifecycle. + } +} diff --git a/src/tokenizer.ts b/src/tokenizer.ts new file mode 100644 index 0000000..e85de53 --- /dev/null +++ b/src/tokenizer.ts @@ -0,0 +1,136 @@ +/** + * Standalone CRBasic tokenizer — shared by both CM6 and Reading View. + * Returns token ranges with CSS class names for decoration. + */ + +import { + KEYWORDS, + LOGICAL_OPERATORS, + CONSTANTS, + FUNCTIONS, + PREPROCESSOR, + wordSet, +} from "./crbasic-tokens"; + +// ── Lookup sets (lower-cased) ─────────────────────────────────────── +const kwSet = wordSet(KEYWORDS); +const opsSet = wordSet(LOGICAL_OPERATORS); +const constsSet = wordSet(CONSTANTS); +const fnsSet = wordSet(FUNCTIONS); +const preprocSet = new Set(PREPROCESSOR.map((p) => p.toLowerCase())); + +// ── Token types → CSS class mapping ──────────────────────────────── +export type TokenType = + | "keyword" + | "builtin" + | "operator" + | "atom" + | "number" + | "string" + | "comment" + | "meta"; + +export interface Token { + from: number; + to: number; + type: TokenType; +} + +/** + * Tokenize a single line of CRBasic source. + * `offset` is the absolute position of the line start (for CM6 decorations). + */ +export function tokenizeLine(line: string, offset: number): Token[] { + const tokens: Token[] = []; + let i = 0; + const len = line.length; + + while (i < len) { + // Skip whitespace + if (/\s/.test(line[i])) { + i++; + continue; + } + + // Comment: ' to end of line + if (line[i] === "'") { + tokens.push({ from: offset + i, to: offset + len, type: "comment" }); + break; + } + + // String: "..." + if (line[i] === '"') { + const start = i; + i++; + while (i < len && line[i] !== '"') i++; + if (i < len) i++; // consume closing quote + tokens.push({ from: offset + start, to: offset + i, type: "string" }); + continue; + } + + // Preprocessor: #word + if (line[i] === "#") { + const m = line.slice(i).match(/^#\w+/i); + if (m && preprocSet.has(m[0].toLowerCase())) { + tokens.push({ from: offset + i, to: offset + i + m[0].length, type: "meta" }); + i += m[0].length; + continue; + } + i++; + continue; + } + + // Numbers + if (/\d/.test(line[i])) { + const m = line.slice(i).match(/^\d+(\.\d+)?/); + if (m) { + tokens.push({ from: offset + i, to: offset + i + m[0].length, type: "number" }); + i += m[0].length; + continue; + } + } + + // Multi-char operators + const opMatch = line.slice(i).match(/^(<>|>>|<<|>=|<=|\*=|\+=|-=|\/=|\\=|\^=|&=)/); + if (opMatch) { + tokens.push({ from: offset + i, to: offset + i + opMatch[0].length, type: "operator" }); + i += opMatch[0].length; + continue; + } + + // Single-char operators + if (/[+\-*/\\^<>=&@!]/.test(line[i])) { + tokens.push({ from: offset + i, to: offset + i + 1, type: "operator" }); + i++; + continue; + } + + // Identifiers / keywords / functions + if (/[A-Za-z_]/.test(line[i])) { + const m = line.slice(i).match(/^[A-Za-z_]\w*/); + if (m) { + const word = m[0].toLowerCase(); + let type: TokenType | null = null; + if (kwSet.has(word)) type = "keyword"; + else if (opsSet.has(word)) type = "operator"; + else if (constsSet.has(word)) type = "atom"; + else if (fnsSet.has(word)) type = "builtin"; + if (type) { + tokens.push({ from: offset + i, to: offset + i + m[0].length, type }); + } + i += m[0].length; + continue; + } + } + + // Anything else — skip + i++; + } + + return tokens; +} + +/** CSS class for CM6 decorations */ +export function cmClass(type: TokenType): string { + return `cm-${type}`; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..591a169 --- /dev/null +++ b/styles.css @@ -0,0 +1 @@ +/* CodeBlock CRBasic — no custom styles needed; uses Obsidian's theme tokens. */ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0361069 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "sourceMap": false, + "baseUrl": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +}