@jxsuite/parser 1.8.2 → 2.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "1.8.2",
3
+ "version": "2.0.1",
4
4
  "description": "Jx markdown parser and external class integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,10 +48,10 @@
48
48
  "upgrade": "bun update --latest && bun install"
49
49
  },
50
50
  "dependencies": {
51
- "@jxsuite/markup": "^0.4.11",
52
- "@jxsuite/schema": "^2.3.0",
53
- "@shikijs/langs": "^4.4.3",
54
- "@shikijs/themes": "^4.4.3",
51
+ "@jxsuite/markup": "^0.4.12",
52
+ "@jxsuite/schema": "^3.0.0",
53
+ "@shikijs/langs": "^4.5.0",
54
+ "@shikijs/themes": "^4.5.0",
55
55
  "glob": "^13.0.6",
56
56
  "mdast-util-to-string": "^4.0.0",
57
57
  "remark-directive": "^4.0.0",
@@ -60,14 +60,14 @@
60
60
  "remark-parse": "^11.0.0",
61
61
  "remark-parse-frontmatter": "^1.0.3",
62
62
  "remark-stringify": "^11.0.0",
63
- "shiki": "^4.4.3",
63
+ "shiki": "^4.5.0",
64
64
  "unified": "^11.0.5",
65
65
  "unist-util-visit": "^5.1.0",
66
66
  "yaml": "^2.9.1"
67
67
  },
68
68
  "devDependencies": {
69
- "@happy-dom/global-registrator": "^20.14.5",
70
- "@jxsuite/runtime": "^4.0.2",
69
+ "@happy-dom/global-registrator": "^20.14.6",
70
+ "@jxsuite/runtime": "^4.1.0",
71
71
  "@types/mdast": "^4.0.4",
72
72
  "ajv": "^8.20.0"
73
73
  },
@@ -9,7 +9,7 @@
9
9
  "description": "File-based content collections. Each key names a content type; entries are loaded from the named source through the format registry.",
10
10
  "type": "object",
11
11
  "additionalProperties": {
12
- "description": "Content type definition. Defines the source directory, frontmatter schema, and element dependencies.",
12
+ "description": "Content type definition. Defines the source, how entries are filtered, identified and routed, and their schema and element dependencies.",
13
13
  "type": "object",
14
14
  "properties": {
15
15
  "$elements": {
@@ -19,10 +19,16 @@
19
19
  "oneOf": [
20
20
  {
21
21
  "type": "object",
22
- "properties": { "$ref": { "type": "string" } },
22
+ "properties": {
23
+ "$ref": {
24
+ "type": "string"
25
+ }
26
+ },
23
27
  "required": ["$ref"]
24
28
  },
25
- { "type": "string" }
29
+ {
30
+ "type": "string"
31
+ }
26
32
  ]
27
33
  }
28
34
  },
@@ -34,16 +40,22 @@
34
40
  "description": "JSON Schema for validating frontmatter of content type entries.",
35
41
  "type": "object",
36
42
  "properties": {
37
- "type": { "const": "object" },
43
+ "type": {
44
+ "const": "object"
45
+ },
38
46
  "properties": {
39
47
  "description": "Field definitions; values are field schemas from the effective project field union.",
40
48
  "type": "object",
41
- "additionalProperties": { "$ref": "https://jxsuite.com/schema/project/fields/v2" }
49
+ "additionalProperties": {
50
+ "$ref": "https://jxsuite.com/schema/project/fields/v2"
51
+ }
42
52
  },
43
53
  "required": {
44
54
  "description": "Frontmatter fields every entry must provide.",
45
55
  "type": "array",
46
- "items": { "type": "string" }
56
+ "items": {
57
+ "type": "string"
58
+ }
47
59
  }
48
60
  }
49
61
  },
@@ -51,6 +63,126 @@
51
63
  "description": "Path to the content directory relative to the project root.",
52
64
  "examples": ["./content/blog/", "./content/docs/"],
53
65
  "type": "string"
