|
|
|
@@ -0,0 +1,279 @@
|
|
|
|
|
/**
|
|
|
|
|
* Jinja2-aware autocomplete for CodeMirror 6.
|
|
|
|
|
*
|
|
|
|
|
* Provides contextual suggestions for:
|
|
|
|
|
* - Top-level template variables inside {{ }} and {% %}
|
|
|
|
|
* - Asset/album/commit fields after dot access inside {% for %} loops
|
|
|
|
|
* - Jinja2 loop special variables (loop.index, loop.first, etc.)
|
|
|
|
|
* - Jinja2 filters after |
|
|
|
|
|
* - Jinja2 block tags after {%
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
autocompletion,
|
|
|
|
|
type Completion,
|
|
|
|
|
type CompletionContext,
|
|
|
|
|
type CompletionResult,
|
|
|
|
|
} from '@codemirror/autocomplete';
|
|
|
|
|
import type { Extension } from '@codemirror/state';
|
|
|
|
|
|
|
|
|
|
/** Shape of per-slot variable metadata from the API. */
|
|
|
|
|
export interface SlotVariables {
|
|
|
|
|
variables: Record<string, string>;
|
|
|
|
|
asset_fields?: Record<string, string>;
|
|
|
|
|
album_fields?: Record<string, string>;
|
|
|
|
|
commit_fields?: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Known collection → field set mappings
|
|
|
|
|
const COLLECTION_FIELD_MAP: Record<string, keyof SlotVariables> = {
|
|
|
|
|
added_assets: 'asset_fields',
|
|
|
|
|
assets: 'asset_fields',
|
|
|
|
|
removed_assets: 'asset_fields',
|
|
|
|
|
collections: 'album_fields',
|
|
|
|
|
albums: 'album_fields',
|
|
|
|
|
commits: 'commit_fields',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Jinja2 loop special variables
|
|
|
|
|
const LOOP_VARS: Completion[] = [
|
|
|
|
|
{ label: 'index', type: 'property', detail: 'Current iteration (1-indexed)' },
|
|
|
|
|
{ label: 'index0', type: 'property', detail: 'Current iteration (0-indexed)' },
|
|
|
|
|
{ label: 'first', type: 'property', detail: 'True on first iteration' },
|
|
|
|
|
{ label: 'last', type: 'property', detail: 'True on last iteration' },
|
|
|
|
|
{ label: 'length', type: 'property', detail: 'Total number of items' },
|
|
|
|
|
{ label: 'revindex', type: 'property', detail: 'Iterations until end (1-indexed)' },
|
|
|
|
|
{ label: 'revindex0', type: 'property', detail: 'Iterations until end (0-indexed)' },
|
|
|
|
|
{ label: 'cycle', type: 'function', detail: 'Cycle through values' },
|
|
|
|
|
{ label: 'depth', type: 'property', detail: 'Nesting level (starts at 0)' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Common Jinja2 filters
|
|
|
|
|
const JINJA_FILTERS: Completion[] = [
|
|
|
|
|
{ label: 'join', type: 'function', detail: 'Join list with separator' },
|
|
|
|
|
{ label: 'length', type: 'function', detail: 'Number of items' },
|
|
|
|
|
{ label: 'default', type: 'function', detail: 'Default value if undefined' },
|
|
|
|
|
{ label: 'upper', type: 'function', detail: 'Uppercase string' },
|
|
|
|
|
{ label: 'lower', type: 'function', detail: 'Lowercase string' },
|
|
|
|
|
{ label: 'title', type: 'function', detail: 'Titlecase string' },
|
|
|
|
|
{ label: 'capitalize', type: 'function', detail: 'Capitalize first char' },
|
|
|
|
|
{ label: 'trim', type: 'function', detail: 'Strip whitespace' },
|
|
|
|
|
{ label: 'replace', type: 'function', detail: 'Replace substring' },
|
|
|
|
|
{ label: 'int', type: 'function', detail: 'Convert to integer' },
|
|
|
|
|
{ label: 'float', type: 'function', detail: 'Convert to float' },
|
|
|
|
|
{ label: 'round', type: 'function', detail: 'Round number' },
|
|
|
|
|
{ label: 'first', type: 'function', detail: 'First item of list' },
|
|
|
|
|
{ label: 'last', type: 'function', detail: 'Last item of list' },
|
|
|
|
|
{ label: 'sort', type: 'function', detail: 'Sort list' },
|
|
|
|
|
{ label: 'reverse', type: 'function', detail: 'Reverse list' },
|
|
|
|
|
{ label: 'batch', type: 'function', detail: 'Split into batches' },
|
|
|
|
|
{ label: 'filesizeformat', type: 'function', detail: 'Human-readable file size' },
|
|
|
|
|
{ label: 'truncate', type: 'function', detail: 'Truncate string' },
|
|
|
|
|
{ label: 'wordcount', type: 'function', detail: 'Count words' },
|
|
|
|
|
{ label: 'e', type: 'function', detail: 'HTML escape' },
|
|
|
|
|
{ label: 'safe', type: 'function', detail: 'Mark as safe HTML' },
|
|
|
|
|
{ label: 'striptags', type: 'function', detail: 'Strip HTML tags' },
|
|
|
|
|
{ label: 'urlencode', type: 'function', detail: 'URL-encode string' },
|
|
|
|
|
{ label: 'list', type: 'function', detail: 'Convert to list' },
|
|
|
|
|
{ label: 'string', type: 'function', detail: 'Convert to string' },
|
|
|
|
|
{ label: 'abs', type: 'function', detail: 'Absolute value' },
|
|
|
|
|
{ label: 'max', type: 'function', detail: 'Maximum value' },
|
|
|
|
|
{ label: 'min', type: 'function', detail: 'Minimum value' },
|
|
|
|
|
{ label: 'map', type: 'function', detail: 'Apply to each item' },
|
|
|
|
|
{ label: 'select', type: 'function', detail: 'Filter items by test' },
|
|
|
|
|
{ label: 'reject', type: 'function', detail: 'Remove items by test' },
|
|
|
|
|
{ label: 'unique', type: 'function', detail: 'Remove duplicates' },
|
|
|
|
|
{ label: 'groupby', type: 'function', detail: 'Group items by attribute' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Jinja2 block tags
|
|
|
|
|
const BLOCK_TAGS: Completion[] = [
|
|
|
|
|
{ label: 'for', type: 'keyword', detail: 'Loop over items' },
|
|
|
|
|
{ label: 'endfor', type: 'keyword', detail: 'End for loop' },
|
|
|
|
|
{ label: 'if', type: 'keyword', detail: 'Conditional block' },
|
|
|
|
|
{ label: 'elif', type: 'keyword', detail: 'Else-if branch' },
|
|
|
|
|
{ label: 'else', type: 'keyword', detail: 'Else branch' },
|
|
|
|
|
{ label: 'endif', type: 'keyword', detail: 'End if block' },
|
|
|
|
|
{ label: 'set', type: 'keyword', detail: 'Assign variable' },
|
|
|
|
|
{ label: 'block', type: 'keyword', detail: 'Template block' },
|
|
|
|
|
{ label: 'endblock', type: 'keyword', detail: 'End block' },
|
|
|
|
|
{ label: 'macro', type: 'keyword', detail: 'Define macro' },
|
|
|
|
|
{ label: 'endmacro', type: 'keyword', detail: 'End macro' },
|
|
|
|
|
{ label: 'raw', type: 'keyword', detail: 'Raw output (no rendering)' },
|
|
|
|
|
{ label: 'endraw', type: 'keyword', detail: 'End raw block' },
|
|
|
|
|
{ label: 'filter', type: 'keyword', detail: 'Apply filter to block' },
|
|
|
|
|
{ label: 'endfilter', type: 'keyword', detail: 'End filter block' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/** Build completions from a name→description record. */
|
|
|
|
|
function toCompletions(
|
|
|
|
|
vars: Record<string, string>,
|
|
|
|
|
type: string,
|
|
|
|
|
): Completion[] {
|
|
|
|
|
return Object.entries(vars).map(([label, detail]) => ({ label, type, detail }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scan backwards from cursor to find the nearest `{% for <var> in <collection> %}`
|
|
|
|
|
* that binds `identifier` as the loop variable.
|
|
|
|
|
* Returns the collection name or null.
|
|
|
|
|
*/
|
|
|
|
|
function findLoopCollection(doc: string, identifier: string): string | null {
|
|
|
|
|
// Search up to 3000 chars back for performance
|
|
|
|
|
const searchText = doc.slice(Math.max(0, doc.length - 3000));
|
|
|
|
|
const re = new RegExp(
|
|
|
|
|
`\\{%-?\\s*for\\s+${escapeRegExp(identifier)}\\s+in\\s+(\\w+)`,
|
|
|
|
|
'g',
|
|
|
|
|
);
|
|
|
|
|
let lastMatch: string | null = null;
|
|
|
|
|
let m: RegExpExecArray | null;
|
|
|
|
|
while ((m = re.exec(searchText)) !== null) {
|
|
|
|
|
lastMatch = m[1];
|
|
|
|
|
}
|
|
|
|
|
return lastMatch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(s: string): string {
|
|
|
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if cursor is inside a Jinja2 delimiter ({{ }}, {% %}, or {# #}).
|
|
|
|
|
* Returns the delimiter type or null.
|
|
|
|
|
*/
|
|
|
|
|
function getJinjaContext(
|
|
|
|
|
textBefore: string,
|
|
|
|
|
): 'expression' | 'block' | 'comment' | null {
|
|
|
|
|
// Find the last opening delimiter
|
|
|
|
|
let lastExpr = textBefore.lastIndexOf('{{');
|
|
|
|
|
let lastBlock = textBefore.lastIndexOf('{%');
|
|
|
|
|
let lastComment = textBefore.lastIndexOf('{#');
|
|
|
|
|
|
|
|
|
|
// Find the last closing delimiter
|
|
|
|
|
const lastCloseExpr = textBefore.lastIndexOf('}}');
|
|
|
|
|
const lastCloseBlock = textBefore.lastIndexOf('%}');
|
|
|
|
|
const lastCloseComment = textBefore.lastIndexOf('#}');
|
|
|
|
|
|
|
|
|
|
// Check if we're inside an expression {{ }}
|
|
|
|
|
if (lastExpr > lastCloseExpr && lastExpr >= lastBlock && lastExpr >= lastComment) {
|
|
|
|
|
return 'expression';
|
|
|
|
|
}
|
|
|
|
|
// Check if we're inside a block {% %}
|
|
|
|
|
if (lastBlock > lastCloseBlock && lastBlock >= lastExpr && lastBlock >= lastComment) {
|
|
|
|
|
return 'block';
|
|
|
|
|
}
|
|
|
|
|
// Check if we're inside a comment {# #}
|
|
|
|
|
if (lastComment > lastCloseComment && lastComment >= lastExpr && lastComment >= lastBlock) {
|
|
|
|
|
return 'comment';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function completionSource(
|
|
|
|
|
vars: SlotVariables,
|
|
|
|
|
): (ctx: CompletionContext) => CompletionResult | null {
|
|
|
|
|
const topLevelCompletions = toCompletions(vars.variables, 'variable');
|
|
|
|
|
|
|
|
|
|
return (ctx: CompletionContext): CompletionResult | null => {
|
|
|
|
|
const textBefore = ctx.state.doc.sliceString(0, ctx.pos);
|
|
|
|
|
const lineBefore = ctx.state.doc.lineAt(ctx.pos);
|
|
|
|
|
const lineTextBefore = lineBefore.text.slice(0, ctx.pos - lineBefore.from);
|
|
|
|
|
|
|
|
|
|
// 1. Filter completions: after |
|
|
|
|
|
const filterMatch = lineTextBefore.match(/\|\s*(\w*)$/);
|
|
|
|
|
if (filterMatch) {
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - filterMatch[1].length,
|
|
|
|
|
options: JINJA_FILTERS,
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Dot-access completions: identifier.partial
|
|
|
|
|
const dotMatch = lineTextBefore.match(/(\w+)\.(\w*)$/);
|
|
|
|
|
if (dotMatch) {
|
|
|
|
|
const [, identifier, partial] = dotMatch;
|
|
|
|
|
|
|
|
|
|
// loop.xxx
|
|
|
|
|
if (identifier === 'loop') {
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - partial.length,
|
|
|
|
|
options: LOOP_VARS,
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find what collection this variable iterates over
|
|
|
|
|
const collection = findLoopCollection(textBefore, identifier);
|
|
|
|
|
if (collection) {
|
|
|
|
|
const fieldKey = COLLECTION_FIELD_MAP[collection];
|
|
|
|
|
const fields = fieldKey ? vars[fieldKey] : undefined;
|
|
|
|
|
if (fields && typeof fields === 'object') {
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - partial.length,
|
|
|
|
|
options: toCompletions(fields, 'property'),
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const jinjaCtx = getJinjaContext(textBefore);
|
|
|
|
|
if (!jinjaCtx || jinjaCtx === 'comment') return null;
|
|
|
|
|
|
|
|
|
|
// 3. Block tag completions: {% partial
|
|
|
|
|
if (jinjaCtx === 'block') {
|
|
|
|
|
const tagMatch = lineTextBefore.match(/\{%-?\s*(\w*)$/);
|
|
|
|
|
if (tagMatch) {
|
|
|
|
|
const partial = tagMatch[1];
|
|
|
|
|
// If we're past the tag keyword, suggest variables (e.g. {% if var %})
|
|
|
|
|
const afterTag = lineTextBefore.match(/\{%-?\s*(?:if|elif|for\s+\w+\s+in)\s+(\w*)$/);
|
|
|
|
|
if (afterTag) {
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - afterTag[1].length,
|
|
|
|
|
options: topLevelCompletions,
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - partial.length,
|
|
|
|
|
options: BLOCK_TAGS,
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Expression variable completions: {{ partial
|
|
|
|
|
if (jinjaCtx === 'expression') {
|
|
|
|
|
const exprMatch = lineTextBefore.match(/\{\{-?\s*(\w*)$/);
|
|
|
|
|
if (exprMatch) {
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - exprMatch[1].length,
|
|
|
|
|
options: topLevelCompletions,
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// Also handle after spaces inside expression (e.g. {{ var if condition }})
|
|
|
|
|
const wordMatch = lineTextBefore.match(/\s(\w*)$/);
|
|
|
|
|
if (wordMatch) {
|
|
|
|
|
return {
|
|
|
|
|
from: ctx.pos - wordMatch[1].length,
|
|
|
|
|
options: topLevelCompletions,
|
|
|
|
|
validFor: /^\w*$/,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Create a CodeMirror autocomplete extension for Jinja2 templates. */
|
|
|
|
|
export function jinjaAutocomplete(vars: SlotVariables): Extension {
|
|
|
|
|
return autocompletion({
|
|
|
|
|
override: [completionSource(vars)],
|
|
|
|
|
activateOnTyping: true,
|
|
|
|
|
icons: true,
|
|
|
|
|
});
|
|
|
|
|
}
|