From 13d646b6730e85cc22d3aa7b69380be93071a34a Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Tue, 26 May 2026 20:21:10 -0700 Subject: [PATCH] Cards: optional repo link, block-level descriptions, schema + CI validation - Project card: optional `repo` field renders a git icon corner link (github.com / bitbucket.org / Gitea URLs supported). - All cards: `.card__title` and `.card__desc` are now display:block, so the description always starts on its own line. - Added data/links.schema.json (JSON Schema 2020-12) describing the full content model. Both data files reference it via `$schema` for github.dev / VS Code autocomplete. - New GitHub Action validates data/links.json on every push/PR: jq syntax check + ajv-cli schema validation. --- .github/workflows/validate-data.yml | 39 ++++++ README.md | 6 +- assets/css/styles.css | 40 +++++- assets/js/app.js | 38 +++++- data/links.example.json | 2 + data/links.schema.json | 193 ++++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/validate-data.yml create mode 100644 data/links.schema.json 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)} + ${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 @@ `; } + 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')." } + } + } + } +}