" Vim syntax file " Language: Marko " Maintainer: Brian Carbone " URL: https://markojs.com " Filenames: *.marko " Last Change: 2026 Jul 29 " Marko interleaves HTML-like tags, TypeScript expressions and CSS in two " modes. Mode is tracked structurally: element bodies and "--" text blocks " are html mode (bare text stays plain), everywhere else is concise, where " a line-start name is a tag: "

if you want, stop by

body text, plain " my-tag size="large" concise tag, highlighted " Same-position priority goes to the item defined last; definition order " is deliberate throughout. if !exists("main_syntax") if exists("b:current_syntax") finish endif let main_syntax = "marko" endif let s:cpo_save = &cpo set cpo&vim " Top level is mostly markup; regions holding prose opt into @Spell. syn spell toplevel " Both includes mutate shared state ("syn case", "syn iskeyword", sync, " 'iskeyword'), restored or restated afterwards. let s:iskeyword_save = &l:iskeyword syn include @markoTS syntax/typescript.vim unlet! b:current_syntax syn include @markoCSS syntax/css.vim unlet! b:current_syntax let &l:iskeyword = s:iskeyword_save unlet s:iskeyword_save syn case match " A "\%#=1" prefix pins a pattern to the backtracking regexp engine: the " default NFA engine is much slower on this file's characteristic shapes " (a lookbehind or bounded repeat in front of an alternation). Both " engines answer alike; only patterns measured faster carry the prefix. " yats attaches type arguments via nextgroup after every identifier, so an " unspaced "<" in any expression would hunt a ">" that may never come: "
would swallow the rest of the file " That group stays cleared. The comment region replaces yats' block " comment, whose "extend" would escape every containing region below. silent! syn clear typescriptTypeArguments silent! syn clear typescriptComment syn region typescriptComment contained start="/\*" end="\*/" contains=@Spell,typescriptCommentTodo " yats claims call arguments, bodies, indexing, arrays, templates and " parenthesized expressions via nextgroup, so the markoTS* pairs below " never see those delimiters. Each row is yats' own definition plus " "contained extend", the one difference from upstream: a multiline group " keeps its containing attribute value open, and a bracketed one shields " the spaces inside, as the parser's group tracking does: " count=Math.max(0, tiers.findIndex((t) => t.featured)) " pair=raw as [string, string] for s:def in [ \ 'typescriptBlock matchgroup=typescriptBraces start=/{/ end=/}/ contains=@typescriptStatement,@typescriptComments fold', \ 'typescriptFuncCallArg matchgroup=typescriptParens start=/(/ end=/)/ contains=@typescriptValue,@typescriptComments,typescriptCastKeyword nextgroup=@typescriptSymbols,typescriptDotNotation skipwhite skipempty skipnl', \ 'typescriptArray matchgroup=typescriptBraces start=/\[/ end=/]/ contains=@typescriptValue,@typescriptComments,typescriptCastKeyword nextgroup=@typescriptSymbols,typescriptDotNotation skipwhite skipempty fold', \ 'typescriptTemplate start=/`/ skip=/\\\\\|\\`\|\n/ end=/`\|$/ contains=typescriptTemplateSubstitution,typescriptSpecial,@Spell nextgroup=@typescriptSymbols skipwhite skipempty', \ 'typescriptObjectLiteral matchgroup=typescriptBraces start=/{/ end=/}/ contains=@typescriptComments,typescriptObjectLabel,typescriptStringProperty,typescriptComputedPropertyName,typescriptObjectAsyncKeyword,typescriptTernary,typescriptCastKeyword fold', \ 'typescriptObjectType matchgroup=typescriptBraces start=/{/ end=/}/ contains=@typescriptTypeMember,typescriptEndColons,@typescriptComments,typescriptAccessibilityModifier,typescriptReadonlyModifier nextgroup=@typescriptTypeOperator skipwhite skipnl fold', \ 'typescriptParenExp matchgroup=typescriptParens start=/(/ end=/)/ contains=@typescriptComments,@typescriptValue,typescriptCastKeyword nextgroup=@typescriptSymbols skipwhite skipempty', \ 'typescriptIndexExpr matchgroup=typescriptProperty start=/\[/ end=/]/ contains=@typescriptValue,typescriptCastKeyword nextgroup=@typescriptSymbols,typescriptDotNotation,typescriptFuncCallArg skipwhite skipempty', \ 'typescriptCall matchgroup=typescriptParens start=/(/ end=/)/ contains=typescriptDecorator,@typescriptParameterList,@typescriptComments nextgroup=typescriptTypeAnnotation,typescriptBlock skipwhite skipnl', \ 'typescriptTupleType matchgroup=typescriptBraces start=/\[/ end=/\]/ contains=@typescriptType,@typescriptComments,typescriptRestOrSpread,typescriptTupleLable skipwhite', \ 'typescriptParenthesizedType matchgroup=typescriptParens start=/(/ end=/)/ contains=@typescriptType nextgroup=@typescriptTypeOperator skipwhite skipempty fold', \ 'typescriptFuncType matchgroup=typescriptParens start=/(\(\k\+:\|)\)\@=/ end=/)\s*=>/me=e-2 contains=@typescriptParameterList nextgroup=typescriptFuncTypeArrow skipwhite skipnl oneline', \ 'typescriptStringLiteralType start=/\z(["'']\)/ skip=/\\\\\|\\\z1\|\\\n/ end=/\z1\|$/ nextgroup=typescriptUnion skipwhite skipempty', \ 'typescriptTemplateLiteralType start=/`/ skip=/\\\\\|\\`\|\n/ end=/`\|$/ contains=typescriptTemplateSubstitutionType nextgroup=typescriptTypeOperator skipwhite skipempty', \ ] silent! exe 'syn clear ' . matchstr(s:def, '^\S\+') exe 'syn region ' . s:def . ' contained extend' endfor unlet s:def " yats' own arrow-function definitions, restated to stay last-defined at " a "(": params eaten as a parenthesized expression turn the arrow's "{" " body into an object literal, whose ternary region has no closing bound: " f((x) => { value = x ?? other; }) syntax match typescriptArrowFuncDef contained /\K\k*\s*=>/ \ contains=typescriptArrowFuncArg,typescriptArrowFunc \ nextgroup=@typescriptExpression,typescriptBlock \ skipwhite skipempty syntax match typescriptArrowFuncDef contained /(\%(\_[^()]\+\|(\_[^()]*)\)*)\_s*=>/ \ contains=typescriptArrowFuncArg,typescriptArrowFunc,@typescriptCallSignature \ nextgroup=@typescriptExpression,typescriptBlock \ skipwhite skipempty syntax region typescriptArrowFuncDef contained start=/(\%(\_[^()]\+\|(\_[^()]*)\)*):/ matchgroup=typescriptArrowFunc end=/=>/ \ contains=typescriptArrowFuncArg,typescriptTypeAnnotation,@typescriptCallSignature \ nextgroup=@typescriptExpression,typescriptBlock \ skipwhite skipempty keepend " The bracket pairs keep an outer region open across nested delimiters " and shield the spaces inside groupings from value termination: " a={ b: { c: 1 } } d=2 " markoTSDelim is deliberately unlinked: delimiters render plain. syn cluster markoExpr contains=@markoTS,markoTSBraces,markoTSParens,markoTSBrackets for [s:pair, s:open, s:close] in [["Braces", '{', '}'], ["Parens", '(', ')'], ["Brackets", '\[', ']']] exe 'syn region markoTS' . s:pair . ' contained transparent matchgroup=markoTSDelim' \ . ' start="' . s:open . '" end="' . s:close . '"' \ . ' contains=@markoExpr extend' endfor unlet s:pair s:open s:close " html-mode "//" and "/* */" comments open wherever whitespace precedes " the slash (a URL's "//" follows ":", so it stays plain); concise context " takes only the line-anchored forms, defined second so they win at a line " start and keep their fold and indent handling: " // note

done // for now

plain " No comment item may take "display": a match skipped during offscreen " state computation exposes its text, and a " syn region markoDeclaration start="" syn region markoDeclaration start="" syn region markoCData start="" " Placeholders interpolate into text; backslashes escape pairwise: " ${user.name} $!{rawHtml} \${literal} \\${live} " markoEscape must never take "display" (it exists to suppress " markoPlaceholder); it consumes an escaped "$", or stops before a "${" " an even backslash run leaves live. syn match markoEscape "\%(\\\\\)*\%(\\\$!\={\|\\\\\ze\$!\={\)" syn region markoPlaceholder matchgroup=markoPlaceholderDelim \ start="\$!\={" end="}" \ contains=@markoExpr extend " The no-"extend" variant for keepend parsed-text bodies and the CSS " contexts: an unterminated "\${" must stop at the container's close. syn region markoPlaceholderText contained matchgroup=markoPlaceholderDelim \ start="\$!\={" end="}" \ contains=@markoExpr \ containedin=cssDefinition,cssAttrRegion,cssStringQ,cssStringQQ syn match markoEntity "&\%(#\d\+\|#[xX]\x\+\|\w\+\);" display " Scriptlets; the whitespace after "$" keeps line-start placeholders out. " No keepend: an open bracket continues, as the parser's group tracking " does. The "$ {" block form is second, so it wins where both start: " $ count += 1 $ { let total = 0; } " $ const rows = [ " "a", " ] syn region markoScriptlet matchgroup=markoScriptletDelim \ start="^\s*\$\s\+" end="$" \ contains=@markoExpr syn region markoScriptlet matchgroup=markoScriptletDelim \ start="^\s*\$\s\+{" end="};\=" \ contains=@markoExpr " "--" marks the rest of the line as text; at end of line it opens an " indented block instead: " p -- some text " p -- " A block of text, html mode. " The inline form is a one-line html region, so later dash runs on the " line sit inside it and are never delimiters (the parser's rule), and " keepend bounds what the line opens. Both forms stand down on a line " whose first non-blank is "<" (html content, where dashes are text). " They overlap at a trailing "--": the nextgroup form is second and wins, " and must not take "display". A bare "--" line belongs to the " later-defined markoTextDelimBlock. syn region markoInlineText oneline keepend matchgroup=markoBlockDelim \ start="\%#=1\%(^\s*<.\{-}\)\@200 " An unquoted value ends at whitespace or the mode's terminators, except " that the parser continues across whitespace flanked by an operator: " if=tag === "select" content=a.desc ?? b.desc onClick=(e) => go(e) syn match markoAttrName contained "\%#=1\%(\$!\={\)\@![[:alnum:]_$@]\%(\k\|[$.]\|:=\@!\)*" display syn match markoSpread contained "\.\.\." display syn region markoString contained start=+\z(["']\)+ skip=+\\\z1+ end=+\z1+ contains=@Spell " Regions so an interpolation can sit inside one (no keepend: the " placeholder carries the region past its "}"): " syn region markoShorthandId contained oneline \ start="#[-[:alnum:]_$]\@=" end="[-[:alnum:]_$]\@!" \ contains=markoPlaceholder syn region markoShorthandClass contained oneline \ start="\.[-[:alnum:]_$]\@=" end="[-[:alnum:]_$]\@!" \ contains=markoPlaceholder " Tag variables, arguments, |parameters|, and method bodies: " " >
"-style line ends; " mid-line "x > 1" continues via the break end's own guard); single " "+"/"-" continue, postfix "i++" ends; unary keywords count unless a " word character precedes ("a-new Date()" stays whole), binary and type " keywords only after whitespace ("class=step-in" ends); a trailing "." " continues when a word character follows, across blank lines. A "/" " counts only when spaces flank it (ahead of the break it is ambiguous " with "/>"), and the break demands a non-space before the space, so a " continuation line's indent cannot end the value it continues: " ratio=total / count max=10 " total=base + " tax let s:val_nocont = '[&*^:=!<%|?~]\@1\|>>\)\@2\@1; \t]\)\@=' let s:val_head = ' contained matchgroup=markoOperator start=":\==>\@!\s*"' \ . ' matchgroup=NONE start="\.\@1<=\%(\.\.\.\)\@3<="' let s:val_tail = ' contains=@markoExpr keepend extend' exe 'syn region markoAttrValue' . s:val_head \ . ' end="\ze," end="\ze/>" end="=\@1"' \ . ' end="' . s:val_last . '\ze\s*\n\%(\_s*\%(' . s:val_op . '\|-\|/[/*>]\@!\)\)\@!"' \ . ' end="' . s:val_break . '\%(\_s*\%(' . s:val_op . '\|-\|/[/*>]\@!\)\)\@!"' . s:val_tail exe 'syn region markoAttrValueConcise' . s:val_head \ . ' end="\ze[,;]" end="' . s:val_last . '"' \ . ' end="' . s:val_break . '\%(\s*\%(' . s:val_op . '\|--\@!\|/[/*]\@!\|>\)\)\@!"' . s:val_tail exe 'syn region markoAttrValueInGroup' . s:val_head \ . ' end="\ze[,\]]" end="' . s:val_last . '"' \ . ' end="' . s:val_break . '\%(\s*\%(' . s:val_op . '\|-\|/[/*]\@!\|>\)\)\@!"' . s:val_tail unlet s:val_nocont s:val_last s:val_break s:val_op s:val_head s:val_tail syn cluster markoTagStuff contains=markoTagVar,markoShorthandId,markoShorthandClass, \markoAttrName,markoString,markoArgs,markoParams,markoTypeArgs, \markoMethodBody,markoSpread,markoTagComment,markoPlaceholder " The name groups themselves are defined near the end of the file; a " cluster may name them before they exist. syn cluster markoTagNames contains=markoTagName,markoTagNameTail,markoAttrTagName, \markoTagNameConditional,markoTagNameRepeat,markoTagNameException, \markoTagNameStatement,markoTagNameBuiltin " Attribute groups may span lines. The lookbehind keeps a spread's array " literal out; that "[" opens a value, not a group: " define [size = "large"] my-tag ...[1, 2] syn region markoAttrGroup contained matchgroup=markoTagDelim \ start="\%(\.\.\.\)\@3" \ matchgroup=NONE start="^\s*\ze\%(import\|export\|class\)\>" \ end="$" contains=@markoExpr " A tag name may be empty (<.card>), dynamic (<${tag}>) or an attribute " tag (<@body>); an arrow's ">" does not end the tag. syn region markoTag matchgroup=markoTagDelim \ start="<\ze[^/ \t!?<>=]" end="/>" end="\%#=1=\@1" \ contains=@markoTagStuff,@markoTagNames,markoAttrValue " Recovers a close tag no body end consumed, eg a stray one in concise " context. syn region markoCloseTag matchgroup=markoTagDelim \ start="" \ contains=@markoTagNames,markoPlaceholder " The name of a close tag whose "". " It stops before "${", so a dynamic close name's placeholder colors: " syn match markoCloseName \ "/\@1<=\%(" display " A dynamic close name's placeholder, chained so the closing ">" keeps " its color: via the body's nextgroup, via the " close name's. syn region markoClosePlaceholder contained matchgroup=markoPlaceholderDelim \ start="\$!\={" end="}" \ contains=@markoExpr \ nextgroup=markoCloseGt,markoCloseTail syn match markoCloseTail contained "\%(\k\|:\)\+\s*" \ nextgroup=markoCloseGt,markoClosePlaceholder display " The anonymous form (): a body end consumed the "" takes its color directly. syn match markoCloseAnon "\%(" display " Statement and concise tags are absent: bare text in a body is prose. " The close-name refinements keep Conditional at any depth; generic " markoTagName stays out or it would outrank markoCloseName. syn cluster markoHtmlContent contains=markoTag,markoHtmlBody,markoDynamicBody, \markoStyle,markoScript,markoTextareaTag,markoHtmlCommentTag, \markoComment,markoHtmlComment,markoCData,markoDeclaration,markoEscape,markoPlaceholder, \markoEntity,markoScriptlet,markoCloseName,markoCloseAnon,markoAttrTagName, \markoTagNameConditional,markoTagNameRepeat,markoTagNameException, \markoTagNameStatement,markoTagNameBuiltin " Whether "" finds the structural one, " and a "/>" there means self-closing, no body: " go(a > b) /> " " The excluded names are the parser's void and text lists ( " takes no close tag; is in neither and opens a body). Defined " after markoTag and the concise regions so a body wins at "<"; a missing " close leaves the body open, as in html.vim. exe 'syn region markoHtmlBody' \ . ' start=+<\ze\%(\%(area\|base\|br\|col\|embed\|hr\|img\|input\|link\|meta\|param' \ . '\|source\|track\|wbr\|style\|script\|textarea\|html-comment\|html-style' \ . '\|html-script\|let\|const\|id\|log\|debug\|lifecycle\|return\)' \ . '\%(\k\|\$\|:=\@!\)\@!\)\@!' \ . '\z(\%(\k\|[$:]\)\+\)\%(\_s\|[/>|(<.#=]\)\@=' . s:tag_end_ahead . '+' \ . ' matchgroup=markoTagDelim' \ . ' end="" end=""' \ . ' contains=@markoHtmlContent' " A dynamic name -- any mix of literal characters and "${...}" " placeholders -- cannot be paired, so a dynamic body closes at the first " close tag of dynamic shape, regardless of nesting: " heading <${tag}>body exe 'syn region markoDynamicBody' \ . ' start=+<\ze\%(\%(\k\|:\)*\$!\={\)\@=' . s:tag_end_ahead . '+' \ . ' matchgroup=markoTagDelim' \ . ' end=""' \ . ' contains=@markoHtmlContent nextgroup=markoClosePlaceholder' unlet s:tag_end_ahead " Standalone delimited blocks hold html content, like markoTextIndent. " A block closes at its exact delimiter -- same indent, same dash count, " longer or shorter runs are text -- or on any dedent; a column-0 block " only at its delimiter (the empty \z1 kills the dedent end). Defined " after the markoBlockDelim forms so a bare "--" line opens a block: " -- " A block of text, html mode. " -- syn region markoTextDelimBlock matchgroup=markoBlockDelim \ start="^\z(\s*\)\z(--\+\)\s*$" \ end="^\z1\z2\s*$" end="^\%(\z1\)\@!\ze\s*\S" \ contains=@markoHtmlContent " Parsed-text tags: bodies hold only text and placeholders (style/script " embed CSS and TypeScript). Each region consumes only its "<"; the " markoParsedOpen overlay colors the open tag normally: "