@khanglvm/relay 0.2.0 → 0.3.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/README.md +72 -5
- package/docs/AGENT.md +70 -4
- package/docs/assets/annotations.png +0 -0
- package/docs/assets/board-dark.png +0 -0
- package/docs/assets/board-light.png +0 -0
- package/docs/assets/demo.gif +0 -0
- package/docs/assets/mobile.png +0 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +58 -24
- package/skills/relay/examples/blocks-showcase.json +9 -0
- package/src/cli.js +115 -2
- package/src/server.js +50 -11
- package/src/spec.js +28 -3
- package/src/ui/annotate.css +57 -5
- package/src/ui/annotate.js +100 -4
- package/src/ui/app.js +44 -0
- package/src/ui/blocks.css +34 -0
- package/src/ui/blocks.js +136 -0
- package/src/ui/style.css +9 -0
- package/vendor/VERSIONS.json +2 -1
- package/vendor/viz-standalone.js +9 -0
package/src/ui/blocks.js
CHANGED
|
@@ -371,6 +371,20 @@
|
|
|
371
371
|
return mermaidPromise;
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
let vizPromise = null;
|
|
375
|
+
function loadViz() {
|
|
376
|
+
if (window.Viz) return Promise.resolve(window.Viz);
|
|
377
|
+
if (vizPromise) return vizPromise;
|
|
378
|
+
vizPromise = new Promise((resolve, reject) => {
|
|
379
|
+
const s = document.createElement('script');
|
|
380
|
+
s.src = '/vendor/viz-standalone.js';
|
|
381
|
+
s.onload = () => (window.Viz ? resolve(window.Viz) : reject(new Error('Graphviz failed to load')));
|
|
382
|
+
s.onerror = () => reject(new Error('Graphviz failed to load'));
|
|
383
|
+
document.head.appendChild(s);
|
|
384
|
+
});
|
|
385
|
+
return vizPromise;
|
|
386
|
+
}
|
|
387
|
+
|
|
374
388
|
// ---------- chart ----------
|
|
375
389
|
function clampHeight(h, def) {
|
|
376
390
|
const n = Number(h);
|
|
@@ -648,6 +662,120 @@
|
|
|
648
662
|
);
|
|
649
663
|
}
|
|
650
664
|
|
|
665
|
+
// ---------- graphviz (offline, vendored Viz.js -> SVG) ----------
|
|
666
|
+
// Same sizing rule as mermaid: never upscale past the diagram's natural
|
|
667
|
+
// width; shrink on narrow screens. Authors set their own colors so there is
|
|
668
|
+
// no theme re-render.
|
|
669
|
+
function sizeDiagramSvg(svgEl) {
|
|
670
|
+
if (!svgEl) return;
|
|
671
|
+
svgEl.removeAttribute('height');
|
|
672
|
+
svgEl.removeAttribute('width');
|
|
673
|
+
const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
|
|
674
|
+
if (vb && vb.width > 0) {
|
|
675
|
+
svgEl.style.width = '100%';
|
|
676
|
+
svgEl.style.maxWidth = Math.ceil(vb.width) + 'px';
|
|
677
|
+
svgEl.style.height = 'auto';
|
|
678
|
+
} else {
|
|
679
|
+
svgEl.style.maxWidth = '100%';
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function renderGraphviz(block, ctx, blockId) {
|
|
684
|
+
const container = el('div', { class: 'blk-graphviz' });
|
|
685
|
+
loadViz()
|
|
686
|
+
.then((Viz) => Viz.instance())
|
|
687
|
+
.then((viz) => {
|
|
688
|
+
const svgEl = viz.renderSVGElement(block.dot || '');
|
|
689
|
+
sizeDiagramSvg(svgEl);
|
|
690
|
+
container.replaceChildren(svgEl);
|
|
691
|
+
if (!ctx.annotate) return;
|
|
692
|
+
const parts = svgEl.querySelectorAll('g.node, g.edge');
|
|
693
|
+
parts.forEach((g) => {
|
|
694
|
+
const titleEl = g.querySelector('title');
|
|
695
|
+
const nodeId = (g.id || (titleEl && titleEl.textContent) || '').trim();
|
|
696
|
+
// Label text lives in <text> elements; g.textContent would also
|
|
697
|
+
// include the <title> child and duplicate the label.
|
|
698
|
+
const labels = Array.from(g.querySelectorAll('text')).map((t) => t.textContent.trim()).filter(Boolean);
|
|
699
|
+
const text = (labels.join(' ') || (titleEl && titleEl.textContent) || '').trim().slice(0, 120);
|
|
700
|
+
ctx.annotate.register(g, {
|
|
701
|
+
blockId,
|
|
702
|
+
questionId: ctx.questionId,
|
|
703
|
+
target: {
|
|
704
|
+
kind: 'graphviz-node',
|
|
705
|
+
nodeId,
|
|
706
|
+
text,
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
})
|
|
711
|
+
.catch((err) => {
|
|
712
|
+
container.replaceChildren(
|
|
713
|
+
el('div', { class: 'blk-error' }, 'Graphviz error: ' + (err && err.message ? err.message : String(err)))
|
|
714
|
+
);
|
|
715
|
+
});
|
|
716
|
+
return container;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ---------- plantuml (server-rendered; client deflate-raw + base64 variant) ----------
|
|
720
|
+
const PLANTUML_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
|
|
721
|
+
|
|
722
|
+
// PlantUML's base64 variant: 3 bytes -> 4 chars using PLANTUML_ALPHABET.
|
|
723
|
+
function encode64(bytes) {
|
|
724
|
+
let out = '';
|
|
725
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
726
|
+
const b1 = bytes[i];
|
|
727
|
+
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
728
|
+
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
729
|
+
out += PLANTUML_ALPHABET[b1 >> 2];
|
|
730
|
+
out += PLANTUML_ALPHABET[((b1 & 0x3) << 4) | (b2 >> 4)];
|
|
731
|
+
if (i + 1 < bytes.length) out += PLANTUML_ALPHABET[((b2 & 0xf) << 2) | (b3 >> 6)];
|
|
732
|
+
if (i + 2 < bytes.length) out += PLANTUML_ALPHABET[b3 & 0x3f];
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function encodePlantUml(code) {
|
|
738
|
+
if (typeof CompressionStream === 'undefined') {
|
|
739
|
+
throw new Error('CompressionStream unavailable');
|
|
740
|
+
}
|
|
741
|
+
const bytes = new TextEncoder().encode(String(code));
|
|
742
|
+
const blob = new Blob([bytes]);
|
|
743
|
+
const compressed = await new Response(
|
|
744
|
+
blob.stream().pipeThrough(new CompressionStream('deflate-raw'))
|
|
745
|
+
).arrayBuffer();
|
|
746
|
+
return encode64(new Uint8Array(compressed));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderPlantuml(block, ctx, blockId) {
|
|
750
|
+
const container = el('div', { class: 'blk-plantuml' });
|
|
751
|
+
const fail = () =>
|
|
752
|
+
container.replaceChildren(
|
|
753
|
+
el('div', { class: 'blk-error' }, 'PlantUML needs network access and a modern browser')
|
|
754
|
+
);
|
|
755
|
+
encodePlantUml(block.code || '')
|
|
756
|
+
.then((encoded) => {
|
|
757
|
+
const server = block.server || 'https://www.plantuml.com/plantuml';
|
|
758
|
+
const img = el('img', {
|
|
759
|
+
class: 'blk-plantuml-img',
|
|
760
|
+
src: server + '/svg/' + encoded,
|
|
761
|
+
alt: 'PlantUML diagram',
|
|
762
|
+
loading: 'lazy',
|
|
763
|
+
});
|
|
764
|
+
if (block.height) img.style.height = clampHeight(block.height, 360) + 'px';
|
|
765
|
+
img.addEventListener('error', fail);
|
|
766
|
+
container.replaceChildren(img);
|
|
767
|
+
if (ctx.annotate) {
|
|
768
|
+
ctx.annotate.register(img, {
|
|
769
|
+
blockId,
|
|
770
|
+
questionId: ctx.questionId,
|
|
771
|
+
target: { kind: 'image', label: 'PlantUML diagram' },
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
.catch(fail);
|
|
776
|
+
return container;
|
|
777
|
+
}
|
|
778
|
+
|
|
651
779
|
// ---------- html (sandboxed iframe) ----------
|
|
652
780
|
function renderHtml(block, ctx, blockId) {
|
|
653
781
|
const height = clampHeight(block.height, 360);
|
|
@@ -690,6 +818,14 @@
|
|
|
690
818
|
inner = renderMermaid(block, ctx, blockId);
|
|
691
819
|
wrapper.append(inner);
|
|
692
820
|
break;
|
|
821
|
+
case 'graphviz':
|
|
822
|
+
inner = renderGraphviz(block, ctx, blockId);
|
|
823
|
+
wrapper.append(inner);
|
|
824
|
+
break;
|
|
825
|
+
case 'plantuml':
|
|
826
|
+
inner = renderPlantuml(block, ctx, blockId);
|
|
827
|
+
wrapper.append(inner);
|
|
828
|
+
break;
|
|
693
829
|
case 'html':
|
|
694
830
|
inner = renderHtml(block, ctx, blockId);
|
|
695
831
|
wrapper.append(inner);
|
package/src/ui/style.css
CHANGED
|
@@ -206,6 +206,15 @@ textarea { min-height: 90px; resize: vertical; }
|
|
|
206
206
|
z-index: 10; display: none;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/* Live-update toast: flagged after a `rly update` reload (see app.js).
|
|
210
|
+
Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
|
|
211
|
+
.toast {
|
|
212
|
+
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
|
213
|
+
background: var(--accent-soft); color: var(--accent);
|
|
214
|
+
padding: 10px 18px; border-radius: 10px; font-size: 0.9rem;
|
|
215
|
+
box-shadow: var(--shadow-lift); z-index: 50;
|
|
216
|
+
}
|
|
217
|
+
|
|
209
218
|
.done { text-align: center; padding: 80px 18px; }
|
|
210
219
|
.done .mark {
|
|
211
220
|
width: 64px; height: 64px; border-radius: 50%;
|