@airalogy/aimd-renderer 2.4.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.
@@ -0,0 +1,159 @@
1
+ import type { Element, Root as HastRoot, Text as HastText } from "hast"
2
+ import type { ShikiHighlighter } from "../vue/vue-renderer"
3
+ import type { AimdRendererOptions } from "./processor"
4
+ import { buildInlineStyle, resolveAssignerVisibility } from "./assignerVisibility"
5
+
6
+ let assignerHighlighterLoadPromise: Promise<ShikiHighlighter | null> | null = null
7
+ const ASSIGNER_HIGHLIGHT_THEME = "github-light"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // HAST helpers (also used by the pipeline coordinator)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function getClassNames(value: unknown): string[] {
14
+ if (Array.isArray(value)) {
15
+ return value.filter((item): item is string => typeof item === "string")
16
+ }
17
+ if (typeof value === "string" && value.trim()) {
18
+ return value.trim().split(/\s+/)
19
+ }
20
+ return []
21
+ }
22
+
23
+ export function hasClassName(node: Element, className: string): boolean {
24
+ return getClassNames(node.properties?.className).includes(className)
25
+ }
26
+
27
+ export function getCodeLanguage(node: Element): string | null {
28
+ const languageClass = getClassNames(node.properties?.className).find(name => name.startsWith("language-"))
29
+ return languageClass ? languageClass.replace("language-", "") : null
30
+ }
31
+
32
+ export function collectTextContent(node: Element): string {
33
+ return node.children.map((child) => {
34
+ if (child.type === "text") {
35
+ return child.value
36
+ }
37
+ if (child.type === "element") {
38
+ return collectTextContent(child)
39
+ }
40
+ return ""
41
+ }).join("")
42
+ }
43
+
44
+ export function visitHastElements(node: HastRoot | Element, visitor: (element: Element) => void): void {
45
+ if (node.type === "element") {
46
+ visitor(node)
47
+ }
48
+
49
+ const children = "children" in node ? node.children : []
50
+ for (const child of children) {
51
+ if (child.type === "element") {
52
+ visitHastElements(child, visitor)
53
+ }
54
+ }
55
+ }
56
+
57
+ export function findDescendantElement(node: Element, predicate: (element: Element) => boolean): Element | null {
58
+ for (const child of node.children) {
59
+ if (child.type !== "element") {
60
+ continue
61
+ }
62
+ if (predicate(child)) {
63
+ return child
64
+ }
65
+ const nested = findDescendantElement(child, predicate)
66
+ if (nested) {
67
+ return nested
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Shiki highlighting for assigner code blocks
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function createHighlightedLineChildren(
78
+ tokens: Array<{ content: string, variants: Record<string, { color: string }> }>,
79
+ ): Array<Element | HastText> {
80
+ if (tokens.length === 0) {
81
+ return [{ type: "text", value: "\u00A0" }]
82
+ }
83
+
84
+ return tokens.map((token) => {
85
+ const color = Object.values(token.variants || {}).find(variant => typeof variant?.color === "string")?.color
86
+ return {
87
+ type: "element",
88
+ tagName: "span",
89
+ properties: color ? { style: buildInlineStyle({ color }) } : {},
90
+ children: [{ type: "text", value: token.content || " " }],
91
+ } as Element
92
+ })
93
+ }
94
+
95
+ async function getAssignerHighlighter(): Promise<ShikiHighlighter | null> {
96
+ if (!assignerHighlighterLoadPromise) {
97
+ assignerHighlighterLoadPromise = import("shiki")
98
+ .then(async ({ createHighlighter }) => {
99
+ const highlighter = await createHighlighter({
100
+ themes: [ASSIGNER_HIGHLIGHT_THEME],
101
+ langs: ["javascript", "python"],
102
+ })
103
+ return highlighter as unknown as ShikiHighlighter
104
+ })
105
+ .catch(() => null)
106
+ }
107
+
108
+ return assignerHighlighterLoadPromise
109
+ }
110
+
111
+ /**
112
+ * Walk a HAST tree and apply Shiki syntax highlighting to every visible
113
+ * assigner code block (`<code>` inside `.aimd-assigner-preview`).
114
+ */
115
+ export async function highlightVisibleAssigners(tree: HastRoot, options: AimdRendererOptions): Promise<void> {
116
+ if (resolveAssignerVisibility(options.assignerVisibility) === "hidden") {
117
+ return
118
+ }
119
+
120
+ const highlighter = await getAssignerHighlighter()
121
+ if (!highlighter?.codeToTokensWithThemes) {
122
+ return
123
+ }
124
+
125
+ visitHastElements(tree, (element) => {
126
+ if (!hasClassName(element, "aimd-assigner-preview")) {
127
+ return
128
+ }
129
+
130
+ const codeNode = findDescendantElement(element, candidate => candidate.tagName === "code")
131
+ if (!codeNode || codeNode.properties?.["data-aimd-highlighted"] === "true") {
132
+ return
133
+ }
134
+
135
+ const lang = getCodeLanguage(codeNode) || "text"
136
+ const codeContent = collectTextContent(codeNode)
137
+ const lines = highlighter.codeToTokensWithThemes?.(codeContent, {
138
+ lang,
139
+ themes: { light: ASSIGNER_HIGHLIGHT_THEME },
140
+ }) || []
141
+
142
+ codeNode.children = lines.map((lineTokens) => {
143
+ return {
144
+ type: "element",
145
+ tagName: "span",
146
+ properties: {
147
+ className: ["aimd-assigner-code__line"],
148
+ style: buildInlineStyle({ display: "block" }),
149
+ },
150
+ children: createHighlightedLineChildren(lineTokens),
151
+ } as Element
152
+ })
153
+ codeNode.properties = {
154
+ ...codeNode.properties,
155
+ className: [...getClassNames(codeNode.properties?.className), "aimd-assigner-code"],
156
+ "data-aimd-highlighted": "true",
157
+ }
158
+ })
159
+ }
@@ -0,0 +1,289 @@
1
+ import type { Plugin } from "unified"
2
+ import type { AimdRendererOptions } from "./processor"
3
+ import type { AimdAssignerVisibility } from "./processor"
4
+ import { createAimdRendererMessages } from "../locales"
5
+
6
+ /**
7
+ * Internal markdown AST node shape used during remark processing.
8
+ */
9
+ export interface MarkdownNode {
10
+ type: string
11
+ children?: MarkdownNode[]
12
+ lang?: string | null
13
+ meta?: string | null
14
+ value?: string
15
+ }
16
+
17
+ export type MarkdownParent = MarkdownNode & { children: MarkdownNode[] }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export function resolveAssignerVisibility(
24
+ visibility: AimdRendererOptions["assignerVisibility"],
25
+ ): AimdAssignerVisibility {
26
+ switch (visibility) {
27
+ case "collapsed":
28
+ case "expanded":
29
+ return visibility
30
+ default:
31
+ return "hidden"
32
+ }
33
+ }
34
+
35
+ export function isAssignerCodeNode(node: MarkdownNode): boolean {
36
+ return node.type === "code" && (node.lang || "").trim().toLowerCase() === "assigner"
37
+ }
38
+
39
+ export function getAssignerRuntime(meta: string | null | undefined): "client" | "server" {
40
+ const runtime = String((meta || "").match(/\bruntime\s*=\s*([^\s]+)/)?.[1] || "")
41
+ .trim()
42
+ .replace(/^['"]|['"]$/g, "")
43
+ .toLowerCase()
44
+ return runtime === "client" ? "client" : "server"
45
+ }
46
+
47
+ export function getRenderedAssignerLanguage(runtime: "client" | "server"): "javascript" | "python" {
48
+ return runtime === "client" ? "javascript" : "python"
49
+ }
50
+
51
+ export function escapeHtml(value: string): string {
52
+ return value
53
+ .replace(/&/g, "&amp;")
54
+ .replace(/</g, "&lt;")
55
+ .replace(/>/g, "&gt;")
56
+ .replace(/"/g, "&quot;")
57
+ }
58
+
59
+ export function buildInlineStyle(declarations: Record<string, string>): string {
60
+ return Object.entries(declarations)
61
+ .map(([property, value]) => `${property}:${value}`)
62
+ .join(";")
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Presentation helpers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ interface AssignerPreviewPresentation {
70
+ badge: string
71
+ containerStyle: string
72
+ headerStyle: string
73
+ titleStyle: string
74
+ badgeStyle: string
75
+ preStyle: string
76
+ codeStyle: string
77
+ }
78
+
79
+ function getCollapsedAssignerPresentation(runtime: "client" | "server"): AssignerPreviewPresentation {
80
+ const isClient = runtime === "client"
81
+ const accent = isClient ? "#0f766e" : "#9a3412"
82
+ const accentSoft = isClient ? "rgba(15, 118, 110, 0.08)" : "rgba(154, 52, 18, 0.08)"
83
+ const border = "rgba(148, 163, 184, 0.26)"
84
+ const codeBackground = "#f8fafc"
85
+ const codeForeground = "#0f172a"
86
+ const ruleColor = "rgba(148, 163, 184, 0.18)"
87
+
88
+ return {
89
+ badge: isClient ? "JS" : "PY",
90
+ containerStyle: buildInlineStyle({
91
+ margin: "0.85rem 0",
92
+ border: `1px solid ${border}`,
93
+ "border-radius": "12px",
94
+ overflow: "hidden",
95
+ background: "rgba(255, 255, 255, 0.92)",
96
+ }),
97
+ headerStyle: buildInlineStyle({
98
+ display: "flex",
99
+ "align-items": "center",
100
+ "justify-content": "space-between",
101
+ gap: "0.7rem",
102
+ padding: "0.6rem 0.8rem",
103
+ "list-style": "none",
104
+ background: "rgba(248, 250, 252, 0.92)",
105
+ color: "#64748b",
106
+ "font-weight": "600",
107
+ "font-size": "0.86rem",
108
+ }),
109
+ titleStyle: buildInlineStyle({
110
+ display: "inline-flex",
111
+ "align-items": "center",
112
+ gap: "0.45rem",
113
+ "letter-spacing": "0.01em",
114
+ }),
115
+ badgeStyle: buildInlineStyle({
116
+ display: "inline-flex",
117
+ "align-items": "center",
118
+ "justify-content": "center",
119
+ padding: "0.12rem 0.44rem",
120
+ "min-width": "2rem",
121
+ "border-radius": "999px",
122
+ border: `1px solid ${accentSoft}`,
123
+ background: accentSoft,
124
+ color: accent,
125
+ "font-size": "0.72rem",
126
+ "font-weight": "700",
127
+ "letter-spacing": "0.08em",
128
+ }),
129
+ preStyle: buildInlineStyle({
130
+ margin: "0",
131
+ padding: "0.8rem 0.85rem 0.9rem",
132
+ overflow: "auto",
133
+ background: codeBackground,
134
+ border: "0",
135
+ "border-top": `1px solid ${ruleColor}`,
136
+ "tab-size": "2",
137
+ }),
138
+ codeStyle: buildInlineStyle({
139
+ display: "block",
140
+ color: codeForeground,
141
+ background: "transparent",
142
+ "font-family": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
143
+ "font-size": "0.88rem",
144
+ "line-height": "1.6",
145
+ "white-space": "pre",
146
+ padding: "0",
147
+ }),
148
+ }
149
+ }
150
+
151
+ function getExpandedAssignerPresentation(runtime: "client" | "server"): AssignerPreviewPresentation {
152
+ const presentation = getCollapsedAssignerPresentation(runtime)
153
+ return {
154
+ ...presentation,
155
+ containerStyle: buildInlineStyle({
156
+ ...Object.fromEntries(presentation.containerStyle.split(";").filter(Boolean).map(rule => {
157
+ const [property, value] = rule.split(":")
158
+ return [property, value]
159
+ })),
160
+ margin: "1rem 0",
161
+ }),
162
+ headerStyle: buildInlineStyle({
163
+ ...Object.fromEntries(presentation.headerStyle.split(";").filter(Boolean).map(rule => {
164
+ const [property, value] = rule.split(":")
165
+ return [property, value]
166
+ })),
167
+ cursor: "default",
168
+ }),
169
+ }
170
+ }
171
+
172
+ function createAssignerHeaderHtml(summary: string, presentation: AssignerPreviewPresentation): string {
173
+ return `<span style="${presentation.titleStyle}">${escapeHtml(summary)}</span>`
174
+ + `<span aria-hidden="true" style="${presentation.badgeStyle}">${presentation.badge}</span>`
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Assigner AST node builders
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function buildExpandedAssignerNode(
182
+ value: string,
183
+ runtime: "client" | "server",
184
+ options: AimdRendererOptions,
185
+ ): MarkdownNode {
186
+ const messages = createAimdRendererMessages(options.locale, options.messages)
187
+ const language = getRenderedAssignerLanguage(runtime)
188
+ const summary = runtime === "client"
189
+ ? messages.assigner.clientSummary
190
+ : messages.assigner.serverSummary
191
+ const presentation = getExpandedAssignerPresentation(runtime)
192
+
193
+ return {
194
+ type: "html",
195
+ value:
196
+ `<div class="aimd-assigner-preview aimd-assigner-preview--expanded aimd-assigner-preview--${runtime}" data-aimd-assigner-runtime="${runtime}" style="${presentation.containerStyle}">`
197
+ + `<div style="${presentation.headerStyle}">`
198
+ + createAssignerHeaderHtml(summary, presentation)
199
+ + "</div>"
200
+ + `<pre style="${presentation.preStyle}"><code class="language-${language}" style="${presentation.codeStyle}">${escapeHtml(value)}</code></pre>`
201
+ + "</div>",
202
+ }
203
+ }
204
+
205
+ function buildCollapsedAssignerNode(
206
+ value: string,
207
+ runtime: "client" | "server",
208
+ options: AimdRendererOptions,
209
+ ): MarkdownNode {
210
+ const messages = createAimdRendererMessages(options.locale, options.messages)
211
+ const language = getRenderedAssignerLanguage(runtime)
212
+ const summary = runtime === "client"
213
+ ? messages.assigner.clientSummary
214
+ : messages.assigner.serverSummary
215
+ const presentation = getCollapsedAssignerPresentation(runtime)
216
+
217
+ return {
218
+ type: "html",
219
+ value:
220
+ `<details class="aimd-assigner-preview aimd-assigner-preview--collapsed aimd-assigner-preview--${runtime}" data-aimd-assigner-runtime="${runtime}" style="${presentation.containerStyle}">`
221
+ + `<summary style="${presentation.headerStyle}">`
222
+ + createAssignerHeaderHtml(summary, presentation)
223
+ + "</summary>"
224
+ + `<pre style="${presentation.preStyle}"><code class="language-${language}" style="${presentation.codeStyle}">${escapeHtml(value)}</code></pre>`
225
+ + "</details>",
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Tree visitor
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export function visitMarkdownParents(node: MarkdownNode, visitor: (parent: MarkdownParent) => void): void {
234
+ if (!Array.isArray(node.children)) {
235
+ return
236
+ }
237
+
238
+ const parent = node as MarkdownParent
239
+ visitor(parent)
240
+
241
+ for (const child of parent.children) {
242
+ visitMarkdownParents(child, visitor)
243
+ }
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Remark plugins
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Remark plugin that inserts visible assigner preview nodes (collapsed or
252
+ * expanded) next to each assigner code block in the markdown AST.
253
+ */
254
+ export const remarkInsertVisibleAssigners: Plugin<[AimdRendererOptions?], MarkdownNode> = (options = {}) => {
255
+ return (tree) => {
256
+ const visibility = resolveAssignerVisibility(options.assignerVisibility)
257
+ if (visibility === "hidden") {
258
+ return
259
+ }
260
+
261
+ visitMarkdownParents(tree, (parent) => {
262
+ for (let index = 0; index < parent.children.length; index++) {
263
+ const child = parent.children[index]
264
+ if (!isAssignerCodeNode(child)) {
265
+ continue
266
+ }
267
+
268
+ const runtime = getAssignerRuntime(child.meta)
269
+ const replacement = visibility === "expanded"
270
+ ? buildExpandedAssignerNode(child.value || "", runtime, options)
271
+ : buildCollapsedAssignerNode(child.value || "", runtime, options)
272
+
273
+ parent.children.splice(index, 0, replacement)
274
+ index += 1
275
+ }
276
+ })
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Remark plugin that strips all assigner code blocks from the markdown AST.
282
+ */
283
+ export const remarkStripAssignerCodeBlocks: Plugin<[], MarkdownNode> = () => {
284
+ return (tree) => {
285
+ visitMarkdownParents(tree, (parent) => {
286
+ parent.children = parent.children.filter(child => !isAssignerCodeNode(child))
287
+ })
288
+ }
289
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Event bus keys for AIMD system
3
+ */
4
+ import type { InjectionKey } from "vue"
5
+
6
+ export const fieldEventKey: InjectionKey<{ name: "field-event" }> = Symbol("symbol-field-event-key")
7
+ export const protocolKey: InjectionKey<{ name: "protocol-key" }> = Symbol("symbol-protocol-key")
8
+ export const draftEventKey: InjectionKey<{ name: "draft-event" }> = Symbol("draft-event-key")
9
+ export const reportEventKey: InjectionKey<{ name: "report-event" }> = Symbol("symbol-report-event-key")
10
+ export const bubbleMenuEventKey: InjectionKey<{ name: "bubble-menu-event" }> = Symbol("symbol-bubble-menu-event-key")
@@ -0,0 +1,126 @@
1
+ import type { Element, Text as HastText, Root as HastRoot } from "hast"
2
+ import type { ExtractedAimdFields } from "@airalogy/aimd-core/types"
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Figure numbering helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Build a map from figure id to its sequence number based on the
10
+ * extracted fields. The sequence is assigned in document order (1-based).
11
+ */
12
+ export function buildFigureSequenceMap(fields: ExtractedAimdFields): Map<string, number> {
13
+ const sequenceMap = new Map<string, number>()
14
+ const figs = fields.fig || []
15
+ let seq = 1
16
+ for (const fig of figs) {
17
+ const id = typeof fig === "string" ? fig : (fig as any)?.id
18
+ if (typeof id === "string" && id.trim() && !sequenceMap.has(id)) {
19
+ sequenceMap.set(id, seq++)
20
+ }
21
+ }
22
+ return sequenceMap
23
+ }
24
+
25
+ /**
26
+ * Build the HAST children array for a figure AIMD node.
27
+ *
28
+ * Returns `[imgElement]` or `[imgElement, figcaptionElement]` depending on
29
+ * whether a title or legend is present.
30
+ */
31
+ export function buildFigureChildren(figNode: {
32
+ id: string
33
+ src?: string
34
+ title?: string
35
+ legend?: string
36
+ sequence?: number | string
37
+ }): (Element | HastText)[] {
38
+ const figSrc = figNode.src || ""
39
+ const figTitle = figNode.title
40
+ const figLegend = figNode.legend
41
+
42
+ const children: (Element | HastText)[] = []
43
+
44
+ // Create img element
45
+ children.push({
46
+ type: "element",
47
+ tagName: "img",
48
+ properties: {
49
+ src: figSrc,
50
+ alt: figTitle || figNode.id,
51
+ className: ["aimd-figure__image"],
52
+ },
53
+ children: [],
54
+ } as Element)
55
+
56
+ // Create figcaption if title or legend exists
57
+ if (figTitle || figLegend) {
58
+ const captionChildren: (Element | HastText)[] = []
59
+
60
+ if (figTitle) {
61
+ captionChildren.push({
62
+ type: "element",
63
+ tagName: "div",
64
+ properties: { className: ["aimd-figure__title"] },
65
+ children: [{ type: "text", value: figTitle }],
66
+ } as Element)
67
+ }
68
+
69
+ if (figLegend) {
70
+ captionChildren.push({
71
+ type: "element",
72
+ tagName: "div",
73
+ properties: { className: ["aimd-figure__legend"] },
74
+ children: [{ type: "text", value: figLegend }],
75
+ } as Element)
76
+ }
77
+
78
+ children.push({
79
+ type: "element",
80
+ tagName: "figcaption",
81
+ properties: { className: ["aimd-figure__caption"] },
82
+ children: captionChildren,
83
+ } as Element)
84
+ }
85
+
86
+ return children
87
+ }
88
+
89
+ /**
90
+ * Walk the HAST tree and stamp each `[data-aimd-type="fig"]` element with
91
+ * its 1-based sequence number as `data-aimd-fig-sequence`.
92
+ *
93
+ * This is a post-processing pass that runs after the initial HAST is built
94
+ * so that figure numbers are consistent with document order regardless of
95
+ * any reordering caused by rehype plugins.
96
+ */
97
+ export function assignFigureSequenceNumbers(
98
+ tree: HastRoot,
99
+ fields: ExtractedAimdFields,
100
+ ): void {
101
+ const sequenceMap = buildFigureSequenceMap(fields)
102
+ if (sequenceMap.size === 0) {
103
+ return
104
+ }
105
+
106
+ const visit = (node: HastRoot | Element): void => {
107
+ if (node.type === "element") {
108
+ const el = node as Element
109
+ if (el.properties?.["data-aimd-type"] === "fig") {
110
+ const figId = el.properties["data-aimd-fig-id"] as string | undefined
111
+ if (figId && sequenceMap.has(figId)) {
112
+ el.properties["data-aimd-fig-sequence"] = String(sequenceMap.get(figId))
113
+ }
114
+ }
115
+ }
116
+
117
+ const children = "children" in node ? node.children : []
118
+ for (const child of children) {
119
+ if (child.type === "element") {
120
+ visit(child)
121
+ }
122
+ }
123
+ }
124
+
125
+ visit(tree)
126
+ }