Files
recipes/static/js/src/cooklang-mode.js
2026-08-17 15:41:27 +00:00

177 lines
4.2 KiB
JavaScript

import { StreamLanguage } from "@codemirror/language";
// Cooklang syntax highlighting mode for CodeMirror 6
// Ported from cooklang-obsidian/src/mode/cook/cook.ts
export const cooklang = StreamLanguage.define({
name: "cooklang",
startState() {
return {
formatting: false,
nextMultiline: false,
inMultiline: false,
afterSection: false,
position: null,
inFrontmatter: false,
inMetadata: false,
inNote: false,
inComment: false,
afterAmount: false // Track if we just closed a {} to look for (prep)
};
},
token(stream, state) {
const sol = stream.sol() || state.afterSection;
const eol = stream.eol();
state.afterSection = false;
if (sol) {
// Reset single-line states at start of new line
state.inNote = false;
state.inMetadata = false;
if (state.nextMultiline) {
state.inMultiline = true;
state.nextMultiline = false;
} else {
state.position = null;
}
}
if (eol && !state.nextMultiline) {
state.inMultiline = false;
state.position = null;
}
if (sol) {
while (stream.eatSpace()) {}
}
// Frontmatter delimiters (---)
if (sol && stream.match(/^---\s*$/)) {
state.inFrontmatter = !state.inFrontmatter;
return "meta";
}
// Inside frontmatter
if (state.inFrontmatter) {
stream.skipToEnd();
return "meta";
}
// Sections (= Section Name, == Section ==, etc.)
if (sol && stream.match(/^=+/)) {
stream.skipToEnd();
return "heading";
}
// Line comments (-- comment)
if (sol && stream.match(/^--/)) {
stream.skipToEnd();
return "comment";
}
// Block comments ([- comment -])
if (stream.match(/^\[-/)) {
state.inComment = true;
return "comment";
}
if (state.inComment) {
if (stream.match(/-]/)) {
state.inComment = false;
return "comment";
}
stream.skipToEnd();
return "comment";
}
// Metadata (>> key: value)
if (sol && stream.match(/^>>/)) {
state.inMetadata = true;
state.position = "metadata-key";
return "meta";
}
if (state.inMetadata) {
if (state.position === "metadata-key") {
if (stream.match(/^[^:]+:/)) {
state.position = "metadata-value";
return "meta";
}
stream.skipToEnd();
return "meta";
} else if (state.position === "metadata-value") {
stream.skipToEnd();
return "meta";
}
}
// Notes (lines starting with >)
if (sol && stream.match(/^>/)) {
state.inNote = true;
return "comment";
}
if (state.inNote) {
stream.skipToEnd();
return "comment";
}
// Shorthand preparations (prep) after ingredient amounts like @onion{1}(chopped)
if (state.afterAmount && stream.match(/^\([^)]*\)/)) {
state.afterAmount = false;
return "string"; // Use string style for prep instructions
}
// Reset afterAmount if we see anything else
if (state.afterAmount && !stream.match(/^\s*(?=\()/, false)) {
state.afterAmount = false;
}
// Ingredients (@ingredient{amount})
if (stream.match(/^@([^@#~]+?(?={))/)) {
return "variableName";
} else if (stream.match(/^@(.+?\b)/)) {
return "variableName";
}
// Cookware (#cookware{amount})
if (stream.match(/^#([^@#~]+?(?={))/)) {
return "keyword";
} else if (stream.match(/^#(.+?\b)/)) {
return "keyword";
}
// Timers (~timer{amount})
if (stream.match(/^~([^@#~]+?(?={))/)) {
return "number";
} else if (stream.match(/^~(.+?\b)/)) {
return "number";
}
// Amounts in curly braces
const ch = stream.next();
if (!ch) return null;
if (ch === '{') {
if (state.position !== "timer") state.position = "measurement";
return null;
}
if (ch === '}') {
state.position = null;
state.afterAmount = true; // Look for (prep) after closing brace
return null;
}
if (ch === '%' && (state.position === "measurement" || state.position === "timer")) {
state.position = "unit";
return null;
}
return state.position;
}
});