66
+ },
67
+ "exclude": {
68
+ "description": "Glob patterns, relative to the source directory, for files that are never read: `internal/**`, `STYLE.md`, `**/node_modules/**`, `.*/**`. `*` and `?` stop at `/`, `**` crosses directories, `{a,b}` alternates, and dotfiles are ordinary files. A bare name with no extension (`internal`) excludes that folder as well as a file of the name. A pattern cannot climb out of the source (`../`). Excluded files are not parsed, validated, routed, linked to, searched or published, and the collection's asset mount does not serve them either: a note that refers to one gets a warning and the reference is left as written.",
69
+ "type": "array",
70
+ "items": {
71
+ "type": "string",
72
+ "minLength": 1
73
+ },
74
+ "examples": [["internal/**", "STYLE.md", ".*/**", "**/node_modules/**"]]
75
+ },
76
+ "where": {
77
+ "description": "Declarative frontmatter filter: only entries whose frontmatter satisfies every key are kept. A value is a literal to equal (a scalar also matches an array field that contains it; an array or an object with no `$` keys is compared whole) or an operator object: `$eq`, `$ne`, `$in`, `$nin`, `$exists`, `$gt`, `$gte`, `$lt`, `$lte`. Keys combine with AND; there is no OR and nothing is evaluated. A filter that removes every entry is reported. Filtered entries are not validated, routed, linked to, queried or searched, and are not served from the collection's asset mount.",
78
+ "type": "object",
79
+ "propertyNames": {
80
+ "pattern": "^[^$]"
81
+ },
82
+ "additionalProperties": {
83
+ "anyOf": [
84
+ {
85
+ "type": "object",
86
+ "propertyNames": {
87
+ "enum": ["$eq", "$ne", "$in", "$nin", "$exists", "$gt", "$gte", "$lt", "$lte"]
88
+ },
89
+ "minProperties": 1,
90
+ "properties": {
91
+ "$eq": {},
92
+ "$ne": {},
93
+ "$in": {
94
+ "type": "array"
95
+ },
96
+ "$nin": {
97
+ "type": "array"
98
+ },
99
+ "$exists": {
100
+ "type": "boolean"
101
+ },
102
+ "$gt": {
103
+ "type": ["string", "number"]
104
+ },
105
+ "$gte": {
106
+ "type": ["string", "number"]
107
+ },
108
+ "$lt": {
109
+ "type": ["string", "number"]
110
+ },
111
+ "$lte": {
112
+ "type": ["string", "number"]
113
+ }
114
+ }
115
+ },
116
+ {
117
+ "type": ["string", "number", "boolean", "null", "array"]
118
+ },
119
+ {
120
+ "type": "object",
121
+ "propertyNames": {
122
+ "pattern": "^[^$]"
123
+ }
124
+ }
125
+ ]
126
+ },
127
+ "examples": [
128
+ {
129
+ "publish": true,
130
+ "status": {
131
+ "$ne": "draft"
132
+ }
133
+ }
134
+ ]
135
+ },
136
+ "idField": {
137
+ "description": "Frontmatter field whose value is the entry id, instead of the path-derived id. An entry without the field keeps its path id and is reported.",
138
+ "type": "string",
139
+ "minLength": 1,
140
+ "examples": ["slug"]
141
+ },
142
+ "route": {
143
+ "description": "A site-absolute path template. `{name}` inserts the entry id (`id`), the file's directory (`dir`), its name (`file`) or a frontmatter field (dotted paths allowed); `{name:slug}` slugifies it, and `lower`, `upper` and `raw` are also available.",
144
+ "examples": ["/kb/{category:slug}/{slug}/"],
145
+ "type": "string",
146
+ "pattern": "^/"
147
+ },
148
+ "indexRoute": {
149
+ "description": "The route template for a directory's `README.md` or `index.md`, the file a folder stands for, when it should differ from `route` (a section's page at `/kb/{dir:slug}/` rather than `/kb/{category:slug}/{slug}/`). It takes the same placeholders and transforms as `route`, and needs a `route` for every other entry.",
150
+ "examples": ["/kb/{dir:slug}/"],
151
+ "type": "string",
152
+ "pattern": "^/"
153
+ },
154
+ "links": {
155
+ "description": "How a relative link to an entry that has no page (excluded, filtered out by `where`, unrouted, or missing) is reported. The link renders as plain text in every mode. Relative links are only rewritten for content types that declare a `route`.",
156
+ "enum": ["warn", "error", "ignore"],
157
+ "default": "warn"
158
+ },
159
+ "alerts": {
160
+ "description": "How GitHub-style alerts (`> [!NOTE]`) in Markdown entries render. `false` turns callouts off for the whole type; `true`, or leaving the option out, renders NOTE, TIP, IMPORTANT, WARNING and CAUTION as built-in accessible markup. An object maps an alert type (case-insensitive) to the custom element that renders it, e.g. { \"NOTE\": \"doc-note\" }; `true` renders a type with the built-in markup (how a type beyond GitHub's five, such as Obsidian's `info`, is switched on); null or false leaves a type an ordinary blockquote. A well-formed marker of a type that is not enabled is reported when the file loads.",
161
+ "type": ["object", "boolean"],
162
+ "propertyNames": {
163
+ "pattern": "^[A-Za-z][\\w-]*$"
164
+ },
165
+ "additionalProperties": {
166
+ "oneOf": [
167
+ {
168
+ "type": "string",
169
+ "pattern": "^[A-Za-z][\\w.-]*$"
170
+ },
171
+ {
172
+ "type": "null"
173
+ },
174
+ {
175
+ "type": "boolean"
176
+ }
177
+ ]
178
+ },
179
+ "examples": [
180
+ {
181
+ "NOTE": "doc-note",
182
+ "TIP": "doc-tip",
183
+ "WARNING": "doc-warning"
184
+ }
185
+ ]
54
186
  }
