Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
Expand Down
11 changes: 8 additions & 3 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default defineConfig({
title: "Plinth",
description:
"Open-source platform foundation for enterprise teams running fleets of internal-tooling modules.",
// Wordmark only — Plinth in Spectral Medium reads as a logo of its own.
// Wordmark only — "Plinth" in IBM Plex Sans reads as a logo of its own.
// (No image logo file; Starlight falls back to the title text.)
favicon: "/favicon.svg",
social: [
Expand All @@ -26,9 +26,14 @@ export default defineConfig({
baseUrl: "https://github.com/plinth-dev/docs/edit/main/",
},
lastUpdated: true,
// OG image lands in Phase F — until then no og:image meta is set
// (better than a 404'd image). Re-enable when /og.png ships.
// og:image lands with the default OG card in Wave 4 once huashu-design
// produces /og-default.png. Until then no og:image is set (better than
// a 404'd image).
head: [
{
tag: "meta",
attrs: { name: "theme-color", content: "#FBFBF9" },
},
{
tag: "link",
attrs: { rel: "preconnect", href: "https://fonts.googleapis.com" },
Expand Down
134 changes: 98 additions & 36 deletions public/tools/sketch-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,26 @@
overflow: hidden;
}
.toolbar {
height: var(--toolbar-h);
min-height: var(--toolbar-h);
background: var(--bg-soft);
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
padding: 6px 16px;
flex-wrap: wrap;
}
.toolbar .group { display: flex; align-items: center; gap: 6px; }
.toolbar .group + .group {
margin-left: 8px;
padding-left: 16px;
border-left: 1px solid var(--rule-firm);
}
@media (max-width: 56rem) {
.toolbar .group + .group {
margin-left: 0; padding-left: 0; border-left: none;
}
}
.toolbar .lbl {
font-family: var(--sans);
font-size: 11px; font-weight: 600;
Expand Down Expand Up @@ -80,13 +86,19 @@
}
.toolbar .status .ok { color: var(--accent); }
.toolbar .status .err { color: #B43A3A; }
.toolbar .status .ok::before { content: "●"; margin-right: 4px; }
.toolbar .status .err::before { content: "✕"; margin-right: 4px; font-weight: 700; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
.editor {
display: grid;
grid-template-columns: 480px 1fr;
height: calc(100vh - var(--toolbar-h));
}
@media (max-width: 56rem) {
.editor { grid-template-columns: 1fr; }
.editor { grid-template-columns: 1fr; height: auto; }
.pane-edit textarea { min-height: 280px; }
}
.pane-edit {
Expand Down Expand Up @@ -139,9 +151,11 @@
</head>
<body>

<div class="toolbar">
<h1 class="visually-hidden">Plinth Sketch — DSL editor for architecture diagrams</h1>

<div class="toolbar" role="toolbar" aria-label="Sketch tool actions">
<div class="group">
<span class="lbl">Example</span>
<label class="lbl" for="example">Example</label>
<select id="example">
<option value="plinth">Plinth substrate (this site)</option>
<option value="threetier">Three-tier web app</option>
Expand All @@ -153,15 +167,15 @@
<button id="download-svg" class="primary">Download SVG</button>
<button id="copy-link">Copy share link</button>
</div>
<span class="status" id="status"><span class="ok"></span> ready</span>
<span class="status" id="status" role="status" aria-live="polite" aria-atomic="true"><span class="ok">ready</span></span>
</div>

<div class="editor">
<main class="editor">
<section class="pane-edit" aria-label="DSL editor">
<header class="header">
Diagram source <span class="meta" id="lines">0 lines</span>
</header>
<textarea id="src" spellcheck="false" autocomplete="off" autocorrect="off" aria-label="Diagram source DSL"></textarea>
<textarea id="src" spellcheck="false" autocomplete="off" autocorrect="off" aria-label="Diagram source DSL" autofocus></textarea>
</section>
<section class="pane-preview" aria-label="SVG preview">
<header class="header">
Expand All @@ -171,9 +185,11 @@
<span class="empty">Type something on the left to see a diagram.</span>
</div>
</section>
</div>
</main>

<script type="module">
// Pinned to a specific version so a server-side change doesn't surprise the
// in-browser tool. Bump explicitly when @plinth-dev/sketch ships a new tag.
import { parse, layout, render } from "https://esm.sh/@plinth-dev/sketch@0.1.0";

const EXAMPLES = {
Expand Down Expand Up @@ -235,13 +251,7 @@
api -> redis
api -> s3
`,
empty: `# Start typing.
# Define nodes first, then group them into layers, then add edges.

# @layer My layer
# id : Label / optional sublabel
# id_a -> id_b : edge label
`,
empty: ``,
};

const $ = (s) => document.querySelector(s);
Expand All @@ -254,36 +264,63 @@

let lastSVG = '';

function setStatus(text, isErr) {
// Replace child nodes (not innerHTML) so SR doesn't over-announce.
const span = document.createElement('span');
span.className = isErr ? 'err' : 'ok';
span.textContent = text;
statusEl.replaceChildren(span);
}

function placeholder(msg) {
canvasEl.replaceChildren();
const s = document.createElement('span');
s.className = 'empty';
s.textContent = msg;
canvasEl.appendChild(s);
}

function update() {
const text = srcEl.value;
linesEl.textContent = `${text.split('\n').length} lines`;
if (!text.trim()) {
canvasEl.innerHTML = '<span class="empty">Type something on the left to see a diagram.</span>';
placeholder('Type something on the left to see a diagram.');
metaEl.textContent = '0 nodes · 0 edges';
statusEl.innerHTML = '<span class="ok">●</span> ready';
setStatus('ready', false);
lastSVG = '';
return;
}
try {
const parsed = parse(text);
metaEl.textContent = `${parsed.nodeOrder.length} nodes · ${parsed.edges.length} edges`;
if (parsed.nodeOrder.length === 0) {
placeholder('Define a node first — e.g. `web : Web tier`.');
lastSVG = '';
if (parsed.errors.length) {
setStatus(`${parsed.errors.length} unparsed line${parsed.errors.length === 1 ? '' : 's'}`, true);
} else {
setStatus('ready', false);
}
return;
}
const lay = layout(parsed);
const svg = render(parsed, lay);
canvasEl.innerHTML = svg;
lastSVG = svg;
metaEl.textContent = `${parsed.nodeOrder.length} nodes · ${parsed.edges.length} edges`;
const missing = new Set();
for (const e of parsed.edges) {
if (!parsed.nodes[e.from]) missing.add(e.from);
if (!parsed.nodes[e.to]) missing.add(e.to);
}
if (parsed.errors.length) {
statusEl.innerHTML = `<span class="err">●</span> ${parsed.errors.length} unparsed line${parsed.errors.length === 1 ? '' : 's'}`;
setStatus(`${parsed.errors.length} unparsed line${parsed.errors.length === 1 ? '' : 's'}`, true);
} else if (missing.size) {
statusEl.innerHTML = `<span class="err">●</span> undefined node${missing.size === 1 ? '' : 's'}: ${[...missing].slice(0, 3).join(', ')}`;
setStatus(`undefined node${missing.size === 1 ? '' : 's'}: ${[...missing].slice(0, 3).join(', ')}`, true);
} else {
statusEl.innerHTML = '<span class="ok">●</span> ok';
setStatus('ok', false);
}
} catch (err) {
statusEl.innerHTML = '<span class="err">●</span> ' + err.message;
setStatus(err.message, true);
}
}

Expand All @@ -297,11 +334,24 @@
srcEl.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(update, 80); });

function flashStatus(msg, isErr) {
const before = statusEl.innerHTML;
statusEl.innerHTML = (isErr ? '<span class="err">●</span> ' : '<span class="ok">●</span> ') + msg;
setTimeout(() => { statusEl.innerHTML = before; }, 1500);
const before = statusEl.cloneNode(true).childNodes;
setStatus(msg, !!isErr);
setTimeout(() => { statusEl.replaceChildren(...before); }, 1500);
}

// Convert byte array → base64 in 8K chunks so we don't blow the
// String.fromCharCode call-stack limit on large DSLs.
function bytesToBase64(bytes) {
const CHUNK = 8192;
let s = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
s += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(s);
}

const MAX_DSL_BYTES = 64 * 1024; // 64 KB cap on share-links / hash decodes

$('#download-svg').addEventListener('click', () => {
if (!lastSVG) return;
const blob = new Blob([lastSVG], { type: 'image/svg+xml' });
Expand All @@ -318,24 +368,36 @@
});

$('#copy-link').addEventListener('click', async () => {
const enc = btoa(String.fromCharCode(...new TextEncoder().encode(srcEl.value)));
// Build the canonical /tools/sketch/ URL even when iframed
const bytes = new TextEncoder().encode(srcEl.value);
if (bytes.length > MAX_DSL_BYTES) {
flashStatus('DSL too large to share-link', true);
return;
}
const enc = bytesToBase64(bytes);
// Build the canonical /tools/sketch/ URL even when iframed.
const base = 'https://plinth.run/tools/sketch/';
const url = base + '#d=' + enc;
try { await navigator.clipboard.writeText(url); flashStatus('share link copied'); }
catch (e) { flashStatus('copy failed', true); }
});

function loadFromHash() {
if (location.hash.startsWith('#d=')) {
try {
const bytes = Uint8Array.from(atob(location.hash.substring(3)), (c) => c.charCodeAt(0));
srcEl.value = new TextDecoder().decode(bytes);
update();
return true;
} catch (e) {}
if (!location.hash.startsWith('#d=')) return false;
const data = location.hash.substring(3);
// base64 expands ~4/3, so a 64KB DSL → ~85KB hash; reject larger.
if (data.length > MAX_DSL_BYTES * 2) {
flashStatus('share link malformed (too large)', true);
return false;
}
try {
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
srcEl.value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
update();
return true;
} catch (e) {
flashStatus('share link malformed', true);
return false;
}
return false;
}

if (!loadFromHash()) loadExample('plinth');
Expand Down
2 changes: 1 addition & 1 deletion src/assets/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
:root {
--sl-font: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif;
--sl-font-system: var(--sl-font);
--sl-font-system-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--sl-font-system-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;

--sl-content-width: 60rem;
--sl-line-height: 1.55;
Expand Down
59 changes: 50 additions & 9 deletions src/components/IFrameTool.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
---
// Embeds an interactive tool (an HTML file in /public/) inside a Starlight page.
// The iframe takes the full content width and a tall fixed height; on small
// viewports we collapse the height so it remains usable.
// Same-origin; we don't add `sandbox=` because allow-scripts + allow-same-origin
// together negates the sandbox (and Chromium warns about it). If we ever host
// tools on a separate origin, drop allow-same-origin and add a real sandbox.
interface Props {
src: string;
title: string;
Expand All @@ -16,18 +17,58 @@ const { src, title, height = "min(78vh, 760px)" } = Astro.props as Props;
title={title}
style={`height: ${height}`}
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-downloads"
></iframe>
</div>

<script is:inline>
// (1) Forward the parent's URL hash into the iframe so /tools/sketch/#d=<b64>
// share links route the encoded DSL into the embedded tool.
// (2) Full-bleed the iframe by measuring the parent's left offset on each
// resize. CSS-only `100vw + calc(50% - 50vw)` misaligns when the parent
// is left-anchored (Starlight splash template), so we measure instead.
(function () {
function applyHash() {
if (!location.hash) return;
const f = document.querySelector(".iframe-tool iframe");
if (!f) return;
const u = new URL(f.getAttribute("src"), location.origin);
u.hash = location.hash;
f.setAttribute("src", u.toString());
}
function applyBleed() {
const els = document.querySelectorAll(".iframe-tool");
els.forEach(function (el) {
// Reset first so the measurement reflects the un-bled position.
el.style.marginLeft = "";
el.style.marginRight = "";
el.style.width = "";
const r = el.getBoundingClientRect();
const left = r.left;
const right = window.innerWidth - r.right;
el.style.marginLeft = -left + "px";
el.style.marginRight = -right + "px";
el.style.width = window.innerWidth + "px";
});
}
function init() {
applyHash();
applyBleed();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
window.addEventListener("resize", applyBleed);
})();
</script>

<style>
/* Break the iframe out of Starlight's content max-width so the tool gets a
wider area. Standard "full bleed" technique: width 100vw + negative
margin-left/right that compensates for the parent's offset. */
/* Default to content width; the inline script widens to viewport on
load + resize. (No CSS-only trick survives Starlight's splash template
which anchors content to a left column rather than centering it.) */
.iframe-tool {
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
width: 100%;
margin-top: 2.5rem;
margin-bottom: 2.5rem;
background: var(--sl-color-bg);
Expand Down
Loading