Synced from
geml-spec/gemlate6829e7— edit it there.
GEML — General Expressive Markup Language(通用表达型标记语言)
English | 中文
规范(稳定版)
| 字段 | 取值 |
|---|---|
| 工作名 | GEML(General Expressive Markup Language,通用表达型标记语言) |
| 版本 | 1.0 |
| 状态 | 稳定(stable) |
| 文件后缀 | .geml |
摘要
GEML 是一种用于结构化、富表达力文档的纯文本标记语言。.geml 文件无需渲染即可作为 纯文本完整阅读;代码、图形、表格、公式、提示框等各类结构化内容统一通过单一的类型块 原语表达;支持稳定标识符并在构建时校验引用;托管外部图形 DSL 而自身绝不内建图形 语言。本规范定义文档模型,块、属性、内联内容与引用的语法,以及合规处理器必须满足的 要求。
目录
约定
本文档中的关键词 MUST(必须)、MUST NOT(必须不)、MAY(可)、 SHOULD(应) 用于表示要求等级:MUST 与 MUST NOT 表示绝对的要求或禁止,SHOULD 表示推荐,MAY 表示可选且被许可的行为。文中「§n」指代编号为该数字的章节。
标注为非规范性的文字仅作解释,不构成任何要求。除非出现在一致性测试集(§8.4)中, 示例均为非规范性。
英文版是本规范的规范性文本,本中文版为资料性译文:二者不一致时, 以英文版为准。
0. 预备
本节定义 GEML 处理器的字符级输入。§1–§9 的每条规则都建立在 §0.5 所定义的 归一化字符流之上。
0.1 字符编码
.geml 文件必须(MUST)使用 UTF-8 编码。处理器必须不(MUST NOT)尝试探测或接受 任何其他编码。
理由(非规范性): 与仅供渲染的格式不同,GEML 承载构建期身份——块 id、跨文档引用, 以及 .gemlhistory 边车中的 SHA-256 内容哈希。它们都定义在字节之上,因此一份文档若 经另一种编码往返,它就是另一份文档,其版本历史也不再可验证。
处理器必须(MUST)以 UTF-8 替换语义解码:非良构字节序列解码为 U+FFFD REPLACEMENT CHARACTER;必须不(MUST NOT)回退到以其他编码重新解释输入。
字符指一个 Unicode 码点。在直觉意义上不构成字符的码点(如组合记号)在本文档中同样 计为字符。
0.2 字节序标记(BOM)
若解码后的输入以 U+FEFF 开头,该单个字符必须(MUST)在解析前移除。只移除开头的一个 U+FEFF;第二个 U+FEFF,或出现在文档其他位置的 U+FEFF,均为普通内容。
0.3 行与行结束符
行结束符指换行符(U+000A)、其后不跟换行符的回车符(U+000D),或回车符加换行符。
行指零个或多个非 U+000A、非 U+000D 的字符,其后跟一个行结束符或输入结束。
空行指不含任何字符,或仅含空格(U+0020)与制表符(U+0009)的行。
每个行结束符必须(MUST)在解析前归一为单个 U+000A。处理器必须不(MUST NOT)让行结束符 的选择改变文档模型:同一文档以 CRLF 与以 LF 书写必须产生完全相同的模型。
注(非规范性): .gemlhistory 边车单独记录文件的主导行结束符,因此恢复某个修订版本 能重现原始字节。归一化管辖的是解析,不是存储。
0.4 不安全字符
U+0000 必须(MUST)替换为 U+FFFD。
理由(非规范性): 对任何按 C 字符串处理的下游消费者,NUL 会截断文档。文档必须不能 (MUST NOT)让流水线中的某个工具看到比解析器更少的内容。
0.5 归一化输入
处理器必须(MUST)在解析前恰好按以下顺序执行:
- 按 UTF-8 解码,非良构序列变为 U+FFFD(§0.1);
- 移除开头的一个 U+FEFF(§0.2);
- 将每个行结束符替换为 U+000A(§0.3);
- 将 U+0000 替换为 U+FFFD(§0.4)。
结果即归一化字符流。每一步都只在行内改写字符——没有任何一步会拆分或合并行—— 因此归一化字符流的行数与输入相同。据此处理器可(MAY)按行号索引原始字节,这正是块级 编辑(geml get/set)能对文件未改动部分保持字节保真的原因。
0.6 媒体类型、后缀与片段标识符
| 文件后缀 | .geml(版本边车:.gemlhistory) |
| 媒体类型 | text/geml |
| 厂商树名称 | text/vnd.geml |
charset 参数 | 唯一允许的取值为 UTF-8,且因与 §0.1 重复而应(SHOULD)省略 |
| 片段标识符 | 块 id,或由两个块 id 派生出的散文段地址(§4) |
text/geml 目前尚未在 IANA 注册;在必须使用已注册类型的场合,使用厂商树名称 text/vnd.geml。.geml 资源上的片段标识符指代携带该 id 的块,与 §5.2 的引用语法 一致——other.geml#budget 无论写成 GEML 引用还是 URL,指的都是同一个块。它也可以指代一段 散文段——两个块之间那段自身不带 id 的文字——用 §4 为它派生的地址。处理器必须(MUST) 两者都能解析,理由是 §8.2(5):未解析的引用是错误,所以一个处理器认得、另一个不认得的片段, 就是同一份文档在前者能构建、在后者失败。
*注(非规范性):*这两个后缀区分的是文档的角色,不是它的格式。.gemlhistory 边车是一份完整、合法的 GEML 文档:它和任何文档一样在 === meta 里声明自己的词汇表 (profile = "geml-history/v1"),校验无诊断。之所以另起一个名字,是为了让工具把两者 分流——收集文档的目录遍历只取 .geml 而放过边车,因为边车的 keyframe 是活文件的镜像、 blob 里装的是已被取代的旧文本,把它扫进来会让每次搜索命中两遍,且其中一半内容早已不在 文档里。凡是按 .geml 做高亮、渲染或解析的工具,都可以同样对待 .gemlhistory;两个 名字都不构成词汇表声明——这是 §8.6.2(2) 排除的,也是边车自己那行 profile 让它不必要的。
1. 约束
本节给出统领后续规范的设计约束。
.geml文件必须无需渲染即可作为纯文本完整阅读。- 代码、图形、表格、公式、提示框必须共用唯一的类型块原语(§3);不为每种内容 单设语法。
- 每个可寻址的块——标题或类型块(§2)——可携带稳定
id;引用必须在构建时解析并 校验(§5)。 - 图形必须内嵌外部 DSL;格式只定义托管协议,绝不内建图形语言(§7)。
- 不存在原始 HTML 逃逸口;语义不绑定任何后端。
- 标题只用 ATX
#。setext 标题与---/===分隔线、frontmatter 规则不属于 GEML。
2. 文档模型
一篇文档是块的序列,块只有两种形态:
- 无栅栏区块(unfenced block)——段落、标题、列表;正文按内联 GEML 解析。
- 类型块——带围栏;正文如何处理由块类型决定(raw / flow / data——§3)。
标题与类型块可携带属性对象 {#id .class key=val}(§4)。段落与列表不可携带: 段落末尾的 {…} 是字面文本,需要 id 的散文放进 text 块(§3)。内联内容只存在于无栅栏 区块中。
2.1 段落
段落是一行或多行非空文本的序列。段落会被出现在行首的以下任何构造打断(结束):
- 空行。
- 标题行。
- 列表项标记行。
- 类型块栅栏(
===)。 %%注释行。
任何不匹配这些打断构造的行都是一条 text-line,并继续该段落。
2.2 列表
列表是连续一行或多行列表项。一个列表项包含前导缩进、标记、一个空格、以及该项的内联内容(§5):
- 无序标记是
-或*; - 有序标记是一个或多个数字后跟
.;首个条目的编号即列表的start。
条目内容是单独一行。条目可以以任务标记开头——[ ]、[x] 或 [X] 后跟一个空格—— 该标记被剥离并记录为勾选/未勾选状态。
嵌套由缩进决定。 缩进按列计(制表符记为 4 列)。比当前条目标记缩进更深的条目,在该 条目下开启一个嵌套列表;缩进更浅的条目则收回到外层列表。两个同级条目之间的空行使 列表变为松散(loose)(否则为紧凑(tight));空行本身不会结束列表。列表在第一个 "既非空行、也不是缩进不浅于本列表的条目行"处结束。%% 注释行(§4)也不是条目行, 同样会结束列表,之后在块级别被识别为注释。
多段落的列表条目不属于 GEML——空行总是结束条目,空行之后的"续行"是普通块级内容。 丰富的条目内容应放进类型块(§3)。
3. 类型块原语
类型块的形态如下:
=== <类型> <属性>?
<正文>
===- 围栏是连续的
=(≥3 个)。一个块由其后最先出现的下列两种行之一闭合:与开围栏 等长的=串,或——当块带有#id时——它的带标签围栏=== #id(长度 ≥3 的=串后跟该块的 id)。带#id不会停用等长裸围栏的闭合:哪种闭合先出现, 块就在哪里结束。 - 嵌套的安全只来自围栏长度纪律:块体中任何一行都不得是与开围栏恰好等长的裸
=串。 推荐的做法是更长的外围栏(====包住===),长于块体内所有围栏样式的行。 带标签的闭合不豁免这条规则——块体内一条与开围栏等长的裸=串会就地闭合该块: 后面的=== #id尚未到达,块体已被静默截断。 - 带标签的闭合消除的是数长度这一风险——闭合行点名它要闭合的块,而不是靠长度匹配—— 推荐在长块中使用(数错
=是长块的常见失误)。但它不能防止块体内出现与开围栏 等长的裸=串——那些串仍然会提前关闭块,带标签关闭对此无效。当块体可能含有围栏样式 的行时,它不能替代更长的开围栏。 - 类型注册表声明每种类型的正文模式:
raw(原样,如带lang=的code、带format=的diagram/table/data、math、带src=的embed)、flow(解析,如note、text)或键值对(每行一个key=val,如meta;该模式在文档模型中序列化为"data"——这个名字早于data块类型,为模型稳定性而保留)。 - 未知类型产生构建告警,其正文按 raw 保留。
text块是可寻址的散文容器:其 flow 正文存在的唯一目的,是给一段散文一个#id与属性,使其可被引用、可被按块编辑(geml get/set)、可被版本化。渲染 为中性块——不带标注样式(标注属于note)。只包裹确实需要寻址的散文;普通段落 仍是默认写法。embed块代表存放在别处的内容:src=指明一个文档,可带片段 (src=other.geml#budget),该块就地渲染为那份内容。片段指向标题时选中标题的整节(即标题本身及其后所有的块,直到遇到下一个同级或更高级别的标题,或者到达文档末尾);片段指向散文段(§4)时选中那一段。src=像任何引用一样受校验(§5),embed块的 body 不使用。part=收窄embed从一个标题那里取走什么,好让一份完全由引用拼装的文档不必 手写自己的标题——而手写的文字正是这类文档要消灭的漂移。head只取标题那一行,body取它之下的全部,intro取到第一个子标题为止的引言;默认是whole。这三个 名字指的就是geml get --head/--body/--intro已经选中的那三块区域,于是文档里的 地址和命令行的选择器不必各造一套词汇。head与body恰好把whole切成互补的两半。 目标不是标题时没有这些部分,整个目标照常选中;取值无法识别时报bad-embed-part告警、整个目标同样照常选中——因为一个悄悄什么都没选中的投射,正是 §8.2 要防的失败。
src= 指向的那份文档,必须(MUST)当作一份完整文档来解析,目标再从解析结果中选出; 它绝不是一段拼进当前文档的文本。因此那份文档里的元数据、引用、相对路径的基准、以及外部 数据(src=),全部相对它自己解析,而不是相对写下 embed 的这份文档:
a.geml b.geml
───────────────────────────── ──────────────────────────────
=== embed {src=b.geml#tbl} === table {#tbl src=rows.csv}
=== ===rows.csv 相对 b.geml 所在目录解析(b.geml 自己被单独打开时也是这么解析的),而不是 相对 a.geml。写下 embed 的文档只决定结果显示在哪里,不改变目标那一侧的任何解析。
两种形式——embed 块与行内投射(§5.3)——都适用;链条的每一层也都适用:链上每份文档都是 它所指向的下一份的基准。
3.1 文法
块结构是上下文无关的,如下所示。内联强调不是上下文无关构造,由 §5.3 的定界符游程算法 解析,而非本文法。
(* 本文法陈述在**逻辑行**之上。在它生效之前,以 `\` 结尾的围栏行或标题行会与其后的
行折叠——反斜杠与换行符合并为一个空格(§4 续行)——因此下文的 NL 指折叠后逻辑行
的结束,属性对象可以占据不止一条物理行。 *)
document = { block } ;
block = unfenced-block | typed-block ;
typed-block = fence , [ SP ] , type , [ SP , attrs ] , NL , body , close-fence ;
(* 围栏后的 SP 是可选的:`===note {#a}` 与 `=== note {#a}` 是同一个块,
`===#a` 与 `=== #a` 同为带标签闭合。要让一行形如围栏的文本保持字面,
用 §5.1 的 `\` 块转义,或把它放进一对配对的 ``` 行之间
(见下),而不是靠省掉那个空格。 *)
fence = "===" , { "=" } ; (* open: N equals signs, N >= 3 *)
close-fence = fence ; (* exactly equal to the opening length *)
type = TYPE-NAME ; (* 比 NAME 窄,见下 *)
body = { LINE } ; (* raw, flow or data per the registry *)
unfenced-block = heading | list | paragraph | comment-line ;
heading = "#" , { "#" } , SP , text , [ SP , attrs ] , NL ; (* 1 to 6 #s *)
paragraph = text-line , { text-line } ;
text-line = LINE ; (* non-empty line not matching an interruption rule *)
comment-line = indent , "%%" , [ SP , text ] , NL ; (* §4:保留在模型中,永不渲染 *)
list = item , { item | blank-line } ;
item = indent , marker , SP , [ task ] , text , NL , { continuation } ;
continuation = indent , text , NL ; (* §2.2:非空、非条目行、非注释行;
缩进深于条目标记列;以软换行并入条目 *)
marker = "-" | "*" | DIGIT , { DIGIT } , "." ;
task = "[" , ( " " | "x" | "X" ) , "]" , SP ;
indent = { " " | TAB } ; (* nesting depth, by column *)
attrs = "{" , { attr-item , [ SP ] } , "}" ;
attr-item = id-attr | class-attr | kv-attr | flag-attr ;
id-attr = "#" , NAME ;
class-attr = "." , NAME ;
kv-attr = NAME , "=" , value ;
flag-attr = NAME ; (* boolean true flag *)
value = bare-word | quoted-string ;
quoted-string = '"' , { quoted-char } , '"' ;
quoted-char = escape-seq | ( CHAR - '"' - "\" ) ;
escape-seq = "\" , ( '"' | "\" ) ; (* only " and \ can be escaped *)
bare-word = BARE-CHAR , { BARE-CHAR } ;
BARE-CHAR = CHAR - SP - TAB - '"' - "{" - "}" ;
(* whitespace ends a bare word and the object's own braces
delimit it, so a value containing whitespace, a quote or a
brace MUST be quoted. Anything else is a bare word, which is
what carries references and paths: `data=#fy25`,
`src=b.geml#tbl`, `src=rows.csv`. NAME and `number` below
are the two shapes §4 gives a meaning — a flag name, a
typed number; every other bare word is a string. *)
number = [ "+" | "-" ] , ( DIGITS , [ "." , [ DIGITS ] ]
| "." , DIGITS ) , [ exp ] ;
(* the bare-word shapes §4 types as a number: `42`, `-1`,
`+1`, `1.5`, `1.`, `.5`, `1e3`, `1.5e-2`. Every other bare
word stays a string — `0x10`, `1e`, `1_000`, `Infinity` — as
does every quoted value. *)
exp = ( "e" | "E" ) , [ "+" | "-" ] , DIGITS ;
DIGITS = DIGIT , { DIGIT } ;
(* leading zeros are allowed: `007` is 7 *)
NAME = NAME-CHAR , { NAME-CHAR } ;
NAME-CHAR = LETTER | DIGIT | "-" | "_" ; (* LETTER:任意 Unicode 字母 *)
TYPE-NAME = ASCII-LETTER , { ASCII-LETTER | DIGIT | "-" | "_" } ;
(* 块类型是文档唯一"选定"而非"派生"的名字:它作为类型注册表的键,
会被敲进命令行,也会作为标识符进入生成产物。所以限 ASCII、字母
开头;而 id、class 和属性键使用的 NAME 故意更宽。这里放开
Unicode 只会换来同名两种编码(NFC/NFD)和注册表键上的形近字,
且没有对应的需求——没有谁像"中文标题被迫产生中文 id"那样被迫
使用中文类型名。 *)NAME 不限于 ASCII,也不要求以字母开头:标题按自身文本派生出的 id(§4)可以以数字或 - 开头,非拉丁文字同样是普通的 NAME 字符。TYPE-NAME 是唯一的例外;而 §8.5 的开放 注册表靠声明词汇表来扩展(§8.6),从不靠名字的形状。
独占一行的三个及以上反引号不开启任何构造——GEML 只有一个代码块 === code——但一对配对的这种行会遮蔽其间的内容,使其不参与上面的扫描:里面的围栏行、标题、列表、%% 行都不再是构造,那一片维持它本来的样子,即流式文本。这道遮蔽是为了不让示例变成定义——一个写在 Markdown 栅栏里、本意是展示语法的块,否则会拿到 id、进入文档的地址空间、被编辑工具改写,而且没有任何人提醒。两条规矩保证它老实。其一,必须配对:未配对的反引号行什么都不遮,因为「未闭合就一直管到文末」会让一行落单的反引号吞掉它后面的每一个块。其二,遮蔽按正文分层——在某个流式正文里开启的一段不会伸出该正文之外——所以远处的一个反引号不可能悄悄改变整篇文档的解析。既要展示 GEML 又要它渲染成代码,用 === code 并把栅栏取得比示例自身长一级(§3);只想让某一行保持字面,用 §5.1 的 \。
3.2 data 块
data 块承载值树——标量、序列与映射,恰为 JSON 的值域——作为受校验的数据; 而 code 承载的是处理器绝不解释的文本。body 在扫描期保持 raw(围栏界定逐字 文本),随后由格式引擎把它解析为块的值;引擎拒绝的 body 是构建错误, 并指名出错行。
format=在这一个模型内部选择表面语法。进入注册表要求语法自描述——仅凭 字节即可确定值,无方言参数。分隔文本不满足(分隔符、有无表头、引号规则都是参数, 且这些参数只有对着列模型才有意义),这正是csv/tsv属于table格式(§6)而 非data格式的原因。json——默认——body 必须(MUST)解析为一个 JSON 值。默认值遵循一条注册表通则: 当模型存在同构的规范语法时,即以其为默认(table→ 管道形式)。JSON 是值树自身 的序列化,也是零依赖处理器始终能校验的那一种语法。jsonl——body 的每个非空行必须(MUST)解析为一个 JSON 值;空行忽略;块的值为各 行值的序列。这是记录流形式:由于文档是平铺的块序列,在文件末尾追加一个完整的data块即是任何文档的合法延续——jsonl 的盲追加工效,外加 id 与校验。src=以外部文件命名块的内容,沿用表格的单一来源纪律(§6):src=与行内 body 二选一——同时给出是错误。文件必须像数据(.json/.jsonl/.yaml/.yml,扩展名即格式;显式format=仍优先于扩展名)。http(s)源由渲染器获取,解析器绝不获取(§9.4)——该块及 其上的图表一并延迟;其他 URL scheme 一律拒绝。这正是日志的安排:记录仍是一个任何 现有工具都能追加、能 tail 的普通.jsonl文件,GEML 文档是它受校验、可寻址、可出 图的视图。yaml、toml与edn是保留格式名(那几种语法的值树读法)。没有相应引擎的 处理器必须(MUST)保留原文 body 并发出警告,且不得(MUST NOT)猜测——降级方式 与未知diagram格式(§7)完全相同。未知的format=值同样降级。- 处理器可以(MAY)为保留名提供引擎。由于完整 YAML 远大于这里的值域,
yaml引擎 必须(MUST)至少读入以下子集,且必须(MUST)按此读法——这样子集之内的 body 在每 个有引擎的处理器中含义相同:- 按缩进嵌套的块映射与块序列,含
- key: value与- - item;平量标量、单引号 与双引号标量;|、|-、>、>-块标量;注释;开头的---与结尾的...; 以及[]/{}表示空序列与空映射。 - 平量标量按 YAML 1.2 的核心 schema(core schema)读:
null、~与空值为 null;true/false为布尔;其整数与浮点形式(含+80、0x1f、0o17、.5) 为数;其余皆为字符串。故yes、no、on、off是字符串——不得(MUST NOT)套用 1.1 的读法。 - 其余一切——锚点、别名、标签、合并键、第二个文档、除
[]与{}以外的流式集 合、.inf与.nan(此值域没有无穷与 NaN),以及用制表符缩进——都在子集之外。 处理器可以(MAY)读入更多 YAML,但依赖子集以外构造的文档不可移植;不读入 它的处理器必须(MUST)报解析错误,而不是猜一个值出来。
- 按缩进嵌套的块映射与块序列,含
schema=为保留属性:指名存放 schema 的块(#id)或 GEML 文档(doc.geml, 可带#id),并像任何引用一样接受引用检查(§5);其他形状是错误。按 schema 校验 值不在本版规范定义之内。- 成功解析的 body 以块的
value暴露在文档模型中。规范序列化只对json与jsonl有定义——分别重排为两空格缩进、每行一个紧凑值。其他格式的 body 与任何 raw body 一样逐字节保留,包括处理器已解析出值的那些:把yaml的 body 按值 重新输出,等于把文档字节改写成 JSON。 - 值为记录数组的
data块可作图表数据源——见 §7.1。
3.3 源路由
code 块可(MAY)用 src= 指明它所展示的代码,而不在文档里存一份拷贝;data 块可(MAY)用同样的方式指明它的值(§3.2)。两者共用一套路由语法:
<path>[#L<start>[-<end>]]路径指明一个文件;可选片段把它收窄到一个行范围,1 起、闭区间(#L14-24, 单行写 #L14)。不带片段时路由指整个文件。范围必须不(MUST NOT)为空或起于第 1 行之前。
- 解析基准。 路由按文档相对解析。当命名了解析根(
--root)时,文档相对不存在 的路由可(MAY)再按该根解析——生成式代码图正是这样从嵌在源码之下的文档里写路由的。 两种解析都仍受 §9.4 的约束边界限制。 - 远端。
http(s)路由由渲染器获取,而非解析器(§9.4);该块及读取它的一切 一并延迟。其他 URL scheme 必须(MUST)拒绝。 - 无法解析的路由是告警,不是错误。 无论那个文件此刻能否取到,
code块都仍然 指明「某位置的一段代码」,因此描述了缺失源码的文档——例如单独发布的生成式代码图, 或描述另一份 checkout 的图——依然有效,只是未经核对。data路由相反:它的值正是 文档所承诺的东西,取不到就是错误(§3.2)。而被禁止的 URL scheme 两者都属于书写 错误,必须(MUST)拒绝。 - 过期范围是错误。 若文件已不含所指的行,处理器必须(MUST)报
bad-source-range。这正是校验路由的意义:漂移的引用让构建失败,而不是渲染出一片 空白。 src=旁的块体是错误。code块不得(MUST NOT)同时携带src=和内联块体;两者只能选其一(code-src-and-body错误,附录 A)。- 不设扩展名门。 代码可以是任何语言,所以指向代码的路由不按后缀限制;安全边界 只由约束(§9.4)承担。
data路由保留它的.json/.jsonl/.yaml门(§3.2),那道门的 用途是指明格式,不是界定文件系统范围。
对 data,范围只是把文件收窄为若干行,之后仍按该格式正常读取:jsonl 日志的窗口 是最自然的用法,而 json 切片当且仅当该切片自身就是一个值时才成立。
4. 属性与标识符
{#budget}设定块 id 为budget。文档内 id 必须唯一。两个 NAME 在 Unicode NFD 归一化后相等,即为同一个名字。 NAME 可以包含任意 Unicode 字母,而带变音符号的字母有不止一种编码:用 U+00E9 写的
Café与写成e+ U+0301 的Café看起来完全相同,作者把标题写成一种形式、把引用写成另一种 形式时,意思是一个名字而不是两个。因此处理器必须在 NFD 下比较 NAME——id、类名 与属性键一律如此——这同时约束 id 唯一性(duplicate-id)与引用解析([[#id]]、other.geml#id、URL 片段)。选 NFD 而非 NFC 仅因分解是更便宜的操作;这个选择不可 观察,因为处理器不得为此改写文档自身的字节,且必须按文档写下的形式上报 id。 显而易见的替代方案是归一化字符流,而 §0.5 刻意不这么做:那会移动行内的字节偏移, 破坏 §0.5 存在的目的——按块的字节忠实编辑。meta会合并,而#meta命名的是那个合并结果。 一份文档可以携带多个meta块;它们的键会合并——同一个键的后续定义是duplicate-meta-key警告, 保留的是第一个定义。#meta命名的正是这个合并视图,而不是其中任何一个块, 它也是本规范中唯一的保留 id。当文档只有一个meta块时,块与合并结果是同一个 东西,因此仍可(MAY)声明{#meta};有两个或更多时则是reserved-id错误——否则同一个地址对读者意味着某一个块、对处理器意味着全部块的合并。#meta命名的是值而非文件中的一段,所以处理器读它时回答的是合并后的键, 而不是字节。{.warning}添加语义类(不含样式)。{caption="年成本"}等key=val是各类型自定义的参数。其中两个不属于任何单一类型、 在每个类型块上都合法:caption——渲染器随块显示的短标签,§5.2 也用它作自动引用的 文字——以及下文的hidden标志。类型未定义的键报unknown-attribute告警,绝非错误, 且原样保留。标题的 id 未显式给出时按文本自动生成;显式给出时写在标题行末尾的属性对象里, 如
## 标题 {#sec}。该派生规则是规范性的:引用([[#id]]、other.geml#id、 URL 片段)必须在所有实现中指向同一个块,因此标题产出什么 id 不能由实现自行决定。 从标题文本出发——取{{key}}插值之前的原始文本,即字面包含的大括号和变量名,以保证 id 的稳定性,不受变量值改变的影响—— 处理器必须按此顺序:- 转为小写;
- 归一化为 NFD——这会把每个变音符号变成独立的组合字符,于是下面的第 4 步把它们 全部删除,派生 id 不携带变音符号。没有这一步,结果就取决于一张 Unicode 查表 而不是一条规则:有预组合码位的(
e+ U+0301 就是字母é)会活过第 4 步,而没有 的(İ小写为i+ U+0307,没有单一码位能拼出它)会被删除——同一类输入,两种 命运。这一步同时也让派生结果不受作者编辑器产出何种归一化形式的影响; - 删除每一处代码跨段(code span),连反引号带内容一并删除——这样
`foo()`内部的标点不会渗进 id; - 删除既非 Unicode 字母、非数字、非空白、也非
-、也非_的每个字符; - 去掉首尾空白;
- 把每一段连续空白替换为单个
-。
于是
## Use \foo()` in 2024 设计派生出#use-in-2024-设计;而由于第 2 步分解、 第 4 步随即删除组合字符,## Ubytovací zařízení派生出#ubytovaci-zarizeni。第 4 步 保留每个 Unicode 字母,而变音符号不是字母,所以不书写组合字符的文字不受影响:## 设计说明派生出#设计说明。**因此仅在变音符号上不同的两个标题会派生出同一个 id 并冲突**——duplicate-id错误,后一个必须显式声明。在变音符号用于区分 词义而非装饰的语言里,这是常态而非边缘情况,此类文档应当自带显式 id。派生 id 与其他 id 一样会冲突:两个标题派生出**相同** id 是duplicate-id**错误**(附录 A)——该 id 指向第一个,后面那个必须显式声明。派生 id 保留下划线:foo_bar派生#foo_bar,foobar派生#foobar——两者不同。标题文本若既无字母也无数字,派生结果为空 id;空 id 同样是 一个派生 id,因此第二个这样的标题会与它冲突——请给其中之一显式写上`。散文段有地址,由它两侧的东西派生。 两个块之间的文字不带
id,于是没有任何东西能 命名它,而一份由引用拼装出来的文档会静默地把它丢掉。下面的派生规则堵上这一点,并且 和上面的标题派生一样是规范性的,理由相同:引用必须在所有实现中指向同一份内容。散文段是一段极大的、既不是类型块也不是标题的相邻内容。它的容器是包含它的最内层 标题所辖的小节,或者整份文档。设 P 为该容器内它前面最近的类型块或标题,N 为它后面 最近的那个,C 为容器:
地址 P 与 N 都在 P-between-N无 P——该段开启容器 C-before-N无 N——该段收束容器 C-after-P两者皆无 没有地址:容器里再无他物, C本身已经指代它用 id 本身书写,所以
#pub-before-cmd就是标题#pub与块#cmd之间那一段。关系词 由结构决定,而不是被选择,因此一段散文恰好只有一个地址,两个处理器不可能拼写不同。 每种形式都点住了该段的两端,这正是让编辑变响的原因:在#cmd与#verify之间插入 一个块,#cmd-between-verify就不再指代连续的散文,于是它停止解析(§8.2(5)), 而不是悄悄指代一段更短的文字。三条规则把它补全:
- 地址需要它的锚有 id。紧邻匿名块的散文段、或直接位于文档正文里的散文段,没有地址; 它仍然是内容,只是不可被引用。
- 已声明的
id永远优先。 处理器先在已声明的 id 里解析片段,之后才查这些地址,所以 有人把某个块命名为{#pub-before-cmd}时,那个块遮蔽这一段,而不是与它竞争。 - 这些地址是被匹配的,绝不被反向解析。 处理器为一份文档派生出这个集合,然后在其中 查找片段;它不会去切分一个地址来还原出两个锚。因此 id 里含有
-before-、-after-或-between-也不会造成歧义,也不需要任何转义就留在 NAME(§4)之内。
风格建议(非规范性):文档标题放在
=== meta(title = "…")而不是顶级标题 ——这样每个标题都对应文档中一个真正的小节。属性值类型:带引号
"…"恒为字符串;true/false为布尔;匹配整数/浮点 语法的裸词为数字;其余裸词为字符串。不支持数组、日期与嵌套表。不带
=的裸属性词是布尔标志,置为true(如hidden)。=== meta块以每行一个key=val承载文档元数据,按上述属性值类型规则。若文档中存在多个=== meta块,它们的键将被合并;先定义的键优先——后来定义相同键时,触发duplicate-meta-key告警,后定义被忽略。哪些块算在内,按 §3 通常的嵌套规则:处在顶层或某个flow正文里的=== meta是一个块,它的键属于本文档;而出现在raw或data正文里的那个,是该正文的内容,什么也不定义——所以文档可以在更长围栏的code块里把=== meta当例子展示,那些键不会变成真的。流式profile是保留的 meta 键:它声明本文档使用的应用层词汇表(§8.6),处理器 必须不(MUST NOT)把它读作别的东西。 正文中的{{key}}会被替换为对应的 meta 值;遇到未知键是构建 error。插值读取 流式正文的源文本,并遵循 §5.3 阶段一(1)的原样 atom:代码片段或行内公式里的{{key}}原样保留(因此 GEML 文档可以引用这一语法本身),原样(raw)块体从不 插值,反斜杠转义的{{key}}渲染为字面文本{{key}}。插值是对流式正文的单趟替换,这两点都是规范性的。单趟:被代入的值绝不会被再次扫描, 因此值里若本身写着
{{other}},产出的就是这六个字符本身——没有嵌套要解,也就没有环要 检测(a = "{{b}}"与b = "{{a}}"会终止,各自产出字面文本)。流式正文:属性值里的{{key}}不插值——caption="{{title}}"就是那串字面字符串——这也正是标题派生 id (见上)稳定的原因。hidden标志把一个块标记为属于文档、且完全参与引用校验,但 不渲染——例如只为图表供数的源表。%%行是隐藏的、原样的、永不渲染的作者备注。两者的分工是:对于参与文档模型(作为数据源、可复用片段等)但应保持不可见的结构化内容,使用hidden;对于不参与文档模型的废弃注释,使用%%。注意,%%仅在块位置(顶层,或flow块的正文内)被识别为注释。在raw块的正文内部,%%行被精确原样保留,不视为注释。属性顺序不影响语义;推荐顺序为
#id、.class、key=val。因此同一个属性对象里 不得(MUST NOT)把一个 NAME 写两次:重名恰恰会让顺序变得有意义。类、key=val与裸旗标写的是同一个 NAME——旗标就是key=true——所以{.link link=http://x link}把link写了三次,是duplicate-name错误(附录 A)。#id是主键,不参与这条:{#a .a}是一个 id 加一个类,正如 HTML 里id与class是两个不同的属性。续行(Line continuation): 以反斜杠
\结尾的块围栏(===)或标题(#)行, 会将其属性对象延续到下一行。反斜杠和换行符会被视为一个空格,从而允许将长属性对象 (例如表格的 schema)拆分换行以提高可读性。只要续上来的行同样以\结尾,折叠就继续, 直到第一条不以\结尾的行为止;折叠结果就是 §3.1 文法所解析的那条逻辑行。只有围栏行 与标题行会折叠:散文行尾的\是硬换行(§5.1),块正文内行尾的\是正文文本。
5. 内联内容与链接
5.1 内联元素
内联元素只出现在无栅栏区块内部。
| 语法 | 含义 |
|---|---|
*强调* | 强调(emphasis) |
**加重** | 加重(strong) |
`代码` | 代码片段(原样;内部不解析) |
~~删除~~ | 删除线 |
$…$ | 内联数学(正文原样) |
{…} | 就地媒体嵌入(图片/音频/视频) |
![[#id]] | 就地内容嵌入(内联投射) |
行尾 \ | 强制换行 |
\ + ASCII 标点 | 转义:该标点取字面值 |
- 强调/加重定界符必须贴住非空白字符,且不得跨块边界。
- 块级逃逸:因为块级语法(如
===围栏、#标题、-列表)必须在行首匹配,所以在行首添加反斜杠(如\===或\#)会破坏块匹配,使其降级为普通正文。随后,内联解析器会将\+标点 转义为字面字符,从而完美实现块语法的逃逸渲染。 - 块级数学使用
=== math类型块(§3)。 - 嵌入
![…]就地渲染/播放其源(绝不跳转),链接[…]则跳转。as ∈ {image, audio, video},省略时按源扩展名推断。 - 列表项可以任务标记开头——
[ ](未完成)或[x]/[X](已完成)后跟一个 空格。该标记从项文本中剥离并记为勾选状态;其余文本按内联解析。
5.2 链接与引用
内部与跨文档引用均在构建时校验。
| 形式 | 含义 |
|---|---|
[文字](https://…) | 外部链接 |
[文字](#budget) | 指向块 budget 的内部引用,文字自定义 |
[[#budget]] | 自动引用:链接文字取自目标的 caption/标题(若皆无则后退使用字面串 #id) |
![[#budget]] | 内联投射:来自块 budget 的内容 |
[[other.geml#budget]] | 同上,但跨文档:指该文档中的那个块 |
![[other.geml#budget]] | 内联投射,跨文档 |
[文字](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/other.geml#budget) | 跨文档引用 |
[^note] | 脚注:把 id 为 note 的块渲染为脚注 |
- 外链选项放进属性对象:
[文字](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/url){rel=nofollow target=_blank}。 - 无法解析的
#id、other.geml#id或[^id]是构建错误。 - 只有当目标是
.geml文档时,#之后的片段才被读作块 id。 在page.html#sec或notes.md#sec里,片段属于那个格式——元素 id、站点生成的 标题 slug——GEML 构建既不解析它也不报告它。目标文档本身仍必须存在;只有#之后的部分交还给定义它的格式。 - 脚注引用会指向任意带有匹配
#id的块(通常是一个note块),渲染器可据此将其表现为文档脚注。 - 内联投射
![[#id]]必须(MUST)解析到一个正文恰好含有一个非空段落的text块—— 投射把该段落的内联内容插入投射位置。目标若为标题、非text类型块、或含多个段落的text块,则触发inline-transclusion-not-inline错误(附录 A)。块级内容请改用=== embed {src=#id}。 - 注(非规范): 反向链接与图谱是对已解析引用的倒排索引,由工具层提供;GEML 不为此新增语法。
引用可以携带坐标。 块地址之后可(MAY)跟一个或多个方括号步进,命名该块内部 的一个单元——表格的行与单元格、data 块值树中的一个节点(§3.2),或合并后 #meta 的一个键(§4):
| 形式 | 含义 |
|---|---|
#fy[2] | 块 fy 的第二个正文行;表头不算行 |
#fy[2]["Q1"] | 一个单元格,列由表头名字指定 |
#fy["Q1"] | 一整列,不含表头,按行序 |
#fy[summary] | summary= 汇总脚行(§6),#fy[summary]["FY"] 则是其中一格 |
#intake["fields"][1]["name"] | data 块值树中的一个节点 |
#meta["version"] | 本文档合并后 meta 中的一个值 |
- 方括号内只有三类记号,两两不会混淆:裸整数是行号或序列下标,带引号的字符串 是列名或映射键,裸词是保留行名——本规范只定义
summary一个。行是 1 起始,值树的序列是 0 起始:前者是读者数得出来的行,后者是 JSON。 - 没有表头行的表格在模型里本就带有字母列名(
A、B、…),它们与compute=、summary=读的是同一套列名(§6)——一套列名,而不是两套。 [必须不(MUST NOT)出现在 NAME 中(§4),因此坐标永远不会被读成 id 的一部分, 坐标出现之前写成的文档也不会改变含义。坐标与跨文档寻址原样组合 (other.geml#fy[2]["Q1"])。- 无法解析的坐标——下标越过最后一行、表头没有那一列、值树没有那个键,或者把坐标 用在没有内部单元的块上——是
unresolved-reference错误,与其他未解析引用一致。 只有table、view、data与meta带内部单元;embed自身不带,因为它的块体 为空、src=在渲染期才解析(§3),所以对 embed 用坐标同样是这条错误,且诊断应 (SHOULD)指出真正能解析的那个地址——即 embed 源上的坐标。 - 当且仅当坐标命名一个叶子值(一个完整独立的值)时,它可(MAY)作为内联投影
![[…]]或embed的src=的目标。位置切片——一整行或一整列——必须不 (MUST NOT)作为投影目标:想要哪一行通常是谓词而不是下标,而挑选行是消费方块的 事(§6.1),不是地址的事。 - 注(非规范): 坐标只在它上方的单元不动时才稳定。插入一行会让它下方的每个 下标位移——这正是「凡是单元能带 id 就该带 id」的理由,也是
summary用词而不用 下标、以及geml find只报所在块的理由。
5.3 识别顺序与强调
无栅栏区块的内联解析分两个阶段进行,并为每个输入指派恰好一个解析。
阶段一——atom(从左到右,按此优先级):
- 反斜杠转义(
\+ ASCII 标点 → 该字面字符;行尾\→ 硬换行)、代码片段、内联数学; 其内容不再进一步解析。 - 元数据插值(
{{key}});替换为标量值。 - 图片(
)、链接、自动引用([[#id]])、内联投射(![[#id]])与脚注引用([^id]);链接或引用不得嵌套在另一个 链接或引用内部。
atom 之间的文本是字面文本。被转义的定界字符是一个字面 atom,因此不参与强调。
阶段二——强调在阶段一产出的整个内联序列上运行:字面文本段与 atom,按原顺序。 一对定界符可以包住 atom——*见 [规范](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/s.geml)* 是一段包含链接的强调——但每个 atom 都是不透明的:atom 内部的字符绝不是定界符(代码片段里或链接目标里的 * 保持原义),atom 内部也不再进一步解析。强调不跨越块边界。强调、加重、删除线由 定界符游程 flanking 解析:
- 一个定界符游程是字面文本中
*的最大连续串,或两个及以上~的最大连续串 (单个~是字面)。 - 取游程紧邻的前后源字符(整个内联序列的开头与结尾视为空白):若游程后面不是空白,且"要么后面不是 标点、要么前面是空白或标点",则它左侧贴合(left-flanking);右侧贴合是镜像。 左侧贴合的游程可开,右侧贴合的游程可闭。
- 在 atom 边界上,"游程前/后的字符"是该 atom 的边缘源字符——它所消费的源文本 跨度的第一个或最后一个字符。
[链接](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/x.geml)之前的*看到[,其后的看到)(都是标点);硬换行之后的游程看到被换行消费的\n(空白),因此不能在那里闭合。 被转义的定界符是一个 atom(§5.3(1)),其边缘字符是反斜杠与被转义的字符——绝不 并入相邻游程的长度。 - 此处的标点指任何属于 Unicode 标点类(
P*)或符号类(S*)的字符,不限于 ASCII。“、)、,与"、)、,同样是标点,因此“*(foo)*”与"*(foo)*"的强调 判定完全一致。(§5.1 的\转义则相反:只作用于 ASCII 标点,因为非 ASCII 字符本就 不是 GEML 语法。) - 一次从左到右的扫描完成配对:每个可闭游程匹配最近的、同字符的、在它之前的可开游程。当一个 游程既可开又可闭时,若两个游程长度之和是 3 的倍数,则该配对被拒绝,除非两者长度都是 3 的 倍数(rule of three)。
- 匹配的
*对是强调(每侧消耗一个)或加重(两者都 ≥ 2 时每侧消耗两个);匹配的~~对是删除线(每侧消耗两个)。任何未配对的定界符都是字面文本。
这是 CommonMark 的定界符游程算法——flanking、三的规则、以及跨内联 atom 的配对——在 GEML 定界符上的限定版:只有 * 和 ~~,没有 _ 强调,也没有引用式链接。*一段带 [链接](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/x.geml) 的文字* 就是包住链接的强调,与 CommonMark 完全一致(GEP-0007)。
6. 表格
块类型 table,两种可互换正文,解析为同一模型。表格装的是事实:它不派生 任何东西,也不借用任何东西。对关系做选择、派生与聚合属于 view 块(§6.1)—— 它取一个表格的输出(或另一个 view 的输出),并发布自己的关系。
(a) 可视化形态
=== table {#budget caption="年成本"}
| 方案 | 人月 | 单价 |
|-------|-----:|-----:|
| 基础版 | 1 | 30 |
| 专业版 | 2 | 30 |
===(b) 数据形态 —— 分隔文本,一行一条记录:
=== table {#fy25 caption="FY2025 各部门营收($M)" format=csv header=1}
Segment, Q1, Q2, Q3, Q4, PriorFY
Cloud, 124.5, 131.2, 142.8, 158.3, 470.0
Hardware, 88.1, 84.6, 90.3, 95.7, 372.0
Services, 45.2, 47.8, 49.1, 52.6, 168.0
==={…} 属性对象用 §4 的 \ 续行拆开——反斜杠与换行符合并为一个空格。这些反斜杠是必需的: 没有它们,开围栏的 {…} 就永远不闭合,整个块——连同围栏——都会变成一个段落。 那些数字所要求的派生列与合计,属于这张表之上的一个 view:
=== view {#fy25-report src=#fy25 \
compute="FY [%.1f] = Q1 + Q2 + Q3 + Q4; \
YoY [%.1f%%] = (FY - PriorFY) * 100 / PriorFY" \
summary="Segment = 'Total'; \
Q1 = sum(Q1); Q2 = sum(Q2); Q3 = sum(Q3); Q4 = sum(Q4); \
PriorFY = sum(PriorFY); FY = sum(FY); \
YoY [%.1f%%] = (sum(FY) - sum(PriorFY)) * 100 / sum(PriorFY)"}
===该 view 解析为:
| Segment | Q1 | Q2 | Q3 | Q4 | PriorFY | FY | YoY |
|---|---|---|---|---|---|---|---|
| Cloud | 124.5 | 131.2 | 142.8 | 158.3 | 470.0 | 556.8 | 18.5% |
| Hardware | 88.1 | 84.6 | 90.3 | 95.7 | 372.0 | 358.7 | -3.6% |
| Services | 45.2 | 47.8 | 49.1 | 52.6 | 168.0 | 194.7 | 15.9% |
| Total | 257.8 | 263.6 | 282.2 | 306.6 | 1010 | 1110.2 | 9.9% |
- 分隔符 —— 数据形态的正文按其格式的天然分隔符切分:
format=csv用,,format=tsv用制表符。delim=把它改成任意单个字符,于是欧洲式;分隔的 CSV 或|分隔的导出文件无需先转换就能直接读。该值必须恰好是一个字符;否则是bad-table-delimiter错误,并改用天然分隔符,让表格其余部分照样读得出来。属性值不带 转义语法(§4),所以制表符分隔写作format=tsv,绝不是delim="\t"。数据正文只按分隔符 切分、别无他物:delim="|"下,| a | b |两端的竖线各自是一个单元格——剥掉它们是视觉 形态(a)的规则,不是这里的。delim只细化数据形态,并不选择它:表格没有数据format时它被忽略,并报出一条ignored-table-delimiter警告。 - 数据来自别处 —— 表格可以不写内联正文,改为声明数据来自哪里。对于
table块这个 属性是src=(diagram把同一件事写作data=,见附录 B.3),它接受三种目标,按同一 规则解析:数据文件(format=csv/tsv,相对于文档的路径或http(s)URL)。src=指向 一个块——#id或doc.geml#id——是错误,并指向view(§6.1):块的输出是别人 派生出来的东西,而装着别人派生结果的"事实表"两头都不是。相对路径目标必须在构建期 解析并校验存在——解析不到是错误。进入.gemlhistory哈希的 只有src/data这串文本,绝非解析出的内容。表格不得同时给数据来源和内联正文 (错误)。内联仍是默认。
下面四条——计算列、单元格装不下的结果、汇总行、显示格式——定义的是 compute= 与 summary= 所接受的语法。这两个属性属于 view(§6.1),不属于表格;语法写 在这里,是因为无论在哪里派生关系,用的都是同一套语法。table 上写这两个属性, 与任何放错位置的键一样,是 unknown-attribute 警告(§8.2)。
计算列 ——
compute列出一条或多条列名 = 表达式公式,以;分隔。每条 表达式对每个数据行求值一次,运算符限+ - * / ( )与一元-(*//优先级高于+/-,左结合)。当遇到空值或非数值单元格时,行级公式计算会将其作为0处理以保证公式完成,并必须(MUST)为每个被代入的单元格报出一条compute-non-numeric-cell警告:结果照常产出,但读者会被告知它建立在哪个单元格上,而不是拿到一个静默算错的数。而在执行聚合函数时,除count会统计所有非空单元格的数量外,其余函数(如sum、avg)都会跳过非数值单元格(不计入总和或平均值分母)。列按表头名引用(名字含空格用引号,如'Unit Price'),或按电子表格列字母引用(A、B…)。公式可引用更靠前的计算列 (上例YoY引用FY);引用必须无环。计算列按公式顺序追加在数据列之后,正文中 不再书写。单元格容纳不了的结果 —— 除以零得到 ±∞,
0 / 0得到 NaN,两者都不是表格值:该单元格 必须(MUST)不持有任何值、必须(MUST)显示为-,处理器必须(MUST)报出一条指明该单元格的compute-not-a-number警告。既然该单元格不持有值,后续公式或聚合读到它时就按其他非数值 单元格处理(行级公式记为0,sum/avg跳过)。summary表达式同理。分母为零是数据的 事实、不是文档的缺陷,所以它是警告、文档仍然合规——但绝不静默。汇总行 ——
summary定义表尾的单独一行,由单元格 = 值条目组成、;分隔, 左侧指明目标列。每个值要么是用作标签的字符串/数字字面量(Segment = 'Total'), 要么是把聚合函数sum、avg、min、max、count(各作用于一列)用+ - * / ( )与 字面量组合而成的表达式((sum(FY) - sum(PriorFY)) * 100 / sum(PriorFY))。聚合把 一列在数据行上折叠,是唯一跨行的构造;汇总表达式中每个列引用都必须被聚合归约(裸 列名在汇总行没有值)。未指定的列留空。显示格式 —— 计算列或汇总单元格可在左侧列名后附带
[printf]格式:FY [%.1f]、YoY [%.1f%%](%%为字面百分号)。格式仅作用于数值的显示,不改变存储值。不支持 日期/时间格式:单元格值只有字符串、数字、布尔(§4),日期按 ISO-8601 纯文本书写。这个拆分只定义在公式的左侧:格式是左侧最后一个
[…]组,它必须(MUST)位于左侧末尾 (允许尾随空白)、必须(MUST)不含]、且必须(MUST)含有%。它之前的部分去掉首尾空白 即为列名。正是这条%判据让列名本身可以带方括号:[Data] = A + B中没有任何东西匹配 格式,列名就是[Data];[Data] [%.1f] = A + B中格式是%.1f,列名仍是[Data]。刻意排除(让表格是文档特性而非电子表格引擎):单元格与区域寻址(
@3$4、@2$1..@4$3)、相对行引用(@-1)、条件式、跨表remote()引用、查表/VLOOKUP, 以及任何嵌入程序(无 Lisp、无 JS)。
6.1 view 块
view 发布一个从另一个关系派生而来的关系。它不取正文——与 src= 并存 的正文是错误——并且必须有 src=,指向一个数据文件(rows.csv)、本文档中 的一个块(#tickets),或另一文档中的一个块(other.geml#tickets)。src= 解析 到的块既不是 table 也不是 view 时是错误:它没有发布任何可派生的关系。
- 选择。
where="<表达式>"保留行;order="<键>[ asc|desc][, …]"排序,默认asc;limit=<n>取排序后的前 n 行;select="<列>[, …]"既收窄也重排列, 且只接受列名——其中出现=是错误,并指向compute=。 - 派生。
compute=与summary=使用 §6 定义的语法。 - 聚合。
by="<列>[, …]"分组;aggregate="<名> = <fn>(<列>)[; …]"用summary=的语法和它的聚合函数命名分组的列。by=不带aggregate=就是那些 键的去重集合,分组按首次出现的顺序排列。aggregate=不带by=是错误: 对全部行只出一行聚合,那是summary=。在分组 view 上compute=只对输入行 求值,因此其中出现聚合公式是错误,并指向aggregate=。 where=表达式用= != < <= > >=把一列与一个数或一个单引号字符串相比, 并以not、and、or(按此优先级)与括号组合。列名含空格时可用单引号括起。 非数值单元格永远不匹配数值比较——这是数据,不是诊断;但拿一个完全没有数值 的列去做数值比较是错误,因为那个过滤条件只可能匹配到空。
求值顺序就是 SQL 的逻辑处理顺序:
src装载 →compute的逐行公式 →where过滤 →compute的聚合公式 →by/aggregate折叠 →order排序 →limit截断 →select收窄 →summary聚合。
因此 compute= 分两遍跑,而 where= 可以(MAY)引用逐行公式产生的列,但 不得(MUST NOT)引用聚合公式产生的列:那个值取决于过滤留下了哪些行,于是 过滤条件会自己决定自己的输入;报错时点名的是那条公式,而不是那处引用。这样换来 的好处是 sum(FY) 只有一个含义——对显示出来的那些行——在 compute= 与 summary= 中都一样。select= 在 order=、limit= 之后跑,正是为了让它们能引用 view 并不展示的列而无需第二套作用域规则;summary= 最后跑,使报告行成为读者所见 之物的合计,并且它必须指向一个在 select= 之后仍然存在的列。
排序在各处理器之间是确定的。 每个排序键的种类只判定一次,对整列:该列每个 单元格都持有数值时按数值比较,否则按文本。文本按 UTF-16 码元比较——绝不用 locale 排序规则,那会让行序取决于机器。排序是稳定的:并列保持源顺序,这正是 limit= 可复现的原因。
view 看见什么。 src= 指向一个块时,取的是该块的元组连同它计算出的那些 列——派生被封装起来,于是消费者依赖的是列名,而不是这些列是怎么来的。源的 summary= 行不跨越过来:compute= 扩展每个元组、结果仍是一个关系,而聚合行 是叠在下面的另一个关系。compute= 定义一个源已经发布的同名列是合法的,并得到一 条 shadowed-source-column 警告;此后源的那一列在本块内不可达。
链条会终止。 一个 view 可以(MAY)作为另一个 view 的 src=,这就是过滤分组 (SQL 的 HAVING)、二次排序或二次投影的写法。src= 链回到起点是错误,并点名环 上的每一个 view;合法链条的深度界与嵌套 embed 完全相同(§9.3)。
没有行匹配不是错误。 过滤后一无所剩的 view 渲染出表头和空正文;真正拦住"本 该有行却因笔误变空"的是未知列错误。view 没有属于自己的字节,所以坐标(§5.2) 读它的单元格、永远不能写——写会被拒绝,并指向源关系。
7. 图形
块类型 diagram 托管外部图形 DSL。
=== diagram {#flow format=mermaid caption="评审流程"}
graph LR
A[草稿] --> B{评审}
B -->|通过| C[发布]
B -->|打回| A
===format选择可插拔渲染器(mermaid、graphviz、d2、plantuml…)。- 正文为
raw,原样交给该渲染器。 - 处理器必须暴露渲染器注册表,且不得自行解释正文。未知
format产生告警,保留 正文。 #flow让该图可被引用:见 [[#flow]]。
7.1 绑定数据的图表
diagram 可用 data= 声明数据源,接受的目标形式与 table 的 src= 相同(§6): 数据文件(.csv/.tsv 相对于文档的路径或 http(s) URL,视为匿名表;或本地 .json/.jsonl 文件,视为匿名记录源);#id,指本文档中的某个块;或 doc.geml#id,指另一个文档中的。处理器必须解析该引用,并向渲染器提供一个 表模型。table 块贡献其模型(含计算列);值为记录数组——非空的映射序列—— 的 data 块(§3.2)通过将记录键按首见顺序投影为列来贡献表模型。图表引用到的每一列 必须(MUST)在每条记录中存在且为标量值;图表未引用的列可放任何内容。悬空引用、 无法产出表模型的目标、或违反上述规则的记录,都是构建错误。处理器仍 不解释 body。
内置 geml-chart 渲染器把表画成图表。format 仍只选渲染器;图表完全用属性 描述,因此处理器能校验(body 留空——非空 body 给告警):
=== diagram {#rev format=geml-chart data=#fy25 type=bar x=Segment y=Q1 caption="Q1 营收"}
===type——bar | line | area | pie | scatter,只改画法,绝不新增属性。- 编码通道(封闭集):
x(类目)、y(数值;逗号列表即多系列)、series(按列 分组)、size(散点气泡)。必填x、y。类型用不到的通道给告警。 rows——data(默认,排除汇总行)、all(数据行 + 汇总行作为额外一点)、summary(只画汇总行)。- 列名、
dataid、rows都对照表校验:写错列名或悬空 id = 构建错误。(若表格数据是外部数据且按 §9.4 由渲染器抓取,列名校验推迟到渲染期)。 - 更复杂的图(标注、参考线、热力图…)改用托管 DSL:
=== diagram {format=vega-lite data=#fy25},spec 写进 body。body 为 raw、不校验列名。
8. 一致性
本规范定义三个合规等级。产品分别声明各自的合规:一个从不渲染的校验器可以是合规 解析器而不是合规渲染器,这并不使它不合规。
8.1 合规文档
合规 GEML 文档指这样一段归一化字符流(§0.5):合规解析器处理它时不产生任何严重级别 为 error 的诊断(附录 A)。
告警不使文档变得不合规:它们标记的是处理器无法完全解释、但必须(MUST)保留的构造—— 未知块类型、未知图格式、未经校验的跨文档引用。
尽管如此,任何输入都是可解析的:§2、§3、§5.3 与 §6 为任意字符流指派恰好一个文档 模型。不存在任何输入是合规解析器可以拒绝、拒绝建模或失败于其上的——不合规的文档同样 产出模型,只是同时带有描述它的错误。
8.2 合规解析器
合规解析器必须(MUST):
- 完全按 §0.5 归一化其输入。
- 解析类型块原语(§3)与属性对象(§4)。
- 构建文档模型,其中每个块 id 唯一且可解析。
- 解析内联强调(§5.3)与列表嵌套(§2.2),使每个输入恰好有一个解析。
- 对任何无法解析的内部或跨文档引用报错误(§5)。
- 把未知块
type和未知图format当告警而非错误,原样保留正文。 - 以附录 A 指派的代码与严重级别报告每一条诊断。
- 遵守 §9.2 的资源限制,以诊断而非失败的方式降级。
- 不依赖任何特定编辑器,不依赖原始 HTML。
8.3 合规渲染器
渲染器是可选(OPTIONAL)的:合规解析器不必产出任何呈现格式的输出。若产出,则必须 (MUST):
- 呈现合规解析器所产出的文档模型,不重新解释
raw块的正文(§3)。 - 不执行
code块,不解释diagram正文(§7)——只能将其交给已注册的外部渲染器 (§9.1)。 - 对文档可控文本满足 §9.5 的落点要求。
- 输出中略去标记为
hidden的块(§4),同时在模型中保留它们。
8.4 一致性测试集
规范配套一套一致性测试集:输入 .geml 与期望文档模型的归一化投影成对。对本文档以 算法方式陈述的规则——内联强调(§5.3)、列表嵌套(§2.2)、原子优先级、元数据插值 (§4)——该测试集是规范参照。第二个独立实现复现每个用例即为合规。在参考仓库中它位于 geml-parser/test/conformance/。
8.5 版本
规范的版本独立于任何实现。本文档为 GEML 1.0;参考实现的包版本遵循其自身发布节奏, 不是规范版本。
实现以「符合 GEML 1.0」的形式声明合规。处理器遇到不认识的构造时必须(MUST)按 §8.2(6) 降级——这就是本格式的前向兼容机制,也是新增一种块类型或图格式不构成破坏性变更的原因。
类型注册表(§3)是开放的。非本规范定义、亦未注册的类型名应(SHOULD)包含连字符 (例如 acme-invoice),把不含连字符的名字留给本规范的未来版本。图的 format 名遵循 同一约定。
8.6 本规范如何被扩展
连字符只是让扩展别挡本规范的路,它并不告诉处理器这个名字是什么意思——§8.2(6) 仍然 要求该名字降级为一条 warning。profile 就是文档用来说明"我用了哪些扩展"的方式,而且是 本规范被扩展的唯一途径:§8.5 说注册表是开放的,本节就是它开放的全部方式。该键 在 === meta 中声明,值是空格分隔的词汇表名列表:
=== meta
profile = "acme-invoice/v1 acme-style/v1"
===8.6.1 应用层词汇表
一份词汇表只放行三样东西,别无其他:块 type 名(§3)、属性键(§4),以及 diagram 的 format 名(§7)。这些名字是什么含义,由该词汇表自己定义,不在这里定义;本节只定义 它们如何被放行。对于它放行的类型——也仅限这些类型——词汇表还声明它们的正文如何被 读取(§8.6.2 第 4 条)。
diagram 的 format 选的是渲染器,无论指定哪个渲染器它的正文都是 raw,所以放行 一个 format 不可能移动文档模型。table 和 data 的 format 是顶着同一个键名的另一种 东西:它选的是正文如何被解析,直接产出模型里承载的表格与值树。因此它们不可被 放行——放行它们的声明会改变模型,而第 4 条禁止这一点。
其余一切属于本规范,只能通过本规范改变。一份词汇表必须不(MUST NOT)引入或改动:
- §§2–5 的语法:围栏、标题、列表、属性对象、行内语法、引用;
- 附录 A 的诊断目录,包括任何一条诊断码的严重级别;
- 本规范已经定义的任何名字的含义。
一份词汇表声明 body 模式,声明的是它自己的类型装着什么。它不得改述本规范已注册 类型的 body 模式——text 是 flow,data 块的由它的 format 引擎决定——那等于重新定义 一个不属于它的名字,即上面最后一条。因此,一个构造该归本规范还是归词汇表,判据不是它 正文的性质,而是归属:凡是每一个读 GEML 的人都应当能读的构造,属于本规范;只服务 于某一个应用的,属于词汇表。
图的 format 名在本版本中不可被放行,尽管 §8.5 对它们推荐同样的连字符约定。未知 format 本来就降级为 warning 并原样保留正文(§8.2(6)),所以一份词汇表提到某个 format 并没有错;它只是无法像消掉 unknown-block-type 那样消掉 unknown-diagram-format。
8.6.2 对合规处理器的要求
合规处理器:
- 当文档声明了处理器所认识的词汇表时,处理器必须不(MUST NOT)把被放行的
type报为unknown-block-type,也必须不把被放行的属性键报为unknown-attribute。 - 必须(MUST)仅通过该声明放行名字。处理器必须不(MUST NOT)从文档内容、文件名 或扩展名推断词汇表。任何这样的推断都是实现特有的知识,第二实现必须原样复刻它才能 在诊断上取得一致——那正是 §8.4 的一致性面泄漏进某一个实现的私有习惯。
- 对自己不认识的已声明名字,必须(MUST)什么也不放行,并且必须(MUST)报出
unrecognized-vocabulary并指名该名字。这条声明不是错误——文档是有效的,不完整的是 处理器看它的那个视图,而现在读的人被告知了这一点,不必自己去发现。解析=== embed(§3)的处理器,必须(MUST)把目标那份未被识别的声明也报出来,报在那条嵌入块上: §3 让目标作为独立文档被解析,所以宿主把那些块渲染成 raw,而读的人看的正是宿主。 但只能为这份文档自己指名的目标报:一份仅经由别的文档的嵌入才能到达的文档,与 这份文档没有任何关系,报出它——它的路径,或它声明的词汇表——等于泄露目标的某个 依赖,而宿主从未从那里取过任何内容。这里划的是两类诊断的界——关于这个处理器做不到什么的,跟着内容走,它的后果显现在 哪里就报在哪里;关于某份文档自身有毛病的,留在有毛病的那份文档里。处理器认识 哪些词汇表,由实现自定;一个一份都不认识的处理器同样是合规的。 - 必须不(MUST NOT)让"放行"改变可寻址单元集,除非该词汇表声明的 body 模式要求 如此,且仅在认识它的处理器中如此。不认识某词汇表的处理器,把它本会放行的每个 类型的正文都读作
raw——即 §8.2(6) 给未知类型的那个体——并且已按第 3 条报出诊断, 所以两种读法之间的差异是被宣告的,不是沉默的。放行从不改变文档中已被识别的块所带的 地址,也不改变被放行类型正文之外的任何地址。
第 3 条要求报出的那条诊断,正是第 4 条那个例外得以安全授予的原因。读不了某词汇表 正文的处理器会说出这件事,于是读的人不会把一个局部视图误当成全貌;一个在这个处理器上 解析得到、在那个处理器上解析不到的地址,总是伴随着理由。§3.2 对处理器没有引擎的保留 data format 早已是这么办的:正文保持 raw,data-format-no-engine 说明缘由,而该 format 本会产出的值树在那个处理器里就是不可寻址的。词汇表的正文是同一种处境,用的是 同一种办法。
§8.4 的测试集无论如何都不受影响,因为它是在文档模型上陈述的,而诊断从来不在其中 ——第 1 条本来就完全是在说"诊断随处理器认识什么而不同"。真正让文档可以安全跨边界编辑 的不是本条,而是 §3:每份文档都在它自己的 === meta 下解析,所以取块、换块与 === embed 都是按目标自己的声明去读目标的,在文档之间搬运的内容不会在途中改变含义。
8.6.3 命名与版本
词汇表名应(SHOULD)带版本(name/v1),使被改动的词汇表成为另一个名字、由文档自己 说明用的是哪一套;并应(SHOULD)包含连字符,理由见 §8.5。
9. 安全与资源限制
GEML 文档常常由机器生成,也常常不可信:它可能来自模型、流水线或一个 pull request。本节 规定文档怀有恶意时处理器必须保证什么。它适用于 §8 的每个合规等级。
9.1 文档是数据,绝不是代码
处理器必须不(MUST NOT)执行或求值文档的任何部分:
code块的正文是存储的文本;必须不(MUST NOT)被运行(§3);diagram正文必须(MUST)原样传给由format选定的外部渲染器,处理器必须不 (MUST NOT)解释它(§7);- 不存在原始 HTML 逃逸口(§1(5)),除 §6 的封闭算术外没有表达式语言——而后者在构造上 就没有条件分支、没有查找、没有跨表引用、没有内嵌程序。
9.2 资源限制
处理器必须(MUST)为自己在文档上的递归深度设定上界,且分别针对:类型块嵌套(§3)、 列表嵌套(§2.2)、内联嵌套(§5)。到达上界时必须(MUST)产出对应的 *-nesting-too-deep 错误(附录 A)并继续处理剩余输入。它必须不(MUST NOT)溢出调用栈、 中止,或无法产出模型。
上界由实现自定;处理器应(SHOULD)各自至少允许 64 层,这已远超任何为阅读而写的文档。 参考实现允许 256 层块嵌套与列表嵌套、100 层内联嵌套。
处理器必须不(MUST NOT)在未按目标文法转义的情况下,用文档可控文本构造正则表达式、 shell 命令或任何其他可执行形式。块 id、类名与属性值都是文档可控的;.geml 文件是不可信 输入,与 .zip 同理。
9.3 引用、环与终止性
引用解析必须(MUST)在任何输入上终止,包括为使其死循环而精心构造的输入:
- 内部引用不可能成环:id 在文档内唯一(§4),因此解析
#id是一次查找,而非遍历。 - 跨文档引用在校验时恰好只解析一层。处理器收集目标文档的 id 时不解析该文档自身的引用, 因此两份互相引用的文档在校验引用时会终止。
- 内容投射(
embed块或内联投射将其目标就地展开)是递归的。处理器必须(MUST)追踪已展开文档的链条,若发现某文档试图投射一个已经处于该展开链条中的文档的目标,则停止展开并报transclusion-cycle错误。 - 计算列(§6)按声明顺序求值,公式只能看到数据列与更早的计算列。因此自引用或 前向引用不是环,而是未知列,报为
compute-error。GEML 表格不需要环检测器:求值顺序 在构造上就让依赖图无环。
9.4 跨文档解析与外部数据
解析跨文档引用(§5.2)会读取由文档指名的文件。处理器必须(MUST)把该解析限制在显式配置 的根目录内,必须(MUST)在判定目标是否位于根内之前解析每一个符号链接,并且必须(MUST) 拒绝逃逸出根目录的目标。解析必须(MUST)失败即关闭:无法确立限制根的处理器不解析 任何东西并报 unresolvable-document,而不是回退到不受限的查找。
媒体 src(§5.1)以及指向 http(s) URL 的表格数据来源(§6)在渲染时由渲染器抓取,解析器从不读取它们。 渲染器必须(MUST)把这类来源当作不可信输入。在文档可能来自不可信作者的场合,渲染器应 (SHOULD)把 src 限制在文档自身的源或目录内,并应(SHOULD)要求显式选择加入才执行 http(s) 抓取:被抓取的 URL 会把读者的地址、以及阅读这一事实与时间,泄露给控制该 URL 的人。
由于外部数据在渲染时抓取,其内容从不进入 .gemlhistory 哈希——只有 src 文本会进入 (§6)。
9.5 落点要求
链接或嵌入(§5.1、§5.2)中的目标地址,若其 URL 方案不属于 http、https、mailto、 tel,则必须不(MUST NOT)被产出为可导航或可加载的目标。处理器必须(MUST)在构建模型 时施加此检查,而不是在渲染落点,这样模型的每个消费者都继承该保护。判定方案时该检查 必须(MUST)忽略位于 U+0000–U+0020 范围内的前导字符与内嵌字符,因为用户代理在对 URL 采取行动前会先剥除它们——java	script: 就是 javascript:。
产出标记语言的渲染器必须(MUST)按文档可控文本所处的位置——元素文本、属性值或 URL—— 对其转义,并且必须(MUST)把 .class 记号(§4)削减到目标格式的标识符字符集,而不是 仅仅转义它们。
9.6 一致性套件能担保到哪一步
由于 §9.5 要求方案检查必须在构建模型时施加,其效果在模型里可见,因此是可被机器 校验的:一致性套件的 safety.json 用例既钉住哪些目标地址会消失,也钉住哪些必须存活; §8 的验收测试会把它们跑在第二实现上。
本节其余要求同样是规范性的,且不在其覆盖范围内。§9.2 的各项上限与 §9.3 的环检测以 诊断形式报出,机器校验它们就要钉死某一个具体界值;§9.5 的展开预算作用在渲染期;§9.4 的 限界是宿主环境的性质。声称一致的实现仍然必须(MUST)满足它们,并且应当(SHOULD)为每一 条自带测试——套件通过是关于解析的证据,不是一张安全证书。一致性套件自己的 README 列出了这些要求实际挡住过的失败。
附录 A:诊断目录
合规解析器产出的每一条诊断,除人类可读的消息外都携带一个代码。消息是散文:它可 (MAY)在版本之间被改写、翻译或补充上下文。代码与严重级别才是契约——它们是一致性 测试、编辑器集成或 CI 关卡所匹配的对象,处理器必须(MUST)以本附录指派的代码与严重级别 报告诊断。
对于本目录已覆盖的情况,处理器必须不(MUST NOT)另造目录之外的代码。处理器可(MAY)为 本规范未定义的情况产出额外诊断;这类代码应(SHOULD)带一个连字符分隔的厂商前缀 (acme-…),以免本目录的未来版本与之冲突。
诊断携带的行号从 1 开始,指归一化字符流(§0.5)中的行——按 §0.5,它同时也是原文件中的 行号。
完整的代码、严重级别与条件对照表见英文版附录 A: 该表是规范性的,并由参考实现的测试逐条机械校验,因此此处不作重复以免译文漂移。
附录 B:语法清单(非规范性)
本附录是语言全部语法构造的完整索引,按每个构造可占据的位置组织。它不定义任何 东西:每一行都引用真正给出定义的章节。任何新增、移除或移动构造的变更,都在同一变更 中更新本清单——清单里缺了条目是文档 bug,绝不是隐藏特性。
GEML 有三个语法位置:
- 块位置——文档层面:一篇文档是块的序列(§2)。
- 内联位置——无栅栏区块以及 flow 类型块正文的流式内容内部(§2、§5)。
- 属性位置——块的属性对象
{…}内部(§4),其中若干键承载指向其他块、文档或 外部数据的引用。
B.1 块位置
| 构造 | 形态 | 正文 | 定义于 |
|---|---|---|---|
| 段落 | 无栅栏 | 内联 | §2、§3.1 |
标题 #…###### | 无栅栏 | 内联 | §1(6)、§3.1、§4 |
列表——-/*、1.;任务标记 [ ]/[x] | 无栅栏 | 条目为内联 | §2.2 |
=== code | 类型块 | raw | §3 |
=== math | 类型块 | raw | §3 |
=== table | 类型块 | raw:管道网格或 format= 数据 | §6 |
=== view | 类型块 | 不取正文;src= 指明它派生自哪个关系 | §6.1 |
=== data | 类型块 | raw:format= 值树(默认 json、jsonl;yaml/toml/edn 保留) | §3.2 |
=== diagram | 类型块 | raw:外部 DSL | §7 |
=== embed | 类型块 | raw(body 不使用);src= 指明内容所在 | §3、§6 |
=== note | 类型块 | flow | §3 |
=== text | 类型块 | flow | §3 |
=== meta | 类型块 | 键值对 | §3、§4 |
| %% 注释行 | 行 | 原样,永不渲染 | §4 | | ``` 行,配对 | 行对 | 遮蔽:其间一切维持流式文本 | §3.1 |
形态取值:无栅栏(§2)、类型块(带围栏,§3)、行——块解析阶段识别的 单行构造。
B.2 内联位置
| 构造 | 家族 | 定义于 |
|---|---|---|
*强调* · **加重** · ~~删除~~ | 修饰 | §5.1、§5.3 |
`代码` | 原样 atom | §5.1 |
$数学$ | 原样 atom | §5.1 |
[文字](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/url) · [文字](#id) · [文字](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/doc.geml#id) | 导航 | §5.2 |
[[#id]] · [[doc.geml#id]] | 导航,链接文字自动 | §5.2 |
[^id] | 脚注引用 | §5.2 |
 | 投射:媒体 | §5.1 |
{{key}} | 投射:元数据标量 | §4 |
行尾 \ · \ + 标点 | 强制换行 · 转义 | §5.1 |
B.3 属性位置
八个属性键承载引用,且全部参与校验(附录 A):
| 键 | 宿主块 | 目标 | 定义于 |
|---|---|---|---|
src= | table | 数据来自哪里:数据文件(csv/tsv,文档相对路径或 http(s) URL)。指向块是错误,并指向 view。 | §6 |
src= | view | 它派生自哪个关系,三种形态:数据文件(csv/tsv)、#id(本文档中的 table 或 view)、doc.geml#id(另一文档中的)。 | §6.1 |
data= | diagram(geml-chart) | 数据来自哪里,与表格 src= 相同的三种形态:数据文件(csv/tsv 代表它所描述的匿名表格;本地 .json/.jsonl 代表匿名记录源)、#id(本文档中的),或 doc.geml#id(另一文档中的)——指向一个 table,或值为记录数组的 data 块(§3.2)。 | §6、§7.1 |
schema= | data | 存放 schema 的块(#id)或 GEML 文档(doc.geml[#id]);仅做引用检查 | §3.2 |
src= | data | 块的外部内容:.json/.jsonl/.yaml 文件,与代码源同一套路由语法(可用行范围收窄,这正是寻址 jsonl 日志窗口的方式);文档相对或 http(s)(渲染期获取) | §3.2 |
src= | code | 该块所展示的代码:一个源文件,可用行范围收窄——<path>[#L<start>[-<end>]],1 起、闭区间。按文档相对解析,也可相对解析根(--root);http(s) 路由在渲染期获取。文件已不含该范围时为错误。 | §3.3 |
src= | embed | 该块所代表的内容:一个文档,可带片段 | §3 |
src= | diagram(geml-code-graph) | 一篇 GEML 文档 | §7 |
其余属性机制——#id、.class、带类型的 key=val 值、hidden 标志——定义于 §4。
B.4 概念 × 位置矩阵
多数概念天然只占一个位置;三个概念同时具有内联与块两种形态,另有两对跨越两个位置的 定义↔使用配对。
| 概念 | 内联位置 | 块位置 |
|---|---|---|
| 散文 | 文本 run | 段落 |
| 代码 | `代码` | === code |
| 数学 | $…$ | === math |
| 投射——就地渲染目标 | (媒体)、![[#id]](内容) | === embed {src=…} |
| 导航——供读者跳转的链接 | [t](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/…)、[[#id]]、[^id] | — |
| 空间性内容 | — | 标题、列表、table、data、diagram、note、text |
| 隐藏内容与注释 | — | hidden 标志、%% 行 |
| 元数据 | {{key}}(使用) | === meta(定义) |
| 脚注 | [^id](使用) | 带有目标 #id 的块 |
注(非规范性): 按行读,这张矩阵分开了两个引用家族。导航渲染为供读者跳转的 链接([t](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/spec/…)、[[#id]]);投射则把被引目标本身渲染在原地。GEML 今天在三个 粒度上投射:标量({{key}},取自 === meta)、媒体对象()、表格的 数据模型(图表的 data=#id)。脚注引用是杂交体:一个导航标记,其目标同时被投射到 文档脚部。
! 通篇就是投射前缀: 投射媒体,![[#id]] 投射内容。两种内容投射的分界 是值与内容:{{key}} 代入元数据的标量——无标记、无上下文规则;而 ![[#id]] 投射目标的行内内容,格式保留,并受 §3 全套上下文规则约束——引用点名 的那份文档按独立文档解析,再从解析结果中选出目标。再加上管块与小节的 === embed, 三个粒度分别是标量、短语、块。