55
187
  }
56
188
  }
@@ -115,14 +115,17 @@
115
115
  "properties": {
116
116
  "data": { "type": "object" },
117
117
  "projectConfig": { "type": "object" },
118
- "root": { "type": "string" }
118
+ "root": { "type": "string" },
119
+ "locale": { "type": ["string", "null"] },
120
+ "urlPattern": { "type": "string" },
121
+ "params": { "type": "array", "items": { "type": "string" } }
119
122
  }
120
123
  },
121
- "description": "Host context: { data, projectConfig, root } where data is the projectData result"
124
+ "description": "Host context: { data, projectConfig, root, locale, urlPattern, params } where data is the projectData result and urlPattern/params describe the dynamic page being expanded"
122
125
  }
123
126
  ],
124
127
  "returnType": { "$ref": "#/$defs/returnTypes/PathEntries" },
125
- "description": "Expand a content-type $paths source into one route-param object per entry (param defaults to \"slug\", field to \"id\")"
128
+ "description": "Expand a content-type $paths source into one route-param object per entry (param defaults to \"slug\", field to \"id\"); a content type with a route and no param or field derives each entry's parameters from the page's own URL pattern"
126
129
  },
127
130
  "assets": {
128
131
  "role": "assets",
@@ -233,12 +233,19 @@
233
233
  "identifier": "options",
234
234
  "type": {
235
235
  "type": "object",
236
- "properties": { "baseDir": { "type": "string" } }
236
+ "properties": {
237
+ "baseDir": { "type": "string" },
238
+ "exclude": {
239
+ "type": "array",
240
+ "items": { "type": "string" },
241
+ "description": "Glob patterns, relative to the source directory, for files and directories to leave out"
242
+ }
243
+ }
237
244
  }
238
245
  }
239
246
  ],
240
247
  "returnType": { "type": "array", "items": { "type": "string" } },
241
- "description": "List markdown entry files for a content-type source"
248
+ "description": "List markdown entry files for a content-type source, in sorted order"
242
249
  },
