// SPDX-License-Identifier: MIT
/*
Language: FFS-MD
Description: The FFS in-house Markdown dialect — CommonMark-ish base plus
FFS additions: top-of-file front-matter, the [IMAGE:]/[VIDEO: ]/
[SVG: ]/[LIST:] block directives, Confluence-style status marks,
:emoji: shortcodes, and footnotes/reference links.
Author: FFS Language Tools
Requires: markdown.js
Category: common, markup
Token contract: spec/ffsmd/TOKENS.md (de-facto spec: spec/ffsmd/MdRenderer.hh).
Design (PROMPT.md): extend highlight.js's built-in `markdown` grammar; do
not hand-roll CommonMark. FFS tokens are layered on top, tried first.
*/
import hljsMarkdown from 'highlight.js/lib/languages/markdown';
/** @type LanguageFn */
export default function (hljs) {
const base = hljsMarkdown(hljs);
// ── §2 FFS block directives: [IMAGE:…] [VIDEO: …] [SVG: …] [LIST:…] ──
// One "FFS directive" scope (meta), with the inner path as `string`, the
// field separators `|`/`,` as `punctuation`, and float/style keywords as
// `keyword`. Prefixes are matched EXACTLY (TOKENS.md §2.1/§2.2):
// [IMAGE: -> no space [VIDEO: -> one trailing space
const DIRECTIVE = {
scope: 'meta',
// exact prefixes, whole bracketed construct up to the closing ]
begin: /\[(?:IMAGE:|VIDEO: |SVG: |LIST:)/,
end: /\]/,
relevance: 10,
contains: [
{ scope: 'punctuation', match: /[|,]/ },
{
// float / list-style keywords (TOKENS.md §2.1, §2.4)
scope: 'keyword',
match: /\b(?:left|right|disc|circle|square|none)\b/
},
{
// width like 40% / 300px
scope: 'number',
match: /\b\d+(?:%|px|em|rem|vw|vh)\b/
},
{
// remaining field content = the src path / label / list tokens
scope: 'string',
match: /[^|,\]]+/
}
]
};
// ── §3.1 Status marks — a CLOSED set, exact literals only ────────────
const STATUS_MARK = {
scope: 'symbol',
match: /\((?:\/|x|!|\?|y|n|on|off)\)/
};
// ── §3.2 Emoji shortcodes :name: ─────────────────────────────────────
// Match the shape only; renderer validates against the curated list/ISO.
const EMOJI = {
scope: 'symbol',
match: /:[a-z0-9_]+:/
};
// ── §3.3 Footnotes: [^label] reference and [^label]: definition ──────
const FOOTNOTE_DEF = {
scope: 'symbol',
begin: /^\[\^[^\]]+\]:/,
relevance: 10
};
const FOOTNOTE_REF = {
scope: 'symbol',
match: /\[\^[^\]]+\]/
};
// ── §4/§5 Fenced code with visible info-string + injection ───────────
// The built-in markdown `code` mode swallows the whole fence opaquely.
// We add our own fenced modes first so the language tag shows as `attr`
// and known languages delegate to their own grammar (subLanguage).
// `fson` embeds the FSON grammar when it is registered (highlightjs-fson);
// if it is not, hljs falls back to plain text for that block. `cpp`
// delegates to hljs's built-in C++ grammar.
const FENCE_FSON = {
scope: 'code',
begin: /^(?:```|~~~)[ \t]*(fson)[ \t]*$/,
beginScope: { 1: 'attr' },
end: /^(?:```|~~~)[ \t]*$/,
subLanguage: 'fson',
relevance: 5
};
const FENCE_CPP = {
scope: 'code',
begin: /^(?:```|~~~)[ \t]*(cpp|c\+\+|cc)[ \t]*$/,
beginScope: { 1: 'attr' },
end: /^(?:```|~~~)[ \t]*$/,
subLanguage: 'cpp',
relevance: 5
};
const FENCE_TAGGED = {
// any other tagged fence: expose the info-string, no delegation
scope: 'code',
begin: /^(?:```|~~~)[ \t]*([A-Za-z0-9_+-]+)[ \t]*$/,
beginScope: { 1: 'attr' },
end: /^(?:```|~~~)[ \t]*$/,
relevance: 0
};
// ── §1 Front-matter — only at the very top of the file ───────────────
// `---` fence, single-line `key: value` fields, `---` fence. Not YAML.
// highlight.js has no reliable start-of-input anchor, so the opening
// fence is disambiguated from body `---` horizontal rules by requiring
// the next line to look like a front-matter field (`key:`). Body `---`
// rules are never followed by a `key:` line, so this keeps the mode
// confined to the header. Given high relevance and placed first.
const FRONTMATTER = {
scope: 'meta',
begin: /^---$(?=\n[ \t]*[A-Za-z_][A-Za-z0-9_]*[ \t]*:)/,
end: /^---$/,
relevance: 10,
contains: [
{
// key: (the attr and its trailing colon captured together so a
// colon inside a value is not mistaken for a field separator)
scope: 'attr',
begin: /^[ \t]*[A-Za-z_][A-Za-z0-9_]*(?=[ \t]*:)/,
end: /:/,
endScope: 'punctuation',
relevance: 0
},
// quoted string values
{ scope: 'string', begin: /"/, end: /"/ },
{ scope: 'string', begin: /'/, end: /'/ },
// array of strings ["a","b"]
{
scope: 'string',
begin: /\[/,
end: /\]/,
contains: [{ scope: 'string', begin: /"/, end: /"/ }]
},
// dates YYYY-MM-DD(THH:MM…) and the YYYY-MM-DD placeholder
{ scope: 'number', match: /\b\d{4}-\d{2}-\d{2}(?:[T ][\d:]+)?\b/ },
{ scope: 'number', match: /\bYYYY-MM-DD\b/ },
// booleans
{ scope: 'literal', match: /\b(?:true|false)\b/ }
]
};
// FFS tokens must be *prepended* to the markdown base so they win over
// the generic markdown rules (e.g. `[...]` link syntax vs a directive).
const ffsContains = [
FRONTMATTER,
FENCE_FSON,
FENCE_CPP,
FENCE_TAGGED,
DIRECTIVE,
FOOTNOTE_DEF,
FOOTNOTE_REF,
STATUS_MARK,
EMOJI,
...base.contains
];
return {
name: 'ffsmd',
aliases: ['ffs-md'],
case_insensitive: false,
contains: ffsContains
};
}