cookcli 0.38.1

A command-line interface for managing and working with Cooklang recipes
Documentation
import { EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, HighlightStyle, bracketMatching } from "@codemirror/language";
import { searchKeymap, highlightSelectionMatches } from "@codemirror/search";
import { linter } from "@codemirror/lint";
import { autocompletion, snippet } from "@codemirror/autocomplete";
import { tags as t } from "@lezer/highlight";
import { cooklang } from "./cooklang-mode.js";
import {
    initToolbar,
    recipeActions,
    wrapSelection,
    prefixLines,
    insertBlock,
    findFrontmatter,
    ensureFrontmatter,
    menuActions,
    pickerActions,
    insertMenuItem,
    continueMeal
} from "./toolbar.js";
import { initRecipePicker } from "./picker.js";

// Diagnostics support
let currentDiagnostics = [];

export function setDiagnostics(view, diagnostics) {
    currentDiagnostics = diagnostics;
    // Trigger a state update to rerun the linter
    if (view) {
        view.dispatch({
            effects: []
        });
    }
}

// Linter that returns current diagnostics
const cooklangLinter = linter((view) => currentDiagnostics, { delay: 0 });

// Completion support
let completionResolver = null;

export function setCompletionResolver(resolver) {
    completionResolver = resolver;
}

// Async completion source for Cooklang
async function cooklangCompletions(context) {
    if (!completionResolver) return null;

    const pos = context.pos;
    const line = context.state.doc.lineAt(pos);
    const textBefore = line.text.slice(0, pos - line.from);

    // Check for trigger characters (@, #, ~)
    // Include . / - in character class so @./path file references trigger completion
    const match = textBefore.match(/[@#~]([a-zA-Z0-9_./\-]*)$/);
    if (!match) return null;

    const prefix = match[1];
    const from = pos - prefix.length;
    const triggerChar = match[0][0];

    try {
        const items = await completionResolver(line.number - 1, pos - line.from);
        if (!items || items.length === 0) return null;

        return {
            from: from,
            // Keep completion open while user types path chars; CodeMirror's
            // built-in FuzzyMatcher handles client-side narrowing.
            validFor: /^[a-zA-Z0-9_./\-]*$/,
            options: items.map(item => {
                const type = item.kind === 17 ? 'file' : item.kind === 6 ? 'variable' : item.kind === 14 ? 'keyword' : 'text';
                // Prefer textEdit.newText (used by recipe references with explicit ranges),
                // then insertText, then label
                const text = item.textEdit?.newText || item.insertText || item.label;

                // Check if LSP sent a snippet (insertTextFormat === 2)
                if (item.insertTextFormat === 2 && text.includes('$')) {
                    // Convert LSP snippet syntax to CodeMirror: $0 -> ${}, $1 -> ${1}
                    const cmSnippet = text.replace(/\$0/g, '${}').replace(/\$(\d+)/g, '${$1}');
                    return {
                        label: item.label,
                        type: type,
                        detail: item.detail || '',
                        apply: snippet(cmSnippet)
                    };
                }

                return {
                    label: item.label,
                    type: type,
                    detail: item.detail || '',
                    apply: text
                };
            })
        };
    } catch (e) {
        console.error('Completion error:', e);
        return null;
    }
}

// Custom highlight style for Cooklang syntax. Each token kind gets a fixed
// class; colour and weight live in static/css/input.css (`.cm-cook-*`) so
// they resolve through the theme tokens and flip with light/dark.
const cooklangHighlightStyle = HighlightStyle.define([
  { tag: t.variableName, class: "cm-cook-ingredient" },
  { tag: t.keyword, class: "cm-cook-cookware" },
  { tag: t.number, class: "cm-cook-timer" },
  { tag: t.comment, class: "cm-cook-comment" },
  { tag: t.meta, class: "cm-cook-metadata" },
  { tag: t.unit, class: "cm-cook-unit" },
  { tag: t.heading, class: "cm-cook-section" },
  { tag: t.string, class: "cm-cook-prep" }
]);

// Editor base theme for layout
const cooklangBaseTheme = EditorView.theme({
  "&": {
    height: "100%",
    fontSize: "14px"
  },
  ".cm-scroller": {
    fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
    overflow: "auto"
  },
  ".cm-content": {
    padding: "1rem"
  },
  ".cm-line": {
    padding: "0 0.5rem"
  }
});

// Cursor position callback
let cursorPositionCallback = null;

export function setCursorPositionCallback(callback) {
  cursorPositionCallback = callback;
}

// Initialize editor
// `menu`: the file is a `.menu`, where Enter continues the current meal (see
// `continueMeal`). Ahead of the default Enter, but behind autocompletion,
// which takes Enter while its list is open.
export function initEditor(container, initialContent, onChange, { menu = false } = {}) {
  const updateListener = EditorView.updateListener.of((update) => {
    if (update.docChanged && onChange) {
      onChange(update.state.doc.toString());
    }
    // Always update cursor position on any update
    const pos = update.state.selection.main.head;
    const line = update.state.doc.lineAt(pos);
    const col = pos - line.from + 1;
    if (cursorPositionCallback) {
      cursorPositionCallback(line.number, col);
    }
  });

  const state = EditorState.create({
    doc: initialContent,
    extensions: [
      lineNumbers(),
      highlightActiveLine(),
      highlightActiveLineGutter(),
      history(),
      bracketMatching(),
      highlightSelectionMatches(),
      cooklang,
      syntaxHighlighting(cooklangHighlightStyle),
      cooklangBaseTheme,
      cooklangLinter,
      autocompletion({
        override: [cooklangCompletions],
        activateOnTyping: true
      }),
      keymap.of([
        ...defaultKeymap,
        ...historyKeymap,
        ...searchKeymap
      ]),
      menu ? Prec.high(keymap.of([{ key: "Enter", run: continueMeal }])) : [],
      updateListener,
      EditorView.lineWrapping
    ]
  });

  const view = new EditorView({
    state,
    parent: container
  });

  // Set cursor to beginning of document (line 1, col 1)
  view.dispatch({
    selection: { anchor: 0 }
  });
  view.focus();

  return view;
}

// Get editor content
export function getContent(view) {
  return view.state.doc.toString();
}

// Set editor content
export function setContent(view, content) {
  view.dispatch({
    changes: {
      from: 0,
      to: view.state.doc.length,
      insert: content
    }
  });
}

// Export for global access
window.CooklangEditor = {
  initEditor,
  getContent,
  setContent,
  setDiagnostics,
  setCompletionResolver,
  setCursorPositionCallback,
  initToolbar,
  recipeActions,
  wrapSelection,
  prefixLines,
  insertBlock,
  findFrontmatter,
  ensureFrontmatter,
  menuActions,
  pickerActions,
  insertMenuItem,
  initRecipePicker
};