Initial release v1.0.0

This commit is contained in:
utleysam 2026-06-04 22:00:21 -06:00 committed by GitHub
parent f3b0208c13
commit a6f9fac67c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 584 additions and 0 deletions

37
esbuild.config.mjs Normal file
View file

@ -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();
}

10
manifest.json Normal file
View file

@ -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
}

19
package.json Normal file
View file

@ -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"
}
}

90
src/cm6-crbasic.ts Normal file
View file

@ -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<string, Decoration> = {};
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<Decoration>();
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,
});

194
src/crbasic-tokens.ts Normal file
View file

@ -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<string> {
return new Set(words.map((w) => w.toLowerCase()));
}

78
src/main.ts Normal file
View file

@ -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<TokenType, string[]> = {
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.
}
}

136
src/tokenizer.ts Normal file
View file

@ -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}`;
}

1
styles.css Normal file
View file

@ -0,0 +1 @@
/* CodeBlock CRBasic — no custom styles needed; uses Obsidian's theme tokens. */

19
tsconfig.json Normal file
View file

@ -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"]
}