ci(site): add format/lint/test/build workflow for astro site

Add Site GitHub Actions workflow running prettier format check, astro
check, tests, and build on changes under site/. Wire prettier into the
site package with config, ignore, and scripts, and format existing
sources.
This commit is contained in:
Christian Visintin
2026-06-07 23:10:24 +02:00
parent f92cb93755
commit a2d766d688
19 changed files with 445 additions and 107 deletions

View File

@@ -5,6 +5,7 @@ interface Props {
}
const { text, class: className } = Astro.props;
---
<button
type="button"
class:list={[
@@ -15,11 +16,29 @@ const { text, class: className } = Astro.props;
aria-label="Copy command to clipboard"
title="Copy"
>
<svg class="copy-icon h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<svg
class="copy-icon h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<svg class="check-icon hidden h-4 w-4 text-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<svg
class="check-icon hidden h-4 w-4 text-green"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
</button>
@@ -46,7 +65,9 @@ const { text, class: className } = Astro.props;
};
document.addEventListener("click", (event) => {
const button = (event.target as HTMLElement)?.closest<HTMLElement>(".copy-btn");
const button = (event.target as HTMLElement)?.closest<HTMLElement>(
".copy-btn",
);
if (button) {
handleCopy(button);
}

View File

@@ -17,44 +17,98 @@ const remote = [
];
const status = "↹ switch · ↑↓ move · ↵ open · SPACE transfer";
---
<section class="mx-auto max-w-5xl px-4 pt-12">
<p class="text-green text-sm tracking-widest uppercase">// terminal file transfer</p>
<div class="mt-4 overflow-hidden rounded-lg border border-line bg-mantle shadow-2xl">
<p class="text-green text-sm tracking-widest uppercase">
// terminal file transfer
</p>
<div
class="mt-4 overflow-hidden rounded-lg border border-line bg-mantle shadow-2xl"
>
<div class="border-b border-line bg-crust px-3 py-2 text-xs text-overlay">
termscp — 192.168.1.10 (sftp) connected<span class="blink ml-1"></span>
</div>
<div class="grid grid-cols-2 bg-base">
<div>
<div class="border-b border-line bg-mantle px-3 py-1.5 text-xs text-teal">/home/veeso (local)</div>
{local.map((r) => (
<div class={`flex justify-between gap-2 px-3 py-0.5 text-sm ${r.kind === "dir" ? "text-blue" : "text-text"}`}>
<span class="truncate min-w-0">{r.name}</span><span class="text-overlay shrink-0">{r.size}</span>
</div>
))}
<div
class="border-b border-line bg-mantle px-3 py-1.5 text-xs text-teal"
>
/home/veeso (local)
</div>
{
local.map((r) => (
<div
class={`flex justify-between gap-2 px-3 py-0.5 text-sm ${r.kind === "dir" ? "text-blue" : "text-text"}`}
>
<>
<span class="truncate min-w-0">{r.name}</span>
<span class="text-overlay shrink-0">{r.size}</span>
</>
</div>
))
}
</div>
<div class="border-l border-line">
<div class="border-b border-line bg-mantle px-3 py-1.5 text-xs text-mauve">/var/www (remote)</div>
{remote.map((r) => (
<div class={`flex justify-between gap-2 px-3 py-0.5 text-sm ${
r.kind === "sel" ? "bg-blue text-base font-bold" : r.kind === "dir" ? "text-blue" : "text-text"
}`}>
<span class="truncate min-w-0">{r.name}</span>
<span class={`shrink-0 ${r.kind === "sel" ? "text-base" : "text-overlay"}`}>{r.size}</span>
</div>
))}
<div
class="border-b border-line bg-mantle px-3 py-1.5 text-xs text-mauve"
>
/var/www (remote)
</div>
{
remote.map((r) => (
<div
class={`flex justify-between gap-2 px-3 py-0.5 text-sm ${
r.kind === "sel"
? "bg-blue text-base font-bold"
: r.kind === "dir"
? "text-blue"
: "text-text"
}`}
>
<span class="truncate min-w-0">{r.name}</span>
<span
class={`shrink-0 ${r.kind === "sel" ? "text-base" : "text-overlay"}`}
>
{r.size}
</span>
</div>
))
}
</div>
</div>
{/* set:html is safe: status is a static author-controlled string, never user input. */}
<div class="flex justify-between border-t border-line bg-crust px-3 py-1.5 text-xs text-overlay">
<span set:html={status.replace(/(↹|↑↓|↵|SPACE)/g, '<span class="text-yellow">$1</span>')}></span>
{
/* set:html is safe: status is a static author-controlled string, never user input. */
}
<div
class="flex justify-between border-t border-line bg-crust px-3 py-1.5 text-xs text-overlay"
>
<span
set:html={status.replace(
/(↹|↑↓|↵|SPACE)/g,
'<span class="text-yellow">$1</span>',
)}
/>
<span>4 files · 1.2 GB</span>
</div>
<div class="bg-base px-6 py-6 text-center">
<h1 class="text-2xl font-bold text-text">The dual-pane explorer for your terminal</h1>
<p class="mx-auto mt-2 max-w-xl text-subtext">Local left, remote right. Transfer, sync, edit — never leave the keyboard.</p>
<h1 class="text-2xl font-bold text-text">
The dual-pane explorer for your terminal
</h1>
<p class="mx-auto mt-2 max-w-xl text-subtext">
Local left, remote right. Transfer, sync, edit — never leave the
keyboard.
</p>
<div class="mt-5 flex flex-wrap justify-center gap-3">
<a href="/install" class="rounded-lg bg-green px-5 py-2.5 font-bold text-crust">Install termscp →</a>
<a href={DOCS_URL} class="rounded-lg border border-line px-5 py-2.5 text-text">User manual</a>
<a
href="/install"
class="rounded-lg bg-green px-5 py-2.5 font-bold text-crust"
>Install termscp →</a
>
<a
href={DOCS_URL}
class="rounded-lg border border-line px-5 py-2.5 text-text"
>User manual</a
>
</div>
</div>
</div>

View File

@@ -1,7 +1,11 @@
---
interface Props { title: string; body: string; }
interface Props {
title: string;
body: string;
}
const { title, body } = Astro.props;
---
<div class="rounded-lg border border-line bg-mantle p-5">
<h3 class="text-green">{title}</h3>
<p class="mt-2 text-sm text-subtext">{body}</p>

View File

@@ -2,13 +2,20 @@
import { DOCS_URL, GITHUB_URL, VERSION } from "../consts";
const year = new Date().getFullYear();
---
<footer class="mt-auto border-t border-line bg-mantle">
<div class="mx-auto flex max-w-5xl flex-col items-center gap-2 px-4 py-8 text-sm text-overlay">
<div
class="mx-auto flex max-w-5xl flex-col items-center gap-2 px-4 py-8 text-sm text-overlay"
>
<div class="flex gap-5">
<a href={GITHUB_URL} class="hover:text-text">GitHub</a>
<a href="https://crates.io/crates/termscp" class="hover:text-text">crates.io</a>
<a href="https://crates.io/crates/termscp" class="hover:text-text"
>crates.io</a
>
<a href={DOCS_URL} class="hover:text-text">User manual</a>
</div>
<p>termscp v{VERSION} · © {year} Christian Visintin · Released under the MIT license.</p>
<p>
termscp v{VERSION} · © {year} Christian Visintin · Released under the MIT license.
</p>
</div>
</footer>

View File

@@ -1,35 +1,52 @@
---
import CopyButton from "./CopyButton.astro";
interface Method { id: string; label: string; cmd: string; }
interface Props { methods: Method[]; }
interface Method {
id: string;
label: string;
cmd: string;
}
interface Props {
methods: Method[];
}
const { methods } = Astro.props;
---
<div class="rounded-lg border border-line bg-mantle">
<div class="flex flex-wrap gap-1 border-b border-line p-2" role="tablist">
{methods.map((m, i) => (
<button
class="install-tab rounded px-3 py-1 text-sm text-overlay data-[active]:bg-base data-[active]:text-green"
data-target={m.id}
data-active={i === 0 ? "" : undefined}
>{m.label}</button>
))}
{
methods.map((m, i) => (
<button
class="install-tab rounded px-3 py-1 text-sm text-overlay data-[active]:bg-base data-[active]:text-green"
data-target={m.id}
data-active={i === 0 ? "" : undefined}
>
{m.label}
</button>
))
}
</div>
{methods.map((m, i) => (
<div class="install-panel relative" data-id={m.id} hidden={i !== 0}>
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{m.cmd}</code></pre>
<CopyButton text={m.cmd} class="absolute right-2 top-2" />
</div>
))}
{
methods.map((m, i) => (
<div class="install-panel relative" data-id={m.id} hidden={i !== 0}>
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text">
<code>{m.cmd}</code>
</pre>
<CopyButton text={m.cmd} class="absolute right-2 top-2" />
</div>
))
}
</div>
<script is:inline>
document.querySelectorAll(".install-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const id = tab.getAttribute("data-target");
document.querySelectorAll(".install-tab").forEach((t) =>
t.toggleAttribute("data-active", t === tab));
document.querySelectorAll(".install-panel").forEach((p) =>
(p.hidden = p.getAttribute("data-id") !== id));
document
.querySelectorAll(".install-tab")
.forEach((t) => t.toggleAttribute("data-active", t === tab));
document
.querySelectorAll(".install-panel")
.forEach((p) => (p.hidden = p.getAttribute("data-id") !== id));
});
});
</script>

View File

@@ -2,7 +2,10 @@
import ThemeToggle from "./ThemeToggle.astro";
import { DOCS_URL, GITHUB_URL } from "../consts";
---
<header class="sticky top-0 z-50 border-b border-line bg-mantle/90 backdrop-blur">
<header
class="sticky top-0 z-50 border-b border-line bg-mantle/90 backdrop-blur"
>
<nav class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
<a href="/" class="flex items-center gap-2 text-text">
<img src="/assets/images/termscp.webp" alt="termscp" class="h-7 w-7" />
@@ -19,8 +22,13 @@ import { DOCS_URL, GITHUB_URL } from "../consts";
<!-- Mobile -->
<details class="relative sm:hidden">
<summary class="cursor-pointer list-none rounded p-2 text-text hover:bg-mantle" aria-label="Menu">☰</summary>
<div class="absolute right-0 mt-2 flex w-48 flex-col gap-3 rounded-lg border border-line bg-mantle p-4 text-sm shadow-xl">
<summary
class="cursor-pointer list-none rounded p-2 text-text hover:bg-mantle"
aria-label="Menu">☰</summary
>
<div
class="absolute right-0 mt-2 flex w-48 flex-col gap-3 rounded-lg border border-line bg-mantle p-4 text-sm shadow-xl"
>
<a href="/install" class="text-overlay hover:text-text">Install</a>
<a href={DOCS_URL} class="text-overlay hover:text-text">User manual</a>
<a href={GITHUB_URL} class="text-overlay hover:text-text">GitHub</a>

View File

@@ -1,11 +1,18 @@
---
const protocols = ["SCP", "SFTP", "FTP/S", "S3", "Kube", "SMB", "WebDAV"];
---
<section class="mx-auto max-w-5xl px-4 py-12 text-center">
<h2 class="text-overlay text-sm uppercase tracking-widest">Speaks every protocol</h2>
<h2 class="text-overlay text-sm uppercase tracking-widest">
Speaks every protocol
</h2>
<div class="mt-4 flex flex-wrap justify-center gap-2">
{protocols.map((proto) => (
<span class="rounded-full border border-line px-4 py-1 text-sm text-text">{proto}</span>
))}
{
protocols.map((proto) => (
<span class="rounded-full border border-line px-4 py-1 text-sm text-text">
{proto}
</span>
))
}
</div>
</section>

View File

@@ -1,6 +1,7 @@
---
// Theme toggle. Contract: localStorage 'theme' = "frappe" | "latte". Safe to render multiple times.
---
<button
type="button"
data-theme-toggle
@@ -17,15 +18,22 @@
window.__themeToggleInit = true;
const root = document.documentElement;
const btns = document.querySelectorAll("[data-theme-toggle]");
const setPressed = () => btns.forEach((b) =>
b.setAttribute("aria-pressed", String(root.getAttribute("data-theme") === "latte")));
const setPressed = () =>
btns.forEach((b) =>
b.setAttribute(
"aria-pressed",
String(root.getAttribute("data-theme") === "latte"),
),
);
btns.forEach((b) =>
b.addEventListener("click", () => {
const next = root.getAttribute("data-theme") === "frappe" ? "latte" : "frappe";
const next =
root.getAttribute("data-theme") === "frappe" ? "latte" : "frappe";
root.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
setPressed();
}));
}),
);
setPressed();
})();
</script>

View File

@@ -30,7 +30,10 @@ const ogImage = new URL("/assets/images/og_preview.jpg", site).href;
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:alt" content="termscp — terminal file transfer and explorer" />
<meta
property="og:image:alt"
content="termscp — terminal file transfer and explorer"
/>
<meta property="og:image:width" content="1280" />
<meta property="og:image:height" content="640" />
@@ -41,24 +44,51 @@ const ogImage = new URL("/assets/images/og_preview.jpg", site).href;
<meta name="twitter:image" content={ogImage} />
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="96x96" href="/assets/images/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png" />
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/assets/images/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/images/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/images/favicon-16x16.png"
/>
<!-- Font preload -->
<link rel="preload" href="/fonts/jetbrains-mono-400.woff2" as="font" type="font/woff2" crossorigin />
<link
rel="preload"
href="/fonts/jetbrains-mono-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- No-flash theme bootstrap. Contract: localStorage 'theme' is "frappe" | "latte"; ThemeToggle writes the same. -->
<script is:inline>
(() => {
const t = localStorage.getItem("theme") ||
(matchMedia("(prefers-color-scheme: light)").matches ? "latte" : "frappe");
const t =
localStorage.getItem("theme") ||
(matchMedia("(prefers-color-scheme: light)").matches
? "latte"
: "frappe");
document.documentElement.setAttribute("data-theme", t);
})();
</script>
<!-- Umami analytics -->
<script defer src="https://cloud.umami.is/script.js" data-website-id="ad2d5621-a893-488c-baa6-d39c306374cf"></script>
<script
defer
src="https://cloud.umami.is/script.js"
data-website-id="ad2d5621-a893-488c-baa6-d39c306374cf"></script>
</head>
<body class="bg-base text-text font-mono min-h-screen flex flex-col">
<slot />

View File

@@ -7,14 +7,30 @@ import ProtocolStrip from "../components/ProtocolStrip.astro";
import FeatureCard from "../components/FeatureCard.astro";
const features = [
{ title: "Handy UI", body: "Explore and operate on the remote and local file system with a handy UI." },
{
title: "Handy UI",
body: "Explore and operate on the remote and local file system with a handy UI.",
},
{ title: "Cross platform", body: "Runs on Windows, macOS, Linux and BSD." },
{ title: "Customizable", body: "Customize the explorer, the text editor and default options." },
{ title: "Bookmarks", body: "Connect to favourite hosts via bookmarks and recent connections." },
{ title: "Security first", body: "Store passwords in your operating system key vault." },
{ title: "Eye on performance", body: "Built with an eye on performance to keep CPU usage low." },
{
title: "Customizable",
body: "Customize the explorer, the text editor and default options.",
},
{
title: "Bookmarks",
body: "Connect to favourite hosts via bookmarks and recent connections.",
},
{
title: "Security first",
body: "Store passwords in your operating system key vault.",
},
{
title: "Eye on performance",
body: "Built with an eye on performance to keep CPU usage low.",
},
];
---
<Base
title="termscp — terminal file transfer & explorer for SCP/SFTP/FTP/S3/Kube/SMB/WebDAV"
description="A feature-rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV/SMB."
@@ -26,9 +42,7 @@ const features = [
<section class="mx-auto max-w-5xl px-4 py-12">
<h2 class="mb-6 text-center text-xl text-text">Why termscp</h2>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{features.map((f) => (
<FeatureCard title={f.title} body={f.body} />
))}
{features.map((f) => <FeatureCard title={f.title} body={f.body} />)}
</div>
</section>
<ProtocolStrip />

View File

@@ -6,7 +6,8 @@ import InstallTabs from "../components/InstallTabs.astro";
import CopyButton from "../components/CopyButton.astro";
import { DOCS_URL } from "../consts";
const script = "curl --proto '=https' --tlsv1.2 -sSLf https://termscp.rs/install.sh | sh";
const script =
"curl --proto '=https' --tlsv1.2 -sSLf https://termscp.rs/install.sh | sh";
const psScript = "irm https://termscp.rs/install.ps1 | iex";
const methods = [
@@ -16,6 +17,7 @@ const methods = [
{ id: "choco", label: "Windows (Chocolatey)", cmd: "choco install termscp" },
];
---
<Base
title="Install termscp"
description="Install termscp on Linux, macOS, BSD and Windows — one shell command, or via Cargo, pacman, pkgin and Chocolatey."
@@ -24,37 +26,58 @@ const methods = [
<Nav />
<main class="mx-auto w-full max-w-3xl flex-1 px-4 py-12">
<h1 class="text-2xl font-bold text-text">Install termscp</h1>
<p class="mt-2 text-subtext">Pick your platform. One command and you're in.</p>
<p class="mt-2 text-subtext">
Pick your platform. One command and you're in.
</p>
<div class="mt-6 rounded-lg border border-line bg-mantle p-1">
<div class="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-overlay">
<div
class="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-overlay"
>
<span class="text-green">$</span> Linux · macOS · BSD
</div>
<div class="relative">
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{script}</code></pre>
<pre
class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{script}</code></pre>
<CopyButton text={script} class="absolute right-2 top-2" />
</div>
</div>
<p class="mt-2 text-xs text-overlay">Requires <span class="text-text">curl</span>. macOS uses Homebrew if available, otherwise builds from source.</p>
<p class="mt-2 text-xs text-overlay">
Requires <span class="text-text">curl</span>. macOS uses Homebrew if
available, otherwise builds from source.
</p>
<div class="mt-6 rounded-lg border border-line bg-mantle p-1">
<div class="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-overlay">
<div
class="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-overlay"
>
<span class="text-green">&gt;</span> Windows (PowerShell)
</div>
<div class="relative">
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{psScript}</code></pre>
<pre
class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{psScript}</code></pre>
<CopyButton text={psScript} class="absolute right-2 top-2" />
</div>
</div>
<p class="mt-2 text-xs text-overlay">Downloads the latest release and adds <span class="text-text">termscp</span> to your user PATH.</p>
<p class="mt-2 text-xs text-overlay">
Downloads the latest release and adds <span class="text-text"
>termscp</span
> to your user PATH.
</p>
<h2 class="mt-10 mb-3 text-lg text-text">Package managers</h2>
<InstallTabs methods={methods} />
<p class="mt-8 text-sm text-overlay">Update anytime with <span class="text-green">termscp --update</span>.</p>
<p class="mt-8 text-sm text-overlay">
Update anytime with <span class="text-green">termscp --update</span>.
</p>
<p class="mt-2 text-sm text-overlay">
Linux build deps: <span class="text-text">libdbus-1</span>, <span class="text-text">pkg-config</span>, <span class="text-text">libsmbclient</span>.
More details in the <a href={DOCS_URL} class="text-blue hover:underline">docs</a>.
Linux build deps: <span class="text-text">libdbus-1</span>, <span
class="text-text">pkg-config</span
>, <span class="text-text">libsmbclient</span>. More details in the <a
href={DOCS_URL}
class="text-blue hover:underline">docs</a
>.
</p>
</main>
<Footer />

View File

@@ -80,9 +80,15 @@ html {
}
/* Theme toggle icons: shown based on active theme, set before paint by the head bootstrap */
[data-icon] { display: none; }
[data-theme="frappe"] [data-icon="frappe"] { display: inline; }
[data-theme="latte"] [data-icon="latte"] { display: inline; }
[data-icon] {
display: none;
}
[data-theme="frappe"] [data-icon="frappe"] {
display: inline;
}
[data-theme="latte"] [data-icon="latte"] {
display: inline;
}
.blink {
display: inline-block;
@@ -92,4 +98,8 @@ html {
vertical-align: -1px;
animation: blink 1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
@keyframes blink {
50% {
opacity: 0;
}
}