243
250
  "load": {
244
251
  "role": "load",
@@ -259,7 +266,11 @@
259
266
  "type": "object",
260
267
  "properties": {
261
268
  "directiveOptions": { "type": "object" },
262
- "directives": { "type": "boolean" }
269
+ "directives": { "type": "boolean" },
270
+ "alerts": {
271
+ "description": "GitHub alert type -> custom element name (or false to leave alerts as blockquotes); absent renders the built-in callouts",
272
+ "oneOf": [{ "const": false }, { "type": "object" }]
273
+ }
263
274
  }
264
275
  }
265
276
  }
package/src/alerts.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Alerts — GitHub-style callouts (`> [!NOTE]`) recognised in a Markdown tree.
3
+ *
4
+ * GitHub, Obsidian and most static-site plugins agree on one notation for a callout: a blockquote
5
+ * whose first line is `[!TYPE]`. Without recognising it a document renders as a quotation that
6
+ * begins with the literal text `[!NOTE]`, which is wrong everywhere a reader sees it.
7
+ *
8
+ * Recognition is a **tree rewrite that runs before conversion**, not a case inside the blockquote
9
+ * handler, for two reasons. Alerts nest (in list items, in other callouts, inside directives), and
10
+ * a rewrite that visits every blockquote gets all of those without the converter having to know the
11
+ * configuration at every depth. And it keeps `transpileJxMarkdown`, the path Studio parses a file
12
+ * through, untouched: that path round-trips back to Markdown on save, and a callout already
13
+ * converted to a `div` would be written back as a directive, silently rewriting the author's file.
14
+ * Only the content loader asks for alerts.
15
+ *
16
+ * Pure: no `node:` imports, no knowledge of Jx elements. The converter lives beside `mdastNodeToJx`
17
+ * in `transpile.ts`.
18
+ *
19
+ * @module @jxsuite/parser/alerts
20
+ * @license MIT
21
+ * @docs framework/site/jx-markdown
22
+ */
23
+
24
+ import type { MdastNode } from "./types.ts";
25
+
26
+ /** The five alert types GitHub defines, and the title each shows by default. */
27
+ export const DEFAULT_ALERT_TITLES: Readonly<Record<string, string>> = {
28
+ caution: "Caution",
29
+ important: "Important",
30
+ note: "Note",
31
+ tip: "Tip",
32
+ warning: "Warning",
33
+ };
34
+
35
+ /** The synthetic mdast node type an alert becomes, consumed by `mdastNodeToJx`. */
36
+ export const ALERT_NODE = "jxAlert";
37
+
38
+ /**
39
+ * The `alerts` option of a content type: alert type (case-insensitive) → the custom element that
40
+ * renders it, or `true` for the built-in markup (which is also how a type beyond GitHub's five,
41
+ * such as Obsidian's `info` or `example`, is switched on). A `null` or `false` value leaves that
42
+ * type as an ordinary blockquote, and `false` for the whole option turns alert recognition off.
43
+ */
44
+ export type AlertsConfig = Record<string, string | boolean | null> | false;
45
+
46
+ /** The option after validation, keyed by lower-case alert type. */
47
+ export interface AlertSettings {
48
+ /** Type → element tag name, or `null` to render the built-in markup. */
49
+ types: Map<string, string | null>;
50
+ /** Types the option switched off on purpose, so an unknown-type report can skip them. */
51
+ disabled: Set<string>;
52
+ }
53
+
54
+ /** A tag name an author could mean: HTML elements and custom elements alike. */
55
+ const TAG_NAME = /^[A-Za-z][\w.-]*$/;
56
+
57
+ /**
58
+ * Validate the `alerts` option into settings, or `null` when recognition is off.
59
+ *
60
+ * Throws on a value that is not a tag name so a typo in `project.json` fails the build where it was
61
+ * written rather than rendering a callout as an unknown element.
62
+ */
63
+ export function normalizeAlerts(config?: unknown, where = "alerts"): AlertSettings | null {
64
+ if (config === false) {
65
+ return null;
66
+ }
67
+ const types = new Map<string, string | null>(
68
+ Object.keys(DEFAULT_ALERT_TITLES).map((type) => [type, null]),
69
+ );
70
+ const disabled = new Set<string>();
71
+ if (config === undefined || config === true) {
72
+ return { disabled, types };
73
+ }
74
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
75
+ throw new TypeError(
76
+ `${where}: expected an object mapping alert types to element names, or false`,
77
+ );
78
+ }
79
+ for (const [type, element] of Object.entries(config)) {
80
+ const key = type.toLowerCase();
81
+ if (!/^[a-z][\w-]*$/.test(key)) {
82
+ throw new TypeError(`${where}: "${type}" is not a valid alert type name`);
83
+ }
84
+ if (element === false || element === null) {
85
+ types.delete(key);
86
+ disabled.add(key);
87
+ } else if (element === true) {
88
+ types.set(key, null);
89
+ } else if (typeof element === "string" && TAG_NAME.test(element)) {
90
+ types.set(key, element);
91
+ } else {
92
+ throw new TypeError(
93
+ `${where}.${type}: expected an element name such as "doc-note", true or false, got ${JSON.stringify(element)}`,
94
+ );
95
+ }
96
+ }
97
+ return { disabled, types };
98
+ }
99
+
100
+ /** The shape an alert takes after the rewrite. */
101
+ export interface AlertNode extends MdastNode {
102
+ type: typeof ALERT_NODE;
103
+ /** Lower-case alert type: `note`, `tip`, … */
104
+ alert: string;
105
+ /** Custom element tag from the `alerts` option, or null for the built-in markup. */
106
+ element: string | null;
107
+ /** The author's title as inline nodes, or null to use the type's default title. */
108
+ titleNodes: MdastNode[] | null;
109
+ }
110
+
111
+ const MARKER = /^\[!([A-Za-z][\w-]*)\][+-]?/;
112
+
113
+ /** Plain text of a run of inline nodes. */
114
+ export function inlineText(nodes: readonly MdastNode[]): string {
115
+ let out = "";
116
+ for (const node of nodes) {
117
+ if (typeof node.value === "string") {
118
+ out += node.value;
119
+ } else if (node.children) {
120
+ out += inlineText(node.children);
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ function textNode(value: string): MdastNode {
127
+ return { type: "text", value };
128
+ }
129
+
130
+ /** A line end in either of its spellings: a file saved on Windows keeps `\r\n` in its text nodes. */
131
+ const LINE_END = /\r?\n/;
132
+
133
+ /**
134
+ * The `[!TYPE]` marker at the start of a paragraph, or null when there is none.
135
+ *
136
+ * `rest` is the text after the marker in that first text node. A marker must be followed by
137
+ * whitespace or the end of the node (`[!NOTE]x` is not one), and the whitespace may be a carriage
138
+ * return, which is what a CRLF file puts there.
139
+ */
140
+ function markerOf(paragraph: MdastNode): { type: string; rest: string } | null {
141
+ const [first] = paragraph.children ?? [];
142
+ if (first?.type !== "text" || typeof first.value !== "string") {
143
+ return null;
144
+ }
145
+ const match = MARKER.exec(first.value);
146
+ if (!match) {
147
+ return null;
148
+ }
149
+ const rest = first.value.slice(match[0].length);
150
+ if (rest !== "" && !/^[ \t\r\n]/.test(rest)) {
151
+ return null;
152
+ }
153
+ return { rest, type: match[1]!.toLowerCase() };
154
+ }
155
+
156
+ /**
157
+ * Split the first paragraph of a blockquote into the title line and the rest, or null when the
158
+ * blockquote is not an alert of a configured type.
159
+ *
160
+ * The title is whatever follows `[!TYPE]` on its own line, across inline formatting (`> [!NOTE] A
161
+ * **bold** title`). That is Obsidian's custom-title form; GitHub itself shows such a blockquote as
162
+ * plain text, so a file using it still reads sensibly there.
163
+ */
164
+ function splitMarker(
165
+ paragraph: MdastNode,
166
+ settings: AlertSettings,
167
+ ): { type: string; title: MdastNode[]; body: MdastNode[] } | null {
168
+ const marker = markerOf(paragraph);
169
+ if (!marker || !settings.types.has(marker.type)) {
170
+ return null;
171
+ }
172
+ const { type, rest } = marker;
173
+ const [, ...siblings] = paragraph.children ?? [];
174
+
175
+ const title: MdastNode[] = [];
176
+ const body: MdastNode[] = [];
177
+ let inTitle = true;
178
+ const take = (node: MdastNode) => {
179
+ if (inTitle && node.type === "break") {
180
+ inTitle = false;
181
+ } else if (inTitle && node.type === "text" && typeof node.value === "string") {
182
+ const lineEnd = LINE_END.exec(node.value);
183
+ if (lineEnd === null) {
184
+ title.push(node);
185
+ } else {
186
+ title.push(textNode(node.value.slice(0, lineEnd.index)));
187
+ inTitle = false;
188
+ body.push(textNode(node.value.slice(lineEnd.index + lineEnd[0].length)));
189
+ }
190
+ } else {
191
+ (inTitle ? title : body).push(node);
192
+ }
193
+ };
194
+ take(textNode(rest.replace(/^[ \t]+/, "")));
195
+ for (const node of siblings) {
196
+ take(node);
197
+ }
198
+
199
+ const trimmed = title.filter((node) => !(node.type === "text" && node.value === ""));
200
+ const text = inlineText(trimmed);
201
+ return {
202
+ body: body.filter((node) => !(node.type === "text" && node.value === "")),
203
+ title: text.trim() === "" ? [] : trimmed,
204
+ type,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Rewrite every alert blockquote in a tree into an {@link AlertNode}, in place.
210
+ *
211
+ * A blockquote that is not an alert, or is of a type the settings do not know, is left alone and
212
+ * its children are still searched, so a callout nested inside an ordinary quotation is found.
213
+ * `onUnknown` hears each well-formed `[!type]` marker whose type is not enabled.
214
+ *
215
+ * @param {MdastNode} tree
216
+ * @param {AlertSettings} settings
217
+ * @param {(type: string) => void} [onUnknown]
218
+ */
219
+ export function transformAlerts(
220
+ tree: MdastNode,
221
+ settings: AlertSettings,
222
+ onUnknown?: (type: string) => void,
223
+ ): void {
224
+ const { children } = tree;
225
+ if (!children) {
226
+ return;
227
+ }
228
+ for (const [i, child] of children.entries()) {
229
+ if (child.type === "blockquote") {
230
+ const [paragraph, ...rest] = child.children ?? [];
231
+ const split = paragraph?.type === "paragraph" ? splitMarker(paragraph, settings) : null;
232
+ if (split) {
233
+ const bodyParagraph: MdastNode[] =
234
+ split.body.length > 0 && inlineText(split.body).trim() !== ""
235
+ ? [{ children: split.body, type: "paragraph" }]
236
+ : [];
237
+ const alert: AlertNode = {
238
+ alert: split.type,
239
+ children: [...bodyParagraph, ...rest],
240
+ element: settings.types.get(split.type) ?? null,
241
+ titleNodes: split.title.length > 0 ? split.title : null,
242
+ type: ALERT_NODE,
243
+ };
244
+ children[i] = alert;
245
+ transformAlerts(alert, settings, onUnknown);
246
+ continue;
247
+ }
248
+ // A well-formed marker of a type nobody switched on: it stays a blockquote, and the author
249
+ // Is told, because the literal `[!info]` on the page is otherwise the only clue.
250
+ const unknown = paragraph?.type === "paragraph" ? markerOf(paragraph) : null;
251
+ if (unknown && !settings.disabled.has(unknown.type)) {
252
+ onUnknown?.(unknown.type);
253
+ }
254
+ }
255
+ transformAlerts(child, settings, onUnknown);
256
+ }
257
+ }