Code View

// 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
  };
}