@liminis/diagrams 0.1.4 → 0.1.5

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,53 @@
1
+ /**
2
+ * Turn ```c4 fenced code blocks into live <C4Playground> islands.
3
+ *
4
+ * The point is a single source of truth that renders usefully in both places:
5
+ *
6
+ * - **GitHub** shows the fence as a syntax-highlighted code block. Honest and
7
+ * readable with no build step, which matters because these files are read on
8
+ * github.com as often as on the docs site.
9
+ * - **The docs site** replaces it with the interactive component.
10
+ *
11
+ * The alternative was hand-writing `<C4Playground source={...} />` per diagram,
12
+ * which duplicates the source into JSX, makes the page unreadable on GitHub, and
13
+ * forces every page carrying a diagram to be MDX-authored rather than markdown.
14
+ *
15
+ * Fence meta becomes props, so a diagram can say how it wants to be shown:
16
+ *
17
+ * ```c4 readOnly height=26rem
18
+ * ```c4 static (readOnly, drag off — a pure illustration)
19
+ *
20
+ * Unknown meta words are ignored rather than throwing: a fence is content, and
21
+ * a typo in it should not fail a docs build.
22
+ */
23
+ /**
24
+ * The mdast/mdx node shapes this plugin touches, typed only as far as it uses
25
+ * them. Deliberately not `@types/mdast`: that would be a dependency for a
26
+ * build-time plugin, and the tree this walks is already whatever the host's
27
+ * remark version produced. `unknown`-valued extras keep the shape open.
28
+ */
29
+ export interface MdastNode {
30
+ type: string;
31
+ name?: string;
32
+ lang?: string;
33
+ meta?: string;
34
+ value?: string;
35
+ children?: MdastNode[];
36
+ attributes?: {
37
+ type: string;
38
+ name?: string;
39
+ value?: unknown;
40
+ }[];
41
+ data?: Record<string, unknown>;
42
+ [key: string]: unknown;
43
+ }
44
+ export interface RemarkC4Options {
45
+ /**
46
+ * Module specifier the injected `import` points at. Defaults to
47
+ * `@site/components/C4Playground.tsx`, the convention the Liminis sites use:
48
+ * an alias, so one string is correct at every page depth.
49
+ */
50
+ component?: string;
51
+ }
52
+ export declare function remarkC4(options?: RemarkC4Options): (tree: MdastNode) => void;
53
+ export default remarkC4;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Turn ```c4 fenced code blocks into live <C4Playground> islands.
3
+ *
4
+ * The point is a single source of truth that renders usefully in both places:
5
+ *
6
+ * - **GitHub** shows the fence as a syntax-highlighted code block. Honest and
7
+ * readable with no build step, which matters because these files are read on
8
+ * github.com as often as on the docs site.
9
+ * - **The docs site** replaces it with the interactive component.
10
+ *
11
+ * The alternative was hand-writing `<C4Playground source={...} />` per diagram,
12
+ * which duplicates the source into JSX, makes the page unreadable on GitHub, and
13
+ * forces every page carrying a diagram to be MDX-authored rather than markdown.
14
+ *
15
+ * Fence meta becomes props, so a diagram can say how it wants to be shown:
16
+ *
17
+ * ```c4 readOnly height=26rem
18
+ * ```c4 static (readOnly, drag off — a pure illustration)
19
+ *
20
+ * Unknown meta words are ignored rather than throwing: a fence is content, and
21
+ * a typo in it should not fail a docs build.
22
+ */
23
+ const COMPONENT = 'C4Playground';
24
+ /**
25
+ * Where the island component is imported from, by default.
26
+ *
27
+ * An alias rather than a relative path: the injected import is the same string
28
+ * on every page, but pages need not sit at the same depth, and a relative path
29
+ * would be correct for exactly one of them. Hosts using a different convention
30
+ * pass `component` instead.
31
+ */
32
+ const DEFAULT_COMPONENT_PATH = '@site/components/C4Playground.tsx';
33
+ /**
34
+ * Walk every node, depth-first, with its parent and index.
35
+ *
36
+ * `unist-util-visit` does this and more, and using it would have made
37
+ * @liminis/diagrams depend on something beyond dagre — an invariant the package
38
+ * asserts about itself and that keeps `./core` as small as it claims to be. Both
39
+ * uses here are plain traversals, so the general version buys nothing.
40
+ *
41
+ * Children are walked before the callback sees the parent's later siblings, and
42
+ * the callback must not splice: collect first, mutate after. Both callers do.
43
+ */
44
+ function walk(node, visitor, parent = null, index = null) {
45
+ visitor(node, index, parent);
46
+ const children = node.children;
47
+ if (!Array.isArray(children))
48
+ return;
49
+ // A copy, so a callback that does mutate cannot make this skip a node.
50
+ for (const [i, child] of [...children].entries())
51
+ walk(child, visitor, node, i);
52
+ }
53
+ /** An mdast attribute whose value is a JS expression rather than a string. */
54
+ function expressionAttribute(name, value) {
55
+ return {
56
+ type: 'mdxJsxAttribute',
57
+ name,
58
+ value: {
59
+ type: 'mdxJsxAttributeValueExpression',
60
+ value: JSON.stringify(value),
61
+ data: {
62
+ estree: {
63
+ type: 'Program',
64
+ sourceType: 'module',
65
+ body: [
66
+ {
67
+ type: 'ExpressionStatement',
68
+ expression: { type: 'Literal', value, raw: JSON.stringify(value) },
69
+ },
70
+ ],
71
+ },
72
+ },
73
+ },
74
+ };
75
+ }
76
+ function booleanAttribute(name, value) {
77
+ return value
78
+ ? { type: 'mdxJsxAttribute', name, value: null }
79
+ : expressionAttribute(name, false);
80
+ }
81
+ function parseMeta(meta) {
82
+ // Keyed by attribute name, so a fence can only ever produce one of each.
83
+ //
84
+ // `static` is shorthand for two attributes, which made `static editable=true`
85
+ // emit `editable` twice — leaving which one wins to whatever the downstream
86
+ // JSX serialiser does with a duplicate. Later tokens now overwrite earlier
87
+ // ones, so that fence means what it reads like: static, but editable after
88
+ // all. Order is what decides, in both directions: `editable=true static` is
89
+ // static, because `static` came last.
90
+ const props = new Map();
91
+ if (!meta)
92
+ return [];
93
+ const set = (attribute) => props.set(attribute.name, attribute);
94
+ for (const token of meta.trim().split(/\s+/)) {
95
+ if (!token)
96
+ continue;
97
+ const [key, raw] = token.split('=');
98
+ switch (key) {
99
+ case 'readOnly':
100
+ set(booleanAttribute('readOnly', true));
101
+ break;
102
+ case 'static':
103
+ // Shorthand: an illustration, not an invitation to edit or drag.
104
+ set(booleanAttribute('readOnly', true));
105
+ set(booleanAttribute('editable', false));
106
+ break;
107
+ case 'editable':
108
+ set(booleanAttribute('editable', raw !== 'false'));
109
+ break;
110
+ case 'height':
111
+ if (raw)
112
+ set({ type: 'mdxJsxAttribute', name: 'height', value: raw });
113
+ break;
114
+ default:
115
+ // Ignored on purpose — see the note above.
116
+ break;
117
+ }
118
+ }
119
+ return [...props.values()];
120
+ }
121
+ /** `import C4Playground from '…'`, built as estree rather than parsed. */
122
+ function importNode(componentPath) {
123
+ // JSON.stringify rather than wrapping in quotes: a path containing a quote or
124
+ // a backslash would otherwise produce invalid JS in the text *and* a `raw`
125
+ // that disagrees with the value beside it — two representations of the same
126
+ // import, differing. Bundlers read the estree; humans read the text.
127
+ const literal = JSON.stringify(componentPath);
128
+ return {
129
+ type: 'mdxjsEsm',
130
+ value: `import ${COMPONENT} from ${literal}`,
131
+ data: {
132
+ estree: {
133
+ type: 'Program',
134
+ sourceType: 'module',
135
+ body: [
136
+ {
137
+ type: 'ImportDeclaration',
138
+ specifiers: [
139
+ {
140
+ type: 'ImportDefaultSpecifier',
141
+ local: { type: 'Identifier', name: COMPONENT },
142
+ },
143
+ ],
144
+ source: { type: 'Literal', value: componentPath, raw: literal },
145
+ attributes: [],
146
+ },
147
+ ],
148
+ },
149
+ },
150
+ };
151
+ }
152
+ /**
153
+ * The generated `<picture>` blocks exist for GitHub, which has no build step.
154
+ * Here the island renders the same diagram interactively, so showing both would
155
+ * be duplication — they are stripped. See scripts/render-diagrams.mjs.
156
+ *
157
+ * Identified by the paths inside them rather than by a marker comment: MDX does
158
+ * not permit HTML comments at all, so `<!-- … -->` is a syntax error rather than
159
+ * a marker.
160
+ *
161
+ * Bare `<img>` is still matched: a page written before the light/dark
162
+ * `<picture>` existed should lose its old block rather than keep it beside the
163
+ * island.
164
+ */
165
+ function referencesGeneratedDiagram(node) {
166
+ if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement')
167
+ return false;
168
+ return (node.attributes ?? []).some((a) => (a.name === 'src' || a.name === 'srcset') &&
169
+ typeof a.value === 'string' &&
170
+ a.value.includes('/diagrams/'));
171
+ }
172
+ function isGeneratedImage(node) {
173
+ if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement')
174
+ return false;
175
+ if (node.name === 'img')
176
+ return referencesGeneratedDiagram(node);
177
+ if (node.name !== 'picture')
178
+ return false;
179
+ return (node.children ?? []).some(referencesGeneratedDiagram);
180
+ }
181
+ function stripRenderedImages(tree) {
182
+ const doomed = [];
183
+ walk(tree, (node, index, parent) => {
184
+ if (parent && index !== null && isGeneratedImage(node))
185
+ doomed.push({ index, parent });
186
+ });
187
+ // Remove back-to-front so earlier indices stay valid.
188
+ for (const { index, parent } of doomed.reverse())
189
+ parent.children?.splice(index, 1);
190
+ }
191
+ export function remarkC4(options = {}) {
192
+ const componentPath = options.component ?? DEFAULT_COMPONENT_PATH;
193
+ return (tree) => {
194
+ stripRenderedImages(tree);
195
+ const replacements = [];
196
+ walk(tree, (node, index, parent) => {
197
+ if (node.type !== 'code' || node.lang !== 'c4' || !parent || index === null)
198
+ return;
199
+ replacements.push({ node, index, parent });
200
+ });
201
+ if (replacements.length === 0)
202
+ return;
203
+ for (const { node, index, parent } of replacements) {
204
+ parent.children[index] = {
205
+ type: 'mdxJsxFlowElement',
206
+ name: COMPONENT,
207
+ attributes: [
208
+ // The drag layer measures the live SVG via getScreenCTM, which does
209
+ // not exist during a server render, so these cannot be hydrated with
210
+ // client:visible.
211
+ { type: 'mdxJsxAttribute', name: 'client:only', value: 'react' },
212
+ expressionAttribute('source', node.value),
213
+ ...parseMeta(node.meta),
214
+ ],
215
+ children: [],
216
+ };
217
+ }
218
+ // One import for the file, regardless of how many diagrams it holds.
219
+ tree.children?.unshift(importNode(componentPath));
220
+ };
221
+ }
222
+ export default remarkC4;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@liminis/diagrams/remark` — turn ```c4 fences into live diagram islands.
3
+ *
4
+ * A build-time remark plugin. It rewrites each fenced `c4` block into a JSX
5
+ * element and injects one import per file, so a markdown page reads as markdown
6
+ * on GitHub and renders as an interactive diagram on a site.
7
+ *
8
+ * It also strips the generated `<picture>` blocks that sit beside those fences
9
+ * for GitHub's benefit: on a site the island renders the same diagram, so
10
+ * showing both would be duplication.
11
+ *
12
+ * import { remarkC4 } from '@liminis/diagrams/remark'
13
+ * export default defineConfig({ markdown: { remarkPlugins: [remarkC4] } })
14
+ *
15
+ * Nothing here imports React, or anything at all beyond the language: it runs
16
+ * in Node during a build, and the package's single runtime dependency is unchanged.
17
+ */
18
+ export { remarkC4, remarkC4 as default } from './remark/remark-c4.js';
19
+ export type { RemarkC4Options, MdastNode } from './remark/remark-c4.js';
package/dist/remark.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@liminis/diagrams/remark` — turn ```c4 fences into live diagram islands.
3
+ *
4
+ * A build-time remark plugin. It rewrites each fenced `c4` block into a JSX
5
+ * element and injects one import per file, so a markdown page reads as markdown
6
+ * on GitHub and renders as an interactive diagram on a site.
7
+ *
8
+ * It also strips the generated `<picture>` blocks that sit beside those fences
9
+ * for GitHub's benefit: on a site the island renders the same diagram, so
10
+ * showing both would be duplication.
11
+ *
12
+ * import { remarkC4 } from '@liminis/diagrams/remark'
13
+ * export default defineConfig({ markdown: { remarkPlugins: [remarkC4] } })
14
+ *
15
+ * Nothing here imports React, or anything at all beyond the language: it runs
16
+ * in Node during a build, and the package's single runtime dependency is unchanged.
17
+ */
18
+ export { remarkC4, remarkC4 as default } from './remark/remark-c4.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liminis/diagrams",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "C4 architecture diagrams: parse C4-PlantUML, lay out with dagre, render to SVG",
5
5
  "license": "MIT",
6
6
  "//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match \u2014 the SSH form does not work.",
@@ -53,6 +53,10 @@
53
53
  "default": "./dist/playground.js"
54
54
  },
55
55
  "./playground.css": "./dist/playground/playground.css",
56
+ "./remark": {
57
+ "types": "./dist/remark.d.ts",
58
+ "default": "./dist/remark.js"
59
+ },
56
60
  "./server": {
57
61
  "types": "./dist/server.d.ts",
58
62
  "default": "./dist/server.js"