markdown-it-ltem 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EurekaAI Co., Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # markdown-it-ltem
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [markdown-it](https://github.com/markdown-it/markdown-it) plugin for [LTEM](../../../../README.md) tables: Markdown pipe tables with merged cells and multi-row headers.
4
+
5
+ ```
6
+ | Region | Revenue | << |
7
+ | ^^ | Q1 | Q2 |
8
+ |--------|---------|-----|
9
+ | Asia | 100 | 120 |
10
+ ```
11
+
12
+ renders as a table whose "Region" cell spans two rows and whose "Revenue" cell spans two columns.
13
+
14
+ Status: version 0.1.0, the first release, for LTEM 1.0. Not yet published to npm. The API may change before version 1.0.0.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ npm install markdown-it markdown-it-ltem
20
+ ```
21
+
22
+ Works with markdown-it 14 and 15. The package is ESM; CommonJS code can use `require("markdown-it-ltem").default` on Node.js 20.19, 22.12 or later.
23
+
24
+ ## Use
25
+
26
+ ```ts
27
+ import markdownit from "markdown-it";
28
+ import ltem from "markdown-it-ltem";
29
+
30
+ const md = markdownit().use(ltem);
31
+ md.render(text);
32
+ ```
33
+
34
+ The plugin replaces markdown-it's GFM table rule and enables it, also in presets that have no table rule (such as `commonmark` and `zero`). A table without LTEM notation renders as before. LTEM tables get `colspan`, `rowspan` and several header rows, following the LTEM specification, version 1.0:
35
+
36
+ - Header rows are the line directly above the delimiter row and the pipe-led lines above it. A `| # identifier |` line is reserved and stays in the paragraph before the table. A table may begin on any line of a paragraph; a delimiter row whose last header row does not fit stays ordinary text. A line such as `- | -` is a list item, not a delimiter row, as in GFM.
37
+ - Body rows end at a blank line, a line indented by four or more spaces, or a line that starts another block (block quote, ATX heading, fenced code, thematic break, list item), and also wherever markdown-it's own rules start a block, such as an HTML block.
38
+ - `\\` in a cell is a line break. Each line is parsed as inlines on its own and the lines are joined with hard breaks (`<br>`).
39
+ - Alignment from the delimiter row is written as `style="text-align:..."`, as markdown-it does.
40
+
41
+ The token stream is the one markdown-it's table rule produces: `table_open`, `thead_open`, `tr_open`, `th_open` or `td_open` (with `colspan` and `rowspan` attributes when greater than 1), one `inline` token per cell, and the matching close tokens. There is no `tbody` when the table has no body rows.
42
+
43
+ Each `th_open` and `td_open` token has `map = [line, line + 1]` (the document line of the cell's origin) and `meta.ltem = { row, col, rowspan, colspan, line, start, end }`: the cell's grid position and where its source is. `start` and `end` are UTF-16 offsets (JavaScript string indices) in that document line, counted from the line's first character, so a container prefix such as `> ` shifts them; they cover the cell's source without the spaces and tabs around it, including any span attribute and escapes. Editors can use them to map a rendered cell back to the text.
44
+
45
+ ## Development
46
+
47
+ See the [workspace README](../../README.md). The tests render all specification examples as whole documents with markdown-it 14 and 15.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * markdown-it plugin for LTEM tables.
3
+ *
4
+ * ```ts
5
+ * import markdownit from "markdown-it";
6
+ * import ltem from "markdown-it-ltem";
7
+ *
8
+ * const md = markdownit("commonmark").use(ltem);
9
+ * const html = md.render(text);
10
+ * ```
11
+ *
12
+ * The plugin replaces markdown-it's GFM table rule. A table without LTEM notation
13
+ * renders as before; LTEM tables get colspan, rowspan and several header rows.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ interface Ruler {
18
+ at(name: string, fn: never, options?: {
19
+ alt?: string[];
20
+ }): void;
21
+ before(beforeName: string, ruleName: string, fn: never, options?: {
22
+ alt?: string[];
23
+ }): void;
24
+ after(afterName: string, ruleName: string, fn: never, options?: {
25
+ alt?: string[];
26
+ }): void;
27
+ enable(list: string | string[], ignoreInvalid?: boolean): string[];
28
+ }
29
+ /**
30
+ * Where a cell is in the document, set as `meta.ltem` on its `th_open` or `td_open`
31
+ * token. `line` is the document line of the cell's origin (counting from 0, like token
32
+ * maps). `start` and `end` are UTF-16 offsets (JavaScript string indices) in that line of
33
+ * markdown-it's source, counted from the line's first character, so a container prefix
34
+ * such as "> " shifts them. They cover the cell's source without the spaces and tabs
35
+ * around it, including any span attribute and escapes; an empty cell has start == end.
36
+ */
37
+ export interface LtemCellPosition {
38
+ row: number;
39
+ col: number;
40
+ rowspan: number;
41
+ colspan: number;
42
+ line: number;
43
+ start: number;
44
+ end: number;
45
+ }
46
+ /** The parts of a markdown-it instance that the plugin uses. */
47
+ export interface MarkdownItLike {
48
+ block: {
49
+ ruler: Ruler;
50
+ };
51
+ core: {
52
+ ruler: Ruler;
53
+ };
54
+ }
55
+ /** Replace the table rule of `md` with the LTEM table rule and enable it. */
56
+ export default function ltem(md: MarkdownItLike): void;
57
+ export { ltem };
package/dist/index.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * markdown-it plugin for LTEM tables.
3
+ *
4
+ * ```ts
5
+ * import markdownit from "markdown-it";
6
+ * import ltem from "markdown-it-ltem";
7
+ *
8
+ * const md = markdownit("commonmark").use(ltem);
9
+ * const html = md.render(text);
10
+ * ```
11
+ *
12
+ * The plugin replaces markdown-it's GFM table rule. A table without LTEM notation
13
+ * renders as before; LTEM tables get colspan, rowspan and several header rows.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ import { endsTableBody, isDelimiterRow, isUpperHeaderRow, parseTable, startsBlock, } from "ltem-core";
18
+ const ALT = ["paragraph", "reference"];
19
+ /** Replace the table rule of `md` with the LTEM table rule and enable it. */
20
+ export default function ltem(md) {
21
+ const rule = ltemTable;
22
+ try {
23
+ md.block.ruler.at("table", rule, { alt: ALT });
24
+ }
25
+ catch {
26
+ // The preset has no table rule; tables are tried before indented code, as in markdown-it.
27
+ md.block.ruler.before("code", "table", rule, { alt: ALT });
28
+ }
29
+ md.block.ruler.enable("table");
30
+ md.core.ruler.before("inline", "ltem_split_lines", splitCellLines);
31
+ md.core.ruler.after("inline", "ltem_join_lines", joinCellLines);
32
+ }
33
+ export { ltem };
34
+ // -- the block rule --------------------------------------------------------------------
35
+ /** Line n as the LTEM core reads it: indentation relative to the block, then the text. */
36
+ function lineText(state, n) {
37
+ const indent = Math.max(0, state.sCount[n] - state.blkIndent);
38
+ return " ".repeat(indent) + state.src.slice(state.bMarks[n] + state.tShift[n], state.eMarks[n]);
39
+ }
40
+ /** Whether line n can belong to a table: not blank and inside the current container. */
41
+ function usable(state, n, endLine) {
42
+ return n < endLine && !state.isEmpty(n) && state.sCount[n] >= state.blkIndent;
43
+ }
44
+ function isCodeIndented(state, n) {
45
+ return state.sCount[n] - state.blkIndent >= 4;
46
+ }
47
+ /** Index of the first delimiter row if a table's header rows start at `start` (spec 2.3), else -1. */
48
+ function findDelimiter(state, start, endLine) {
49
+ for (let n = start; usable(state, n, endLine) && usable(state, n + 1, endLine); n++) {
50
+ // Line n is the last header row: a paragraph line, as in GFM (spec 2.3), so not the start
51
+ // of another block. isDelimiterRow rejects a delimiter row indented as code.
52
+ if (isDelimiterRow(lineText(state, n + 1)))
53
+ return startsBlock(lineText(state, n)) ? -1 : n + 1;
54
+ // Header rows above the last one begin with a pipe, are not reserved and do not look
55
+ // like a delimiter row (spec 2.3, 9).
56
+ if (!isUpperHeaderRow(lineText(state, n)))
57
+ return -1;
58
+ }
59
+ return -1;
60
+ }
61
+ /** Whether a body row ends before line n (spec 2.4), by the core's rules or markdown-it's. */
62
+ function endsBody(state, n, endLine, terminators) {
63
+ if (!usable(state, n, endLine) || isCodeIndented(state, n))
64
+ return true;
65
+ if (endsTableBody(lineText(state, n)))
66
+ return true;
67
+ return terminators.some((rule) => rule(state, n, endLine, true));
68
+ }
69
+ function ltemTable(state, startLine, endLine, silent) {
70
+ if (isCodeIndented(state, startLine))
71
+ return false;
72
+ // Called to end a paragraph at startLine: if the line above is a pipe-led line of the
73
+ // same paragraph, it would be a header row of any table here, so no table starts at
74
+ // startLine (spec 2.3). Not so if startLine looks like a delimiter row: as a last
75
+ // header row it stands alone.
76
+ if (silent && startLine > 0 && isUpperHeaderRow(lineText(state, startLine - 1))
77
+ && !isDelimiterRow(lineText(state, startLine)))
78
+ return false;
79
+ const delimiter = findDelimiter(state, startLine, endLine);
80
+ if (delimiter < 0)
81
+ return false;
82
+ let stop = delimiter + 1;
83
+ if (!silent) {
84
+ // Body rows run until a blank line or a line that starts another block.
85
+ const terminators = state.md.block.ruler.getRules("blockquote");
86
+ const oldParentType = state.parentType;
87
+ state.parentType = "table";
88
+ while (!endsBody(state, stop, endLine, terminators))
89
+ stop++;
90
+ state.parentType = oldParentType;
91
+ }
92
+ const lines = [];
93
+ for (let n = startLine; n < stop; n++)
94
+ lines.push(lineText(state, n));
95
+ const result = parseTable(lines);
96
+ // No table here, for example if the last header row does not fit (spec 2.3).
97
+ if (result.table === null || result.start !== 0 || result.end !== lines.length)
98
+ return false;
99
+ if (silent)
100
+ return true;
101
+ emitTable(state, result.table, result.sources, startLine, delimiter, stop);
102
+ state.line = stop;
103
+ return true;
104
+ }
105
+ // -- tokens ----------------------------------------------------------------------------
106
+ /** The lines of each cell's inline token whose text has more than one line (spec 4.2). */
107
+ const cellLines = new WeakMap();
108
+ /** The cell token that each per-line inline token belongs to, while inlines are parsed. */
109
+ const lineOwner = new WeakMap();
110
+ /**
111
+ * The offset in document line n of markdown-it's source for offset i of `lineText(n)`,
112
+ * which replaces the container prefix and indentation by spaces.
113
+ */
114
+ function documentOffset(state, n, i) {
115
+ const lineStart = state.src.lastIndexOf("\n", state.bMarks[n] - 1) + 1;
116
+ const indent = Math.max(0, state.sCount[n] - state.blkIndent);
117
+ const textStart = state.bMarks[n] + state.tShift[n];
118
+ return textStart + Math.max(0, i - indent) - lineStart;
119
+ }
120
+ function emitTable(state, table, sources, start, delimiter, stop) {
121
+ const byRow = new Map(); // indices into table.cells and sources
122
+ table.cells.forEach((cell, i) => {
123
+ const row = byRow.get(cell.row);
124
+ if (row)
125
+ row.push(i);
126
+ else
127
+ byRow.set(cell.row, [i]);
128
+ });
129
+ // Source line of each grid row: header rows, then (after the delimiter) body rows.
130
+ const sourceLine = (r) => (r < table.header_rows ? start + r : start + r + 1);
131
+ const emitRow = (r) => {
132
+ const tag = r < table.header_rows ? "th" : "td";
133
+ const line = sourceLine(r);
134
+ state.push("tr_open", "tr", 1).map = [line, line + 1];
135
+ for (const i of byRow.get(r) ?? []) {
136
+ const cell = table.cells[i];
137
+ const source = sources[i];
138
+ const open = state.push(`${tag}_open`, tag, 1);
139
+ open.map = [line, line + 1];
140
+ const position = {
141
+ row: cell.row, col: cell.col, rowspan: cell.rowspan, colspan: cell.colspan, line,
142
+ start: documentOffset(state, start + source.line, source.start),
143
+ end: documentOffset(state, start + source.line, source.end),
144
+ };
145
+ open.meta = { ltem: position };
146
+ const attrs = [];
147
+ if (cell.colspan > 1)
148
+ attrs.push(["colspan", String(cell.colspan)]);
149
+ if (cell.rowspan > 1)
150
+ attrs.push(["rowspan", String(cell.rowspan)]);
151
+ const align = table.align[cell.col];
152
+ if (align)
153
+ attrs.push(["style", `text-align:${align}`]);
154
+ if (attrs.length)
155
+ open.attrs = attrs;
156
+ const lines = cell.text.split("\n");
157
+ const inline = state.push("inline", "", 0);
158
+ // A backslash before a line end is a hard break (spec 4.2: lines join with <br />).
159
+ inline.content = lines.join("\\\n");
160
+ inline.map = [line, line + 1];
161
+ inline.children = [];
162
+ if (lines.length > 1)
163
+ cellLines.set(inline, lines);
164
+ state.push(`${tag}_close`, tag, -1);
165
+ }
166
+ state.push("tr_close", "tr", -1);
167
+ };
168
+ state.push("table_open", "table", 1).map = [start, stop];
169
+ state.push("thead_open", "thead", 1).map = [start, delimiter];
170
+ for (let r = 0; r < table.header_rows; r++)
171
+ emitRow(r);
172
+ state.push("thead_close", "thead", -1);
173
+ if (table.rows > table.header_rows) {
174
+ state.push("tbody_open", "tbody", 1).map = [delimiter + 1, stop];
175
+ for (let r = table.header_rows; r < table.rows; r++)
176
+ emitRow(r);
177
+ state.push("tbody_close", "tbody", -1);
178
+ }
179
+ state.push("table_close", "table", -1);
180
+ }
181
+ // -- line breaks in cells (spec 4.2) -----------------------------------------------------
182
+ //
183
+ // Each line of a cell is parsed as inlines on its own, so that emphasis or a code span
184
+ // cannot run across a `\\` line break. Just before markdown-it's "inline" core rule, a
185
+ // cell's inline token is replaced by one inline token per line; just after it, the
186
+ // lines' children are joined back into the cell's token with hard breaks between them.
187
+ // Every other core rule sees one inline token per cell.
188
+ function replaceTokens(state, map) {
189
+ const out = [];
190
+ for (const token of state.tokens)
191
+ map(token, out);
192
+ state.tokens.length = 0;
193
+ for (const token of out)
194
+ state.tokens.push(token);
195
+ }
196
+ function splitCellLines(state) {
197
+ if (!state.tokens.some((token) => cellLines.has(token)))
198
+ return;
199
+ replaceTokens(state, (token, out) => {
200
+ const lines = cellLines.get(token);
201
+ if (!lines) {
202
+ out.push(token);
203
+ return;
204
+ }
205
+ for (const line of lines) {
206
+ const part = new state.Token("inline", "", 0);
207
+ part.content = line;
208
+ part.map = token.map;
209
+ part.level = token.level;
210
+ part.children = [];
211
+ lineOwner.set(part, token);
212
+ out.push(part);
213
+ }
214
+ });
215
+ }
216
+ function joinCellLines(state) {
217
+ if (!state.tokens.some((token) => lineOwner.has(token)))
218
+ return;
219
+ replaceTokens(state, (token, out) => {
220
+ const owner = lineOwner.get(token);
221
+ if (!owner) {
222
+ out.push(token);
223
+ return;
224
+ }
225
+ const children = owner.children ?? [];
226
+ if (out[out.length - 1] === owner) {
227
+ children.push(new state.Token("hardbreak", "br", 0));
228
+ }
229
+ else {
230
+ children.length = 0;
231
+ out.push(owner);
232
+ }
233
+ for (const child of token.children ?? [])
234
+ children.push(child);
235
+ owner.children = children;
236
+ });
237
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "markdown-it-ltem",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "markdown-it plugin for LTEM tables: merged cells (colspan, rowspan) and multi-row headers",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "markdown-it-plugin",
8
+ "markdown-it",
9
+ "markdown",
10
+ "table",
11
+ "colspan",
12
+ "rowspan",
13
+ "ltem"
14
+ ],
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "README.md"
28
+ ],
29
+ "sideEffects": false,
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "test": "tsc -p tsconfig.test.json && node --test \"test/**/*.test.ts\"",
36
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\""
37
+ },
38
+ "dependencies": {
39
+ "ltem-core": "0.1.0"
40
+ },
41
+ "peerDependencies": {
42
+ "markdown-it": "^14 || ^15"
43
+ },
44
+ "devDependencies": {
45
+ "markdown-it": "^15.0.2",
46
+ "markdown-it-14": "npm:markdown-it@^14.3.2"
47
+ }
48
+ }