@topik/remark-tags 0.0.0-stage → 0.1.0-alpha.7

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 The Topik Authors
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,209 @@
1
- # Temporary Holding Version
1
+ # @topik/remark-tags
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
+ Declare inline and block tags in Markdown using `{% name %}` syntax. This package
4
+ provides a remark plugin and the underlying micromark/mdast extensions. It parses
5
+ and writes syntax; component schemas, expression evaluation, and rendering belong
6
+ to the application.
7
+
8
+ ## Install and use
9
+
10
+ ```sh
11
+ npm install @topik/remark-tags unified remark-parse remark-stringify
12
+ ```
13
+
14
+ ```js
15
+ import { unified } from "unified";
16
+ import remarkParse from "remark-parse";
17
+ import remarkStringify from "remark-stringify";
18
+ import remarkTags from "@topik/remark-tags";
19
+
20
+ const tags = {
21
+ badge: { kind: "inline" },
22
+ panel: { kind: "block" },
23
+ };
24
+
25
+ const processor = unified().use(remarkParse).use(remarkTags, tags).use(remarkStringify);
26
+
27
+ const file = processor.processSync(`{% panel title="Release" %}
28
+ Available {% badge %}**today**{% /badge %}.
29
+ {% /panel %}`);
30
+
31
+ console.log(String(file));
32
+ ```
33
+
34
+ The default export and named `remarkTags` export refer to the same plugin. This is
35
+ an ESM package requiring Node.js 22.12 or later.
36
+
37
+ Contributor tooling uses the workspace's `.node-version` and requires
38
+ `^22.23.4 || ^24.11.0 || >=26.0.0` for Vite+, Vitest, and Astro integration
39
+ tests. CI verifies the built library on Node.js 22.12 separately from the test runner.
40
+
41
+ ## Syntax
42
+
43
+ - Declare each tag's placement as `inline` or `block`. Unknown names are errors.
44
+ - Block opening and closing tags occupy their own lines, including inside lists
45
+ and blockquotes. An inline tag on its own line stays in the surrounding paragraph.
46
+ - Use `{% badge /%}` for a self-closing tag or
47
+ `{% badge %}Markdown children{% /badge %}` for a paired tag.
48
+ - Names start with a lowercase ASCII letter, followed by letters, digits, or
49
+ hyphens. Names are case-sensitive; camelCase is supported.
50
+ - Attributes use `key=value` with double-quoted strings, finite numbers, or
51
+ booleans: `title="Preview" columns=2 open=true`. Strings support `\\`, `\"`,
52
+ `\|`, `\[`, and `\]` escapes. Empty strings are preserved; control characters
53
+ are refused. Applications can explicitly admit `\n`, `\r` and `\t`
54
+ for named ordinary string attributes with
55
+ `escapedWhitespace: { component: ["attribute"] }`;
56
+ raw control characters and all other control escapes remain refused.
57
+ The writer escapes brackets in attributes inside Markdown labels,
58
+ including native directive labels, to preserve their surrounding structure.
59
+ - Tag-looking text in code, escaped openers, and raw HTML blocks remains literal.
60
+ - Tags nest within Markdown boundaries. Opening inside emphasis and closing
61
+ outside it, or opening an inline tag in one paragraph and closing it in another,
62
+ is an error.
63
+
64
+ This is Markdoc-style syntax, not a complete Markdoc implementation. Standard
65
+ remark directives (`:name`, `::name`, `:::name`) remain independent. GFM and other
66
+ Markdown features are enabled through their own plugins.
67
+
68
+ The writer sorts attribute names by code unit, escapes values, and normalizes
69
+ spacing. It preserves an empty block container as a container; empty inline tags
70
+ normalize to self-closing tags. Null and undefined attribute values in constructed
71
+ trees are omitted. Caller-built attribute maps must be plain objects (including
72
+ null-prototype objects) with enumerable own data properties; accessors and custom
73
+ prototypes are refused without invoking their getters. Formatting preserves
74
+ supported tree meaning, not source bytes.
75
+ HTML blocks remain separated from following tag markers by a blank line. Blank
76
+ lines inside a block tag do not make its enclosing list item loose; blank lines
77
+ between the item's direct block children still do.
78
+ Literal tag openers are escaped without changing URL or email autolinks. When
79
+ emphasis or strong marks nest across inline tags, their delimiters alternate to
80
+ keep the tag boundaries intact; the surrounding writer's preferences are restored
81
+ after each tag.
82
+
83
+ ## AST and extensions
84
+
85
+ | Node | Meaning |
86
+ | ----------------- | --------------------------------------------------------------------- |
87
+ | `tagText` | Inline tag with phrasing children; self-closing tags have no children |
88
+ | `tagLeaf` | Self-closing block tag with no children |
89
+ | `tagContainer` | Paired block tag with block/definition children |
90
+ | `tagVariable` | Opt-in variable reference with a string `path` |
91
+ | `tagConditional` | Opt-in conditional containing one or two `tagBranch` nodes |
92
+ | `tagBranch` | Flow children with a condition string, or `null` for the else branch |
93
+ | `tagCodeTemplate` | Opt-in fenced code with literal language/metadata and a text template |
94
+
95
+ Component nodes contain `name` and `attributes`. Parsed nodes retain source
96
+ positions. Exported node interfaces augment mdast's node maps for TypeScript users.
97
+ Branch positions span from the `if` or `else` marker through the last child,
98
+ excluding the following marker and trailing blank lines. Empty branches cover
99
+ only their opening marker.
100
+
101
+ Use the same declarations and options with all three extensions when working
102
+ directly with mdast:
103
+
104
+ ```js
105
+ import { fromMarkdown } from "mdast-util-from-markdown";
106
+ import { toMarkdown } from "mdast-util-to-markdown";
107
+ import { tagSyntax, tagFromMarkdown, tagToMarkdown } from "@topik/remark-tags";
108
+
109
+ const tags = { badge: { kind: "inline" } };
110
+ const tree = fromMarkdown('{% badge label="Preview" /%}', {
111
+ extensions: [tagSyntax(tags)],
112
+ mdastExtensions: [tagFromMarkdown(tags)],
113
+ });
114
+ const source = toMarkdown(tree, { extensions: [tagToMarkdown(tags)] });
115
+ ```
116
+
117
+ ## Optional expression syntax
118
+
119
+ Pass `{ expressions: true }` as the plugin's third `.use()` argument, or as the
120
+ second argument to each extension, to enable:
121
+
122
+ ```md
123
+ Hello {% $student.name %}.
124
+
125
+ {% if equals($student.role, "mentor") %}
126
+ Mentor instructions.
127
+ {% else /%}
128
+ Student instructions.
129
+ {% /if %}
130
+ ```
131
+
132
+ Variable paths are dot-separated identifiers. Conditions are preserved as opaque
133
+ source strings with balanced quotes and safe delimiters; this package neither
134
+ evaluates them nor validates an expression language. Applications must supply that
135
+ behavior. `if` and `else` are reserved declaration names when expressions are
136
+ enabled. Conditions are block-only, with at most one else branch.
137
+
138
+ ### Explicit text templates
139
+
140
+ The same option enables `t"..."` attribute values and paired code-template scopes:
141
+
142
+ ````md
143
+ {% panel title=t"Install {% $package.name %}" %}
144
+ {% template code %}
145
+
146
+ ```sh
147
+ npm install {% $package.name %}
148
+ echo '{%% $literal.example %}'
149
+ ```
150
+
151
+ {% /template code %}
152
+ {% /panel %}
153
+ ````
154
+
155
+ Ordinary quoted attributes and ordinary code blocks retain their literal meaning.
156
+ Inside an explicit template, `{% $path %}` is a reference and `{%%` produces the
157
+ literal opener `{%`. Escape processing runs once: `{%%%` produces `{%%`. Code
158
+ backslashes stay literal; attribute templates use the usual quoted-string escapes
159
+ before scanning their references. Attribute template literals cannot contain
160
+ controls or line breaks. Code literals cannot contain NUL or carriage returns;
161
+ source line endings normalize to LF. Template paths use dot-separated ASCII
162
+ identifiers and refuse `__proto__`, `prototype`, and `constructor` segments.
163
+
164
+ Code scope markers occupy their own block lines and contain exactly one closed
165
+ fenced block in the same Markdown parent. They can appear inside lists,
166
+ blockquotes, declared block tags, and conditional branches. Extra content,
167
+ nested scopes, indented code, missing closers, and interpreted `mermaid` code are
168
+ refused. Language and metadata stay literal. The special two-word markers leave
169
+ custom tags named `template` and `codeTemplate` available.
170
+
171
+ Template attributes contain an exported `TagTextTemplate` value:
172
+
173
+ ```ts
174
+ {
175
+ type: "topikTextTemplate",
176
+ segments: [
177
+ { type: "literal", value: "Install " },
178
+ { type: "variable", path: ["package", "name"] },
179
+ ],
180
+ }
181
+ ```
182
+
183
+ `tagCodeTemplate` is a leaf with `lang`, `meta`, and `template`; it has no ordinary
184
+ `value` or children. Empty and literal-only templates keep their template identity.
185
+ The writer merges adjacent literal segments, escapes literal openers, and chooses
186
+ a fence that preserves the payload. It preserves decoded fence metadata through
187
+ character references when needed, including tabs and leading spaces. Applications
188
+ choose eligible attribute slots, resolve references, and render the resulting text;
189
+ this package preserves the authored structure without evaluating it.
190
+
191
+ ## Diagnostics and limits
192
+
193
+ Malformed source throws `TagSyntaxError`. Its `diagnostics` array contains a
194
+ `message` and, when available, one-based `line` and `column`. Structural limit
195
+ errors also have `id: "tag-content-limit"`. Invalid declarations and unsupported
196
+ writer inputs throw ordinary errors.
197
+
198
+ Template syntax uses stable `topik-template-syntax`, `topik-variable-path`,
199
+ `topik-template-control`, `topik-code-template-structure`, and
200
+ `topik-code-template-language` diagnostic IDs.
201
+
202
+ `TAG_LIMITS` bounds both the incoming Markdown tree and the converted tag tree to
203
+ depth 128 and 50,000 nodes, counting template objects and segments in the converted
204
+ tree. It also bounds each template's decoded input payload and generated source
205
+ value to 1,000,000 UTF-16 units. Quoted template output counts the `t` prefix,
206
+ quotes, and surrounding Markdown escape rules; code output counts generated
207
+ fences and scope markers.
208
+ These limits do not bound an entire source document. Host applications remain
209
+ responsible for their own input limits and rendering policy.
@@ -0,0 +1,130 @@
1
+ import { BlockContent, DefinitionContent, Node, Parent, PhrasingContent, Root } from "mdast";
2
+ import { Extension } from "mdast-util-from-markdown";
3
+ import { Options } from "mdast-util-to-markdown";
4
+ import { Extension as Extension$1 } from "micromark-util-types";
5
+ import { Plugin } from "unified";
6
+ export type TagDeclarations = Record<string, {
7
+ kind: "inline" | "block";
8
+ }>;
9
+ export interface TagTextTemplate {
10
+ type: "topikTextTemplate";
11
+ segments: Array<{
12
+ type: "literal";
13
+ value: string;
14
+ } | {
15
+ type: "variable";
16
+ path: string[];
17
+ }>;
18
+ }
19
+ export type TagAttributeValue = string | number | boolean | TagTextTemplate;
20
+ /** Bounds on both the incoming mdast tree and the converted tag tree. */
21
+ export declare const TAG_LIMITS: Readonly<{
22
+ treeDepth: 128;
23
+ treeNodes: 50000;
24
+ templateLength: 1000000;
25
+ }>;
26
+ export interface TagSyntaxOptions {
27
+ /** Capture raw opening-fence headers for host-owned header admission, including templates. */
28
+ fencedCodeHeaders?: WeakMap<Node, string>;
29
+ /** Opt in to variables, conditions, text templates, and bounded code templates. */
30
+ expressions?: boolean;
31
+ /** Named attributes that can encode LF, CR and TAB with backslash escapes. */
32
+ escapedWhitespace?: Readonly<Record<string, readonly string[]>>;
33
+ }
34
+ export type TagNode = TagTextNode | TagLeafNode | TagContainerNode | TagVariableNode | TagConditionalNode | TagCodeTemplateNode;
35
+ interface NamedTag extends Parent {
36
+ name: string;
37
+ attributes?: Record<string, TagAttributeValue | null | undefined>;
38
+ }
39
+ export interface TagTextNode extends NamedTag {
40
+ type: "tagText";
41
+ children: PhrasingContent[];
42
+ }
43
+ export interface TagLeafNode extends NamedTag {
44
+ type: "tagLeaf";
45
+ children: [];
46
+ }
47
+ export interface TagContainerNode extends NamedTag {
48
+ type: "tagContainer";
49
+ children: Array<BlockContent | DefinitionContent>;
50
+ }
51
+ export interface TagVariableNode extends Node {
52
+ type: "tagVariable";
53
+ path: string;
54
+ }
55
+ export interface TagCodeTemplateNode extends Node {
56
+ type: "tagCodeTemplate";
57
+ lang?: string | null;
58
+ meta?: string | null;
59
+ template: TagTextTemplate;
60
+ }
61
+ export interface TagBranchNode extends Parent {
62
+ type: "tagBranch";
63
+ condition: string | null;
64
+ children: Array<BlockContent | DefinitionContent>;
65
+ }
66
+ export interface TagConditionalNode extends Parent {
67
+ type: "tagConditional";
68
+ children: TagBranchNode[];
69
+ }
70
+ declare module "mdast" {
71
+ interface PhrasingContentMap {
72
+ tagText: TagTextNode;
73
+ tagVariable: TagVariableNode;
74
+ }
75
+ interface BlockContentMap {
76
+ tagLeaf: TagLeafNode;
77
+ tagContainer: TagContainerNode;
78
+ tagConditional: TagConditionalNode;
79
+ tagCodeTemplate: TagCodeTemplateNode;
80
+ }
81
+ interface RootContentMap {
82
+ tagBranch: TagBranchNode;
83
+ tagText: TagTextNode;
84
+ tagLeaf: TagLeafNode;
85
+ tagContainer: TagContainerNode;
86
+ tagVariable: TagVariableNode;
87
+ tagConditional: TagConditionalNode;
88
+ tagCodeTemplate: TagCodeTemplateNode;
89
+ }
90
+ }
91
+ export interface TagDiagnostic {
92
+ id?: string;
93
+ option?: string;
94
+ message: string;
95
+ line?: number;
96
+ column?: number;
97
+ }
98
+ export declare class TagSyntaxError extends Error {
99
+ readonly diagnostics: TagDiagnostic[];
100
+ constructor(diagnostics: TagDiagnostic[]);
101
+ }
102
+ declare module "micromark-util-types" {
103
+ interface TokenTypeMap {
104
+ topikTextTag: "topikTextTag";
105
+ topikFlowTag: "topikFlowTag";
106
+ }
107
+ }
108
+ /** Recognize markers without consuming the Markdown between paired tags. */
109
+ export declare function tagSyntax(declarations: TagDeclarations, options?: TagSyntaxOptions): Extension$1;
110
+ /** Build tag nodes after Markdown has established block and phrasing boundaries. */
111
+ export declare function tagFromMarkdown(declarations: TagDeclarations, options?: TagSyntaxOptions): Extension;
112
+ declare module "mdast-util-to-markdown" {
113
+ interface ConstructNameMap {
114
+ tagText: "tagText";
115
+ }
116
+ }
117
+ /** Serialize tag nodes using the host writer for their Markdown children. */
118
+ export declare function tagToMarkdown(declarations: TagDeclarations, options?: TagSyntaxOptions): Options;
119
+ /** Normalize complete literal runs before escaping, including caller-constructed segments. */
120
+ export declare function writeTextTemplate(template: unknown, code?: boolean): string;
121
+ declare module "unified" {
122
+ interface Data {
123
+ micromarkExtensions?: Extension$1[];
124
+ fromMarkdownExtensions?: Array<Extension[] | Extension>;
125
+ toMarkdownExtensions?: Options[];
126
+ }
127
+ }
128
+ /** Add declarative tag parsing and serialization to a remark processor. */
129
+ export declare const remarkTags: Plugin<[TagDeclarations, TagSyntaxOptions?], Root>;
130
+ export { remarkTags as default };