diff --git a/.github/workflows/validate-data.yml b/.github/workflows/validate-data.yml
new file mode 100644
index 0000000..51ff214
--- /dev/null
+++ b/.github/workflows/validate-data.yml
@@ -0,0 +1,39 @@
+name: Validate links data
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "data/links.json"
+ - "data/links.example.json"
+ - "data/links.schema.json"
+ - ".github/workflows/validate-data.yml"
+ pull_request:
+ paths:
+ - "data/links.json"
+ - "data/links.example.json"
+ - "data/links.schema.json"
+ - ".github/workflows/validate-data.yml"
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: JSON syntax (jq)
+ run: |
+ set -e
+ for f in data/links.json data/links.example.json data/links.schema.json; do
+ echo "Checking $f"
+ jq empty "$f"
+ done
+
+ - name: Schema validation (ajv)
+ run: |
+ npx --yes ajv-cli@5 validate \
+ --spec=draft2020 \
+ --strict=false \
+ -s data/links.schema.json \
+ -d data/links.json \
+ -d data/links.example.json
diff --git a/README.md b/README.md
index 29fac79..e5d4dee 100644
--- a/README.md
+++ b/README.md
@@ -187,16 +187,18 @@ Bigger, prose-friendly card with tags.
"type": "card",
"title": "CoVote",
"url": "https://covote.org",
+ "repo": "https://github.com/covote/covote", // optional — adds a git icon in the corner
"description": "Anonymous election platform for co-ops.",
"tags": ["democracy", "co-ops"],
- "featured": true // optional — makes it the hero card
+ "featured": true // optional — makes it the hero card
}
```
| field | required? | notes |
|---------------|-----------|-------|
| `title` | yes | Serif headline (Fraunces in editorial; Archivo 900 caps in Swiss). |
-| `url` | no | If present, the card becomes a link. |
+| `url` | no | If present, the title becomes a link. |
+| `repo` | no | Optional secondary link to the project's source repository. Renders a small git icon in the top-right corner. Supports `github.com`, `bitbucket.org`, and Gitea/self-hosted URLs. When set, the card is no longer wrapped in a single anchor — the title is the primary link, the icon is the secondary. |
| `description` | no | Paragraph under the title. |
| `tags` | no | Array of strings — small monospaced chips. |
| `featured` | no | When `true`, card spans 8/12 columns AND fills with the accent color (paper text on vermilion). Use sparingly — one per section. |
diff --git a/assets/css/styles.css b/assets/css/styles.css
index 08484c7..14a7fc9 100644
--- a/assets/css/styles.css
+++ b/assets/css/styles.css
@@ -345,8 +345,8 @@ main > .section:first-child { margin-top: var(--space-3xl); }
.card--link .favicon img { width: 100%; height: 100%; object-fit: cover; }
.card--link .favicon img.is-favicon { width: 44px; height: 44px; object-fit: contain; }
.card--link .favicon[data-fallback] { font-family: var(--display); font-weight: 500; font-size: 1.7rem; color: var(--ink); }
-.card__title { font-weight: 540; letter-spacing: -0.005em; line-height: 1.25; }
-.card__desc { color: var(--muted); font-size: var(--fs-sm); }
+.card__title { display: block; font-weight: 540; letter-spacing: -0.005em; line-height: 1.25; }
+.card__desc { display: block; color: var(--muted); font-size: var(--fs-sm); }
.card__host { font-family: var(--mono); font-size: var(--fs-mini); color: var(--muted); text-transform: lowercase; }
.card__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem 0.8rem; margin-top: 0.2rem; }
.card__date { font-family: var(--mono); font-size: var(--fs-mini); color: var(--muted); letter-spacing: 0.02em; text-transform: uppercase; }
@@ -365,6 +365,35 @@ main > .section:first-child { margin-top: var(--space-3xl); }
letter-spacing: -0.02em;
line-height: 1.05;
}
+.card__title--link { color: inherit; text-decoration: none; }
+.card__title--link:hover { color: var(--accent); }
+
+/* optional repo link (github/bitbucket/gitea) */
+.card--has-repo::after { content: none; }
+.card__repo {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: currentColor;
+ opacity: 0.55;
+ transition: opacity 180ms var(--ease), background-color 180ms var(--ease), color 180ms var(--ease), transform 220ms var(--ease-strong);
+ z-index: 2;
+}
+.card__repo svg { width: 18px; height: 18px; display: block; }
+.card__repo:hover {
+ opacity: 1;
+ color: var(--accent);
+ background: color-mix(in oklch, var(--accent) 10%, transparent);
+ transform: translateY(-1px);
+}
+.card--featured .card__repo { color: color-mix(in oklch, var(--on-accent) 75%, transparent); opacity: 0.85; top: 1.25rem; right: 1.5rem; }
+.card--featured .card__repo:hover { color: var(--on-accent); background: color-mix(in oklch, var(--on-accent) 16%, transparent); }
/* FEATURED — accent-filled hero card */
.card--featured {
@@ -711,6 +740,13 @@ main > .section:first-child { margin-top: var(--space-3xl); }
line-height: 1;
}
.foot__right { text-align: right; }
+.foot__edit {
+ margin-left: 0.6em;
+ color: var(--muted);
+ border-bottom: 1px dotted color-mix(in oklch, var(--muted) 50%, transparent);
+ transition: color 180ms var(--ease), border-color 180ms var(--ease);
+}
+.foot__edit:hover { color: var(--accent); border-bottom-color: var(--accent); }
/* reveal */
.reveal { opacity: 0; transform: translateY(16px); transition: opacity 700ms var(--ease-strong), transform 700ms var(--ease-strong); }
diff --git a/assets/js/app.js b/assets/js/app.js
index 8cce617..cc76f19 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -48,9 +48,23 @@
function renderProject(it) {
const featured = it.featured ? " card--featured" : "";
+ const tags = (it.tags || []).map(t => `${esc(t)}`).join("");
+ const repo = repoLinkMarkup(it.repo);
+
+ if (repo) {
+ const titleTag = it.url ? "a" : "span";
+ const titleAttrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
+ return `
+
+ ${repo}
+ <${titleTag} class="card__title card__title--link" ${titleAttrs}>${esc(it.title)}${titleTag}>
+ ${it.description ? `
${esc(it.description)}
` : ""}
+ ${(tags || it.date) ? `
${tags}${dateMarkup(it)}
` : ""}
+
`;
+ }
+
const tag = it.url ? "a" : "div";
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
- const tags = (it.tags || []).map(t => `${esc(t)}`).join("");
return `
<${tag} class="card card--project${featured} reveal" ${attrs}>
${esc(it.title)}
@@ -59,6 +73,26 @@
${tag}>`;
}
+ function repoKind(url) {
+ const h = hostOf(url).toLowerCase();
+ if (!h) return null;
+ if (h === "github.com" || h.endsWith(".github.com")) return "github";
+ if (h === "bitbucket.org" || h.endsWith(".bitbucket.org")) return "bitbucket";
+ return "gitea";
+ }
+
+ function repoLinkMarkup(url) {
+ if (!url) return "";
+ const kind = repoKind(url);
+ if (!kind) return "";
+ const host = hostOf(url);
+ const label = kind === "github" ? "View source on GitHub"
+ : kind === "bitbucket" ? "View source on Bitbucket"
+ : `View source (${host})`;
+ const icon = ``;
+ return `${icon}`;
+ }
+
function renderYouTube(it) {
const wide = it.featured ? " card--wide" : "";
return `
@@ -736,7 +770,7 @@
${sections.map((s, i) => renderSection(s, i + 1)).join("")}
diff --git a/data/links.example.json b/data/links.example.json
index 1335054..441afdc 100644
--- a/data/links.example.json
+++ b/data/links.example.json
@@ -1,4 +1,5 @@
{
+ "$schema": "./links.schema.json",
"profile": {
"name": "Ada Lovelace",
"handle": "@ada",
@@ -39,6 +40,7 @@
"type": "card",
"title": "Bernoulli Numbers",
"url": "https://example.com/projects/bernoulli",
+ "repo": "https://github.com/example/bernoulli",
"description": "Method for computing Bernoulli numbers using the Engine.",
"tags": ["mathematics"],
"date": "1843-07"
diff --git a/data/links.schema.json b/data/links.schema.json
new file mode 100644
index 0000000..75eb6da
--- /dev/null
+++ b/data/links.schema.json
@@ -0,0 +1,193 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://dlstack/links.schema.json",
+ "title": "dlstack links file",
+ "description": "Content document for a dlstack site. Lives at data/links.json and is fetched at runtime by assets/js/app.js.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["profile", "sections"],
+ "properties": {
+ "$schema": { "type": "string", "description": "Reference to this schema for editor autocomplete." },
+
+ "profile": {
+ "type": "object",
+ "description": "Hero block at the top of the page.",
+ "additionalProperties": true,
+ "required": ["name"],
+ "properties": {
+ "name": { "type": "string" },
+ "handle": { "type": "string" },
+ "tagline": { "type": "string" },
+ "bio": { "type": "string" },
+ "location": { "type": "string" },
+ "avatar": { "type": "string", "description": "Optional avatar image URL." }
+ }
+ },
+
+ "theme": {
+ "type": "object",
+ "additionalProperties": true,
+ "properties": {
+ "accent": { "type": "string", "description": "Accent color (CSS color, usually hex)." },
+ "template": { "type": "string", "enum": ["editorial", "swiss", "cosmos"] }
+ }
+ },
+
+ "sections": {
+ "type": "array",
+ "description": "Ordered list of content sections.",
+ "items": { "$ref": "#/$defs/section" }
+ },
+
+ "social": {
+ "type": "array",
+ "description": "Pill buttons under the hero bio.",
+ "items": { "$ref": "#/$defs/social" }
+ },
+
+ "footer": {
+ "type": "object",
+ "additionalProperties": true,
+ "properties": {
+ "copy": { "type": "string", "description": "Left-aligned footer line." },
+ "year": { "type": "string", "description": "'auto' to use current year, or a fixed string." },
+ "editUrl": { "type": "string", "format": "uri", "description": "Optional 'Edit on GitHub' link, e.g. https://github.com/you/repo/edit/main/data/links.json. When set, an unobtrusive link appears in the footer." }
+ }
+ }
+ },
+
+ "$defs": {
+ "section": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "label", "items"],
+ "properties": {
+ "id": { "type": "string" },
+ "label": { "type": "string" },
+ "kicker": { "type": "string", "description": "Small line above the section label." },
+ "layout": { "type": "string", "enum": ["clients", "testimonials"], "description": "Special layout. Omit for the default bento grid." },
+ "headless": { "type": "boolean", "description": "Render section body without the header — useful for chaining a continuation onto the previous section." },
+ "items": { "type": "array", "items": { "$ref": "#/$defs/item" } }
+ }
+ },
+
+ "item": {
+ "oneOf": [
+ { "$ref": "#/$defs/itemLink" },
+ { "$ref": "#/$defs/itemCard" },
+ { "$ref": "#/$defs/itemYouTube" },
+ { "$ref": "#/$defs/itemPortfolio" },
+ { "$ref": "#/$defs/itemClient" },
+ { "$ref": "#/$defs/itemTestimonial" }
+ ]
+ },
+
+ "date": {
+ "type": "string",
+ "description": "Optional date. Accepts ISO 'YYYY', 'YYYY-MM', 'YYYY-MM-DD', or free-form strings like '1837'.",
+ "pattern": "^.+$"
+ },
+
+ "itemLink": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "title", "url"],
+ "properties": {
+ "type": { "const": "link" },
+ "title": { "type": "string" },
+ "url": { "type": "string", "format": "uri-reference" },
+ "description": { "type": "string" },
+ "image": { "type": "string", "description": "Custom logo URL. Overrides the auto-fetched favicon." },
+ "icon": { "type": "string", "description": "Alias for 'image'." },
+ "featured": { "type": "boolean" },
+ "date": { "$ref": "#/$defs/date" }
+ }
+ },
+
+ "itemCard": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "title"],
+ "properties": {
+ "type": { "const": "card" },
+ "title": { "type": "string" },
+ "url": { "type": "string", "format": "uri-reference", "description": "Primary link. When present, the title becomes a link." },
+ "repo": { "type": "string", "format": "uri", "description": "Optional secondary link to the project's source repo. Renders a git icon in the corner. Supports github.com, bitbucket.org, and Gitea/self-hosted URLs." },
+ "description": { "type": "string" },
+ "tags": { "type": "array", "items": { "type": "string" } },
+ "featured": { "type": "boolean", "description": "Hero card — spans 8/12 columns, fills with accent color." },
+ "date": { "$ref": "#/$defs/date" }
+ }
+ },
+
+ "itemYouTube": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "id", "title"],
+ "properties": {
+ "type": { "const": "youtube" },
+ "id": { "type": "string", "description": "YouTube video ID (the 11-char string after v=)." },
+ "title": { "type": "string" },
+ "featured": { "type": "boolean", "description": "When true, card spans the wider layout." },
+ "date": { "$ref": "#/$defs/date" }
+ }
+ },
+
+ "itemPortfolio": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "image"],
+ "properties": {
+ "type": { "const": "portfolio" },
+ "image": { "type": "string" },
+ "title": { "type": "string" },
+ "description": { "type": "string" },
+ "url": { "type": "string", "format": "uri-reference" },
+ "ratio": { "type": "string", "pattern": "^\\d+\\s*:\\s*\\d+$", "description": "Aspect ratio as 'W:H' (e.g. '16:9', '5:2'). Default '5:2'." },
+ "date": { "$ref": "#/$defs/date" }
+ }
+ },
+
+ "itemClient": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "title"],
+ "properties": {
+ "type": { "const": "client" },
+ "title": { "type": "string" },
+ "url": { "type": "string", "format": "uri-reference" },
+ "image": { "type": "string" },
+ "icon": { "type": "string", "description": "Alias for 'image'." },
+ "date": { "$ref": "#/$defs/date" }
+ }
+ },
+
+ "itemTestimonial": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "quote"],
+ "properties": {
+ "type": { "const": "testimonial" },
+ "quote": { "type": "string" },
+ "name": { "type": "string" },
+ "role": { "type": "string" },
+ "org": { "type": "string" },
+ "url": { "type": "string", "format": "uri-reference" },
+ "image": { "type": "string" },
+ "icon": { "type": "string", "description": "Alias for 'image'." },
+ "date": { "$ref": "#/$defs/date" }
+ }
+ },
+
+ "social": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["label", "url"],
+ "properties": {
+ "label": { "type": "string" },
+ "url": { "type": "string", "format": "uri-reference" },
+ "icon": { "type": "string", "description": "Icon key (e.g. 'github', 'linkedin', 'mail', 'rss')." }
+ }
+ }
+ }
+}