diff --git a/Makefile b/Makefile
index 0fb1def..9179b93 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,20 @@
-.PHONY: launch
+.PHONY: launch cover
+
launch:
@echo "Launching the application..."
- npm run dev
\ No newline at end of file
+ npm run dev
+
+# Rebuild static/cover.png (the og:image) from tools/og-card.html.
+#
+# The card is copied into public/ and served over HTTP rather than opened as a
+# file:// URL, so the Jost webfont and the logo are same-origin — woff2 is
+# CORS-gated, and a cross-origin miss fails silently to the fallback font.
+cover:
+ npm run build
+ cp tools/og-card.html public/og-card.html
+ @python3 -m http.server 8899 --directory public >/dev/null 2>&1 & \
+ srv=$$!; sleep 1; \
+ node tools/og-shot.mjs http://127.0.0.1:8899/og-card.html static/cover.png 1200 630; \
+ kill $$srv
+ rm -f public/og-card.html
+ @echo "static/cover.png regenerated"
diff --git a/assets/js/custom.js b/assets/js/custom.js
index c5525d0..167ea72 100644
--- a/assets/js/custom.js
+++ b/assets/js/custom.js
@@ -1 +1,55 @@
-// Put your custom JS code here
+// Put your custom JS code here
+
+// Refresh the homepage star counts from /.netlify/functions/stars.
+//
+// The counts are already rendered at build time, so this only corrects them
+// between rebuilds. Every failure path leaves the build-time values in place:
+// running `hugo server` without `netlify dev` has no function to call, and a
+// visitor may be offline.
+//
+// Cards are deliberately not re-sorted. This runs after first paint, so
+// reordering would visibly shuffle them. The order therefore reflects the last
+// deploy, and only looks wrong if two projects swap rank between deploys.
+(function () {
+ function format(count) {
+ return count >= 1000 ? (count / 1000).toFixed(1) + "k" : String(count);
+ }
+
+ function refreshStars() {
+ var badges = document.querySelectorAll(".feature-stars[data-repo]");
+ if (!badges.length) {
+ return;
+ }
+
+ fetch("/.netlify/functions/stars")
+ .then(function (response) {
+ if (!response.ok) {
+ throw new Error("stars endpoint responded " + response.status);
+ }
+ return response.json();
+ })
+ .then(function (stars) {
+ for (var i = 0; i < badges.length; i++) {
+ var badge = badges[i];
+ var count = stars[badge.dataset.repo.toLowerCase()];
+ if (typeof count !== "number") {
+ continue;
+ }
+
+ badge.querySelector("[data-stars-count]").textContent = format(count);
+ badge.title = count + " stars on GitHub";
+ // Reveals the badge on cards whose build-time lookup came back empty.
+ badge.classList.remove("feature-stars-pending");
+ }
+ })
+ .catch(function () {
+ // Keep the build-time counts.
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", refreshStars, { once: true });
+ } else {
+ refreshStars();
+ }
+})();
diff --git a/assets/scss/common/_custom.scss b/assets/scss/common/_custom.scss
index f7c1361..84d7d34 100644
--- a/assets/scss/common/_custom.scss
+++ b/assets/scss/common/_custom.scss
@@ -1 +1,291 @@
-// Put your custom SCSS code here
+// Put your custom SCSS code here
+
+// Shrink the top-right navbar icons (social links + color mode toggler)
+#socialMenu .social-link svg,
+#buttonColorMode svg {
+ width: 20px;
+ height: 20px;
+}
+
+// Skip link: the first focusable thing on the page, hidden until it has focus.
+//
+// Not Bootstrap's `.visually-hidden-focusable`, so this does not depend on that
+// utility surviving purgecss. Clipped rather than `display: none`, because a
+// display-none element cannot receive focus at all.
+.skip-link {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1080; // above the sticky navbar ($zindex-sticky is 1020)
+ padding: 0.625rem 1rem;
+ background: $primary;
+ color: #fff;
+ border-bottom-right-radius: 0.375rem;
+ text-decoration: none;
+
+ &:not(:focus) {
+ // The standard visually-hidden recipe: out of sight, still in the a11y tree.
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+
+ &:focus {
+ color: #fff;
+ }
+}
+
+// Keep the footer at the bottom of the viewport on short pages
+// (the homepage does not fill the screen otherwise).
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+// Flex items default to `min-width: auto`, so wide content (code blocks,
+// tables) could stretch the page instead of scrolling inside its own box
+// now that `body` is a flex container.
+body > * {
+ min-width: 0;
+}
+
+.footer {
+ margin-top: auto;
+
+ // Doks sizes footer items at $font-size-base (16px) from `md` up, which is
+ // heavy for a copyright line under an 18px body. No media query needed to
+ // override it: same specificity, and this file is imported last.
+ li {
+ font-size: 0.8125rem;
+ }
+}
+
+// Scale the homepage headline with the viewport rather than wrapping it.
+.home h1.home-title {
+ // `clamp()` passes through to CSS; Sass would try to evaluate `min()`
+ // itself and fail on the mixed rem/vw units.
+ font-size: clamp(1.5rem, 3.4vw, 3rem);
+ // Doks pulls the home headline up by 1rem, which eats into the hero padding.
+ margin-top: 0;
+ margin-bottom: 1.75rem;
+}
+
+// One line, but only where one line fits. Below `sm` the headline would have to
+// drop to ~15px to fit 320px on a single line, so it wraps to two legible lines
+// instead — `nowrap` there ran off the right edge and scrolled the page
+// sideways.
+@include media-breakpoint-up(sm) {
+ .home h1.home-title {
+ white-space: nowrap;
+ }
+}
+
+// The one piece of prose on the page. Constrained because a full-width measure
+// under a centred headline reads as a caption rather than a statement.
+.home .lead {
+ max-width: 40rem;
+ margin-inline: auto;
+ opacity: 0.75;
+}
+
+// The headline carries the top of the page alone, so give it room to sit in.
+// The slack comes out of the dead band above the footer, which the short
+// homepage leaves over anyway.
+.home {
+ .section.container-fluid {
+ // Scaled, not fixed: the headline itself scales with the viewport, and a
+ // flat 5.5rem left 88px of air above a 20px headline on a phone.
+ padding-top: clamp(2.5rem, 7vw, 5.5rem);
+ padding-bottom: clamp(2rem, 5.5vw, 4.5rem);
+ }
+
+ .section-features {
+ padding-top: 0;
+ }
+}
+
+// Brand mark in the navbar (top left), next to the site title
+.navbar-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ &::before {
+ content: "";
+ display: inline-block;
+ flex: 0 0 auto;
+ width: 32px;
+ height: 32px;
+ background: url("images/logo.png") no-repeat center / contain;
+ border-radius: 50%;
+ }
+}
+
+// Project cards on the homepage.
+//
+// Flex rather than grid or Bootstrap's row/col: seven cards never fill a row
+// evenly, and flex-wrap centres the short final row instead of leaving a hole
+// on one side. The explicit basis keeps every card the same width, which
+// `flex-grow` would not — the last row would stretch wider than the rows above.
+$feature-gap: 1.25rem;
+
+// Names the grid without shouting: smaller than the headline, uppercase and
+// tracked out so it reads as a label for the section rather than a second title
+// competing with the hero.
+//
+// Deliberately not called `.section-title` — the theme already uses that for the
+// "Related posts" heading on blog and legal pages (doks-core _posts.scss:55), and
+// this would have restyled those into tiny uppercase labels.
+.feature-grid-title {
+ margin-top: 0;
+ margin-bottom: 1.5rem;
+ text-align: center;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ // Not lower: at 13px this is normal-size text for WCAG, so it needs 4.5:1.
+ // 0.5 measured 3.01:1 and failed; 0.7 measures 5.4:1.
+ opacity: 0.7;
+}
+
+.feature-grid {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: $feature-gap;
+ // It is a `ul` now, for the item count screen readers announce. Strip the
+ // list affordances the browser adds; the cards are the visual affordance.
+ padding-left: 0;
+ margin-bottom: 0;
+ list-style: none;
+}
+
+.feature-card {
+ flex: 0 0 100%;
+}
+
+@include media-breakpoint-up(sm) {
+ .feature-card {
+ // Two per row: subtract the one gap between them.
+ flex-basis: calc(50% - #{$feature-gap * 0.5});
+ }
+}
+
+@include media-breakpoint-up(lg) {
+ .feature-card {
+ // Four per row: three gaps spread across four cards.
+ flex-basis: calc(25% - #{$feature-gap * 0.75});
+ }
+}
+
+.feature-card {
+ padding: 1.5rem;
+ // A warm tint rather than neutral grey: at these low alphas it barely reads
+ // as colour, but it stops the cards from looking like grey wireframes.
+ background: rgba($primary, 0.02);
+ border: 1px solid rgba($primary, 0.14);
+ border-radius: 0.75rem;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
+
+ // `h3` rather than `h2`: the grid now sits under its own section heading, so
+ // the card titles are one level deeper in the outline.
+ h3 {
+ // Doks puts a 2rem top margin on every heading, which leaves a gap
+ // between the card's top edge and its title.
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ font-size: 1.125rem;
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ }
+ }
+
+ // Decorative, and `aria-hidden` in the markup. `flex: 0 0 auto` stops a
+ // wide emoji from being squeezed by a long project name.
+ .feature-icon {
+ flex: 0 0 auto;
+ font-size: 1rem;
+ line-height: 1;
+ }
+
+ p {
+ margin-bottom: 0;
+ font-size: 1rem;
+ opacity: 0.78;
+ }
+
+ &:hover {
+ border-color: rgba($primary, 0.6);
+ box-shadow: 0 6px 20px rgba($primary, 0.12);
+ transform: translateY(-2px);
+
+ h3 a {
+ color: $primary;
+ }
+ }
+}
+
+// The donation call to action. Deliberately unboxed: a panel here competed with
+// the project cards for attention and left a large empty band across the page.
+// A plain centred line separates it from the grid on whitespace alone.
+.feature-cta {
+ // Separated by whitespace alone — no rule and no box. The gap is a little
+ // wider than the grid's own row gap so the invitation still reads as the end
+ // of the page rather than an eighth card that fell out of the row.
+ margin-top: 3rem;
+ text-align: center;
+
+ .feature-cta-title {
+ display: block;
+ font-weight: 600;
+ color: $primary;
+ }
+
+ p {
+ margin: 0.125rem 0 0;
+ font-size: 0.9375rem;
+ opacity: 0.7;
+ }
+}
+
+// GitHub star count, right-aligned on the card's title line
+.feature-stars {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-left: auto;
+ flex: 0 0 auto;
+ font-size: 0.8125rem;
+ font-weight: 500;
+
+ svg {
+ // A gold star, as GitHub draws it. The muting sits on the number instead of
+ // the whole badge, so the colour is not washed out by an opacity of its own.
+ color: #e3a008;
+ // Nudge the star onto the text baseline
+ margin-bottom: -0.125rem;
+ }
+
+ // The count stays in the body colour: yellow text this small would be hard to
+ // read on white, and the star alone carries the colour.
+ [data-stars-count] {
+ opacity: 0.65;
+ }
+}
+
+// No count yet: the build-time lookup failed and assets/js/custom.js has not
+// filled this in (or cannot reach the endpoint). Hide it rather than show a
+// bare star with no number.
+.feature-stars-pending {
+ display: none;
+}
diff --git a/assets/scss/common/_variables-custom.scss b/assets/scss/common/_variables-custom.scss
index 7302032..e6c285a 100644
--- a/assets/scss/common/_variables-custom.scss
+++ b/assets/scss/common/_variables-custom.scss
@@ -1,2 +1,7 @@
-// Put your custom SCSS variables here
-$primary: #F2A884;
+// Put your custom SCSS variables here
+
+// The brand orange, sampled from the logo. The previous value (#F2A884) was a
+// pale tint of it that only managed 1.96:1 contrast against white — well below
+// the 4.5:1 WCAG AA needs for text — so links and accents barely registered and
+// the site read as monochrome. This hits 4.56:1.
+$primary: #d93c0d;
diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml
index 7987c73..41f0755 100644
--- a/config/_default/hugo.toml
+++ b/config/_default/hugo.toml
@@ -21,7 +21,10 @@ defaultContentLanguageInSubdir = false
enable = true
[outputs]
- home = ["HTML", "RSS", "searchIndex"]
+ # SITEMAP on `home` is what produces /sitemap.xml. The Doks starter left it off,
+ # so the root sitemap was never generated even though robots.txt advertises it —
+ # only the per-section /blog/sitemap.xml existed.
+ home = ["HTML", "RSS", "searchIndex", "SITEMAP"]
section = ["HTML", "RSS", "SITEMAP"]
[outputFormats.searchIndex]
@@ -48,6 +51,20 @@ defaultContentLanguageInSubdir = false
[caches.getjson]
dir = ":cacheDir/:project"
maxAge = -1 # "30m"
+ # GitHub star counts (resources.GetRemote). These are only the seed values for
+ # first paint and the card ordering — assets/js/custom.js replaces the numbers
+ # from /.netlify/functions/stars on every visit, so what a reader sees is at
+ # most an hour old regardless of when the site last built. A TTL here just
+ # stops repeated deploys from spending the 60 requests/hour unauthenticated
+ # API budget; Netlify persists this cache between builds.
+ [caches.getresource]
+ dir = ":cacheDir/:project"
+ maxAge = "12h"
+
+# Allow reading GITHUB_TOKEN so CI can raise the GitHub API rate limit when
+# fetching star counts. Repeats Hugo's defaults, which this setting replaces.
+[security.funcs]
+ getenv = ['^HUGO_', '^CI$', '^GITHUB_TOKEN$']
[taxonomies]
contributor = "contributors"
diff --git a/config/_default/module.toml b/config/_default/module.toml
index d82136f..9e5022e 100644
--- a/config/_default/module.toml
+++ b/config/_default/module.toml
@@ -1,87 +1,88 @@
-# mounts
-## archetypes
-[[mounts]]
- source = "node_modules/@thulite/doks-core/archetypes"
- target = "archetypes"
-
-[[mounts]]
- source = "archetypes"
- target = "archetypes"
-
-## assets
-[[mounts]]
- source = "node_modules/@thulite/core/assets"
- target = "assets"
-
-[[mounts]]
- source = "node_modules/@thulite/images/assets"
- target = "assets"
-
-[[mounts]]
- source = "node_modules/@thulite/doks-core/assets"
- target = "assets"
-
-[[mounts]]
- source = "node_modules/@tabler/icons/icons"
- target = "assets/svgs/tabler-icons"
-
-[[mounts]]
- source = "assets"
- target = "assets"
-
-## content
-[[mounts]]
- source = "content"
- target = "content"
-
-## data
-[[mounts]]
- source = "node_modules/@thulite/doks-core/data"
- target = "data"
-
-[[mounts]]
- source = "data"
- target = "data"
-
-## i18n
-[[mounts]]
- source = "node_modules/@thulite/doks-core/i18n"
- target = "i18n"
-
-[[mounts]]
- source = "i18n"
- target = "i18n"
-
-## layouts
-[[mounts]]
- source = "node_modules/@thulite/core/layouts"
- target = "layouts"
-
-[[mounts]]
- source = "node_modules/@thulite/seo/layouts"
- target = "layouts"
-
-[[mounts]]
- source = "node_modules/@thulite/images/layouts"
- target = "layouts"
-
-[[mounts]]
- source = "node_modules/@thulite/doks-core/layouts"
- target = "layouts"
-
-[[mounts]]
- source = "node_modules/@thulite/inline-svg/layouts"
- target = "layouts"
-
-[[mounts]]
- source = "layouts"
- target = "layouts"
-
-## static
-[[mounts]]
- source = "node_modules/@thulite/doks-core/static"
- target = "static"
-
-[[mounts]]
- source = "static"
- target = "static"
+# mounts
+## archetypes
+[[mounts]]
+ source = "node_modules/@thulite/doks-core/archetypes"
+ target = "archetypes"
+
+[[mounts]]
+ source = "archetypes"
+ target = "archetypes"
+
+## assets
+[[mounts]]
+ source = "node_modules/@thulite/core/assets"
+ target = "assets"
+
+[[mounts]]
+ source = "node_modules/@thulite/images/assets"
+ target = "assets"
+
+[[mounts]]
+ source = "node_modules/@thulite/doks-core/assets"
+ target = "assets"
+
+[[mounts]]
+ source = "node_modules/@tabler/icons/icons"
+ target = "assets/svgs/tabler-icons"
+
+[[mounts]]
+ source = "assets"
+ target = "assets"
+
+## content
+[[mounts]]
+ source = "content"
+ target = "content"
+
+## data
+[[mounts]]
+ source = "node_modules/@thulite/doks-core/data"
+ target = "data"
+
+[[mounts]]
+ source = "data"
+ target = "data"
+
+## i18n
+[[mounts]]
+ source = "node_modules/@thulite/doks-core/i18n"
+ target = "i18n"
+
+[[mounts]]
+ source = "i18n"
+ target = "i18n"
+
+## layouts
+[[mounts]]
+ source = "layouts"
+ target = "layouts"
+
+[[mounts]]
+ source = "node_modules/@thulite/core/layouts"
+ target = "layouts"
+
+[[mounts]]
+ source = "node_modules/@thulite/seo/layouts"
+ target = "layouts"
+
+[[mounts]]
+ source = "node_modules/@thulite/images/layouts"
+ target = "layouts"
+
+[[mounts]]
+ source = "node_modules/@thulite/doks-core/layouts"
+ target = "layouts"
+
+[[mounts]]
+ source = "node_modules/@thulite/inline-svg/layouts"
+ target = "layouts"
+
+
+## static
+[[mounts]]
+ source = "node_modules/@thulite/doks-core/static"
+ target = "static"
+
+[[mounts]]
+ source = "static"
+ target = "static"
diff --git a/config/_default/params.toml b/config/_default/params.toml
index 2f53f2b..61262ae 100644
--- a/config/_default/params.toml
+++ b/config/_default/params.toml
@@ -1,13 +1,15 @@
# Hugo
-title = "My Docs"
-description = "Congrats on setting up a new Doks project!"
+title = "InftyAI"
+description = "Exploring the ∞ possibilities of AI"
images = ["cover.png"]
# mainSections
mainSections = ["docs"]
[social]
- twitter = "getdoks"
+ # Drives , i.e. who X credits when the site is shared.
+ # Was "getdoks", the theme author's handle, left over from the starter.
+ twitter = "InftyAI"
# Doks (@thulite/doks-core)
[doks]
@@ -48,7 +50,12 @@ mainSections = ["docs"]
toTopButton = false # false (default) or true
breadcrumbTrail = false # false (default) or true
headlineHash = true # true (default) or false
- scrollSpy = true # true (default) or false
+ # Off, because there is nothing to spy on: scrollspy highlights the current
+ # heading inside `#toc`, and no page on this site renders a table of contents.
+ # Leaving it on had one real effect — `tabindex="0"` on `
`, which made the
+ # body itself the first Tab stop and pushed the skip link to second. Turn this
+ # back on if a docs section with a TOC is ever added.
+ scrollSpy = false # true (default) or false
# Multilingual
multilingualMode = false # false (default) or true
@@ -60,7 +67,7 @@ mainSections = ["docs"]
# UX
headerBar = false # true (default) or false
- backgroundDots = true # true (default) or false
+ backgroundDots = false # true (default) or false
# Homepage
sectionFooter = false # false (default) or true
diff --git a/config/production/hugo.toml b/config/production/hugo.toml
index 25b6683..553ebd6 100644
--- a/config/production/hugo.toml
+++ b/config/production/hugo.toml
@@ -1,2 +1,11 @@
-# Overrides for production environment
-baseurl = "/"
+# Overrides for production environment
+
+# No `baseurl` override here on purpose. The Doks starter shipped `baseurl = "/"`,
+# which silently beat the real URL in config/_default and left every absolute URL
+# broken in production: `og:url` and `og:image` came out relative (Open Graph
+# requires absolute), and robots.txt advertised a relative `Sitemap:`. The
+# canonical value lives in config/_default/hugo.toml and should stay the only
+# place it is written down.
+#
+# config/next keeps `baseurl = "/"` deliberately — branch and preview deploys
+# have their own hostnames and must not claim the production URL.
diff --git a/content/_index.md b/content/_index.md
index 376f504..a5d1428 100644
--- a/content/_index.md
+++ b/content/_index.md
@@ -1,13 +1,18 @@
----
-title: "Exploring the ∞ possibilities of AI"
-description: ""
-# lead: ""
-date: 2023-09-07T16:33:54+02:00
-lastmod: 2023-09-07T16:33:54+02:00
-draft: false
-seo:
- title: "InftyAI ∞" # custom title (optional)
- description: "Exploring the ∞ possibilities of AI" # custom description (recommended)
- canonical: "" # custom canonical URL (optional)
- robots: "" # custom robot tags (optional)
----
+---
+title: "Exploring the ∞ possibilities of AI"
+# Shown under the headline. Placeholder copy in your voice — edit freely; it is
+# the only prose on the page, so it is what tells a first-time visitor what
+# InftyAI actually does.
+lead: "We build open source infrastructure for AI."
+description: "InftyAI is an open source community building infrastructure for AI: LLM inference on Kubernetes, GPU orchestration, model routing, and agent sandboxes."
+date: 2023-09-07T16:33:54+02:00
+lastmod: 2023-09-07T16:33:54+02:00
+draft: false
+seo:
+ title: "InftyAI ∞" # custom title (optional)
+ # Deliberately not a copy of the title — search snippets and social cards
+ # previously showed the same eight words twice.
+ description: "InftyAI is an open source community building infrastructure for AI: LLM inference on Kubernetes, GPU orchestration, model routing, and agent sandboxes."
+ canonical: "" # custom canonical URL (optional)
+ robots: "" # custom robot tags (optional)
+---
diff --git a/data/projects.yaml b/data/projects.yaml
new file mode 100644
index 0000000..6bbd603
--- /dev/null
+++ b/data/projects.yaml
@@ -0,0 +1,55 @@
+# Homepage project cards. `repo` is used to look up the GitHub star count at
+# build time; cards are rendered in descending order of stars.
+#
+# `icon` is decorative only — it is rendered `aria-hidden`, so the project name
+# has to carry the meaning on its own. Keeping it out of `description` leaves the
+# prose clean and gives every card the same visual anchor beside its title.
+projects:
+ - name: Awesome-LLMOps
+ icon: "🎉"
+ repo: InftyAI/Awesome-LLMOps
+ url: https://awesome-llmops.inftyai.com
+ description: "An awesome & curated list of best LLMOps tools."
+
+ - name: llmaz
+ icon: "☸️"
+ repo: InftyAI/llmaz
+ url: https://llmaz.inftyai.com/
+ description: "Easy, advanced inference platform for large language models on Kubernetes."
+
+ - name: Alphatrion
+ icon: "⚒️"
+ repo: InftyAI/alphatrion
+ url: https://github.com/InftyAI/alphatrion
+ description: "The open-source framework for LLM experiments and agent orchestration."
+
+ - name: AMRS
+ icon: "🧬"
+ repo: InftyAI/amrs
+ url: https://github.com/InftyAI/amrs
+ description: "The adaptive model routing system for exploration and exploitation."
+
+ - name: SandD
+ icon: "🤖"
+ repo: InftyAI/sandd
+ url: https://github.com/InftyAI/sandd
+ description: "A lightweight sandbox daemon for secure agent execution in isolated environments."
+
+ - name: Nebula
+ icon: "🎮"
+ repo: InftyAI/Nebula
+ url: https://github.com/InftyAI/Nebula
+ description: "The control plane for GPUaaS."
+
+ - name: MLX
+ icon: "🦀"
+ repo: InftyAI/mlx
+ url: https://github.com/InftyAI/mlx
+ description: "Rust bindings for Apple's MLX framework."
+
+# Rendered below the grid as plain text — not a project, and not a link, so it
+# has no `repo` (no star count) and no `url`.
+cta:
+ name: "Donation Plan"
+ icon: "✨"
+ description: "Got a brilliant idea? Let's build something extraordinary together."
diff --git a/functions/stars.mjs b/functions/stars.mjs
new file mode 100644
index 0000000..5513a9c
--- /dev/null
+++ b/functions/stars.mjs
@@ -0,0 +1,73 @@
+// Current GitHub star counts for the InftyAI org, keyed by lowercased
+// `owner/repo`.
+//
+// The homepage renders star counts at build time; this endpoint lets the
+// browser refresh them without waiting for a rebuild. It exists as a function
+// rather than a direct browser fetch for three reasons: it is same-origin, so
+// the `connect-src 'self'` CSP in netlify.toml needs no loosening; it can hold
+// a token server-side; and the CDN caches its response, so GitHub sees roughly
+// one request an hour regardless of traffic.
+
+const ORG = "InftyAI";
+
+// One org-wide request instead of one per project, so adding a project to
+// data/projects.yaml needs no change here. 100 is the page-size ceiling; the
+// org would need to outgrow that before pagination mattered.
+const UPSTREAM = `https://api.github.com/orgs/${ORG}/repos?per_page=100&type=public`;
+
+export default async () => {
+ const headers = {
+ Accept: "application/vnd.github+json",
+ "User-Agent": "inftyai-website",
+ };
+
+ // Optional: raises the rate limit from 60 to 5,000 requests an hour. Set it
+ // in Netlify's environment variables if the unauthenticated budget ever runs
+ // short.
+ const token = process.env.GITHUB_TOKEN;
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ let repos;
+ try {
+ const upstream = await fetch(UPSTREAM, { headers });
+ if (!upstream.ok) {
+ return failed(`GitHub responded ${upstream.status}`);
+ }
+ repos = await upstream.json();
+ } catch (err) {
+ return failed(err.message);
+ }
+
+ // Lowercased because the casing GitHub reports does not always match the
+ // slugs in data/projects.yaml (`InftyAI/SandD` vs `InftyAI/sandd`).
+ const stars = {};
+ for (const repo of repos) {
+ stars[repo.full_name.toLowerCase()] = repo.stargazers_count;
+ }
+
+ return new Response(JSON.stringify(stars), {
+ headers: {
+ "Content-Type": "application/json",
+ // Set explicitly so the catch-all `Cache-Control` in netlify.toml cannot
+ // pin this response for a year.
+ "Cache-Control": "public, max-age=600",
+ "Netlify-CDN-Cache-Control":
+ "public, s-maxage=3600, stale-while-revalidate=86400",
+ },
+ });
+};
+
+// The page falls back to its build-time counts on any failure, so the body is
+// only ever read by a human debugging. `no-store` keeps a transient GitHub
+// outage from being cached for an hour.
+function failed(reason) {
+ return new Response(JSON.stringify({ error: reason }), {
+ status: 503,
+ headers: {
+ "Content-Type": "application/json",
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/hugo_stats.json b/hugo_stats.json
index a7a6bcb..170baff 100644
--- a/hugo_stats.json
+++ b/hugo_stats.json
@@ -13,17 +13,18 @@
"g",
"h1",
"h2",
+ "h3",
"h5",
"head",
"header",
"html",
- "img",
"input",
"kbd",
"label",
"li",
"line",
"link",
+ "main",
"meta",
"noscript",
"p",
@@ -43,25 +44,16 @@
"classes": [
"DocSearch-Label",
"active",
- "bg-dots",
"blog",
"blog-header",
- "blur-up",
- "border-0",
"btn",
"btn-close",
- "btn-cta",
- "btn-lg",
"btn-link",
- "btn-primary",
"card",
"card-body",
"card-list",
"categories",
"col-lg-10",
- "col-lg-12",
- "col-lg-5",
- "col-lg-8",
"col-lg-9",
"col-md-12",
"col-xl-8",
@@ -78,6 +70,13 @@
"d-md-none",
"d-none",
"error404",
+ "feature-card",
+ "feature-cta",
+ "feature-cta-title",
+ "feature-grid",
+ "feature-grid-title",
+ "feature-icon",
+ "feature-stars",
"flex-column",
"flex-grow-1",
"flex-lg-row",
@@ -87,33 +86,25 @@
"form-control-lg",
"fs-5",
"h-auto",
- "h4",
"h5",
"home",
+ "home-title",
"icon",
"icon-tabler",
"icon-tabler-brand-discord",
"icon-tabler-brand-github",
- "icon-tabler-brand-slack",
"icon-tabler-brand-x",
"icon-tabler-menu",
"icon-tabler-search",
"icon-tabler-x",
- "icons-tabler-outline",
- "img-fluid",
- "img-simple",
"justify-content-between",
"justify-content-center",
- "justify-content-start",
- "lazyloaded",
"lead",
"list",
"list-inline",
"list-inline-item",
"list-view",
- "ls-is-cached",
"mb-0",
- "mb-2",
"me-2",
"me-auto",
"me-lg-3",
@@ -131,7 +122,6 @@
"ms-3",
"ms-auto",
"mt-3",
- "mt-n3",
"mx-2",
"mx-auto",
"my-3",
@@ -151,12 +141,9 @@
"order-lg-4",
"p-0",
"p-2",
- "pb-3",
"privacy",
"px-0",
- "px-4",
"query-no-results",
- "rounded-circle",
"row",
"search-form",
"search-input",
@@ -169,8 +156,8 @@
"section",
"section-features",
"section-md",
- "section-sm",
"single",
+ "skip-link",
"social-link",
"status",
"sticky-top",
@@ -180,8 +167,6 @@
"taxonomy",
"text-center",
"text-decoration-none",
- "text-lg-end",
- "text-lg-start",
"text-muted",
"text-reset",
"title",
@@ -190,6 +175,7 @@
"wrap"
],
"ids": [
+ "content",
"offcanvasNavMain",
"offcanvasNavMainLabel",
"query",
diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html
new file mode 100644
index 0000000..73c0065
--- /dev/null
+++ b/layouts/_default/baseof.html
@@ -0,0 +1,40 @@
+{{/* Overrides @thulite/doks-core/layouts/_default/baseof.html. Three changes, all
+ in the body; everything else is kept byte-for-byte so theme updates stay easy
+ to diff against:
+
+ 1. A `` landmark. The theme shipped none, so assistive technology had no
+ way to skip the navbar and jump to the content.
+ 2. A skip link as the first focusable element, which needs (1) to point at.
+ 3. `role="document"` dropped from `.wrap`. It is meant for reading-mode content
+ inside web *applications*; on an ordinary page it adds nothing and competes
+ with the real landmarks.
+
+ `` has to wrap the `sidebar-prefooter` and `sidebar-footer` blocks too,
+ not just `main`: the homepage renders its project grid into sidebar-prefooter,
+ and blog/legal pages render "Related posts" into sidebar-footer. Both are page
+ content, and neither can be wrapped from inside its own block. */}}
+
+
+ {{ partial "head/head" . }}
+ {{ partial "head/body-class" . }}
+
+ {{ i18n "skip_to_content" | default "Skip to main content" }}
+ {{ partial "header/header" . }}
+
+
+
+ {{ if and (eq site.Params.doks.containerBreakpoint "fluid") (or (not (in .Site.Params.mainSections .Type)) (.IsNode)) }}
{{ end }}
+ {{ block "main" . }}{{ end }}
+ {{ if and (eq site.Params.doks.containerBreakpoint "fluid") (or (not (in .Site.Params.mainSections .Type)) (.IsNode)) }}
+ {{/* Names the grid, so the seven project headings hang off a section
+ instead of directly off the page's h1. */}}
+
Projects
+ {{/* A list, because that is what seven sibling projects are: screen
+ readers announce the count and allow item-by-item navigation.
+ Flexbox rather than Bootstrap's row/col — it needs no breakpoint
+ classes, no `h-100`, and no gutter overrides (Doks ships neither the
+ `.g-*` utilities nor a non-zero `--bs-gutter-y`). */}}
+
+ {{ range $cards -}}
+
+
+ {{ with .icon }}{{ . }}{{ end }}
+ {{ .name }}
+ {{/* Rendered for every project, even when the build-time
+ lookup failed, so assets/js/custom.js can fill it in
+ from /.netlify/functions/stars. A card with no count
+ yet is hidden by `.feature-stars-pending` until then. */}}
+ {{ if .repo -}}
+ {{ $stars := .stars -}}
+ {{ $known := and $stars (ge $stars 0) -}}
+
+ {{/* Filled rather than outlined: at 14px a solid star
+ carries the colour, an outline barely shows it. */}}
+ {{ if $known }}{{ if ge $stars 1000 }}{{ printf "%.1fk" (div (float $stars) 1000) }}{{ else }}{{ $stars }}{{ end }}{{ end }}
+
+ {{ end -}}
+
+
{{ .description }}
+
+ {{ end -}}
+
+ {{/* An invitation, not a project — kept out of the grid so it does not
+ read as one more repository. */}}
+ {{ with site.Data.projects.cta -}}
+
+
+ {{ with .icon }}{{ . }} {{ end }}{{ .name }}
+
+
{{ .description }}
+ {{ end -}}
{{ end }}
{{ end }}
{{ define "sidebar-footer" }}
-{{ if site.Params.doks.sectionFooter -}}
-
-
-
-{{ end -}}
-{{ end }}
\ No newline at end of file
+{{ end }}
diff --git a/layouts/index.html b/layouts/index.html
deleted file mode 100644
index 6e1430f..0000000
--- a/layouts/index.html
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/layouts/partials/footer/footer.html b/layouts/partials/footer/footer.html
new file mode 100644
index 0000000..fc03b26
--- /dev/null
+++ b/layouts/partials/footer/footer.html
@@ -0,0 +1,16 @@
+{{/* Overrides the Doks footer, which splits into a left-aligned column for the
+ footer menu and a right-aligned one for `site.Params.footer`. Only the menu
+ is populated here, so the two columns are collapsed into one centered list.
+ Both sources are still rendered, so setting `params.footer` later works. */}}
+
diff --git a/layouts/sitemap.xml b/layouts/sitemap.xml
new file mode 100644
index 0000000..7ac87c9
--- /dev/null
+++ b/layouts/sitemap.xml
@@ -0,0 +1,30 @@
+{{/* Overrides @thulite/seo/layouts/sitemap.xml, which ranges over `.Pages`. On the
+ home page that is the *child* pages only, so the homepage itself — the most
+ important URL on this site — was left out of /sitemap.xml. Ranging over
+ `site.Pages` and filtering by kind includes it while still keeping the empty
+ taxonomy list pages (categories, tags, contributors) out. */}}
+{{ printf "" | safeHTML }}
+
+ {{ $pages := where site.Pages "Kind" "in" (slice "home" "section" "page") }}
+ {{ range where $pages "Sitemap.Disable" "ne" true }}
+ {{- if .Permalink -}}
+
+ {{ .Permalink }}{{ if not .Lastmod.IsZero }}
+ {{ safeHTML ( .Lastmod.Format "2006-01-02T15:04:05-07:00" ) }}{{ end }}{{ with .Sitemap.ChangeFreq }}
+ {{ . }}{{ end }}{{ if ge .Sitemap.Priority 0.0 }}
+ {{ .Sitemap.Priority }}{{ end }}{{ if .IsTranslated }}{{ range .Translations }}
+ {{ end }}
+ {{ end }}
+
+ {{- end -}}
+ {{ end }}
+
diff --git a/static/cover.png b/static/cover.png
new file mode 100644
index 0000000..cf0e7a8
Binary files /dev/null and b/static/cover.png differ
diff --git a/static/images/logo.png b/static/images/logo.png
new file mode 100644
index 0000000..6f07aea
Binary files /dev/null and b/static/images/logo.png differ
diff --git a/tools/og-card.html b/tools/og-card.html
new file mode 100644
index 0000000..9fe6891
--- /dev/null
+++ b/tools/og-card.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
Exploring the ∞ possibilities of AI
+
We build open source infrastructure for AI.
+
inftyai.com
+
+
+
diff --git a/tools/og-shot.mjs b/tools/og-shot.mjs
new file mode 100644
index 0000000..06e6a91
--- /dev/null
+++ b/tools/og-shot.mjs
@@ -0,0 +1,47 @@
+// Headless-Chrome screenshot at an exact pixel size, waiting for webfonts.
+// Drives Chrome over CDP directly so this needs no Puppeteer/Playwright
+// dependency — the browser is already on the machine.
+//
+// Usage: node tools/og-shot.mjs
+// Normally invoked by `make cover`; see tools/og-card.html.
+const [url, out, w, h] = process.argv.slice(2);
+const { execFile } = await import('node:child_process');
+const fs = await import('node:fs/promises');
+
+const CHROME = process.env.CHROME
+ || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const port = 9333 + Math.floor(Math.random() * 500);
+const proc = execFile(CHROME, [
+ '--headless=new', `--remote-debugging-port=${port}`, '--no-first-run',
+ '--user-data-dir=/tmp/cdp-og-' + port, '--hide-scrollbars', 'about:blank',
+]);
+
+let page;
+for (let i = 0; i < 60 && !page; i++) {
+ try { page = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find((t) => t.type === 'page'); } catch {}
+ if (!page) await new Promise((r) => setTimeout(r, 200));
+}
+const ws = new WebSocket(page.webSocketDebuggerUrl);
+await new Promise((r) => (ws.onopen = r));
+let id = 0; const pending = new Map();
+ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { pending.get(x.id)(x.result); pending.delete(x.id); } };
+const send = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+const ev = (e) => send('Runtime.evaluate', { returnByValue: true, awaitPromise: true, expression: e }).then((r) => r.result && r.result.value);
+
+await send('Page.enable');
+await send('Runtime.enable');
+await send('Emulation.setDeviceMetricsOverride', { width: +w, height: +h, deviceScaleFactor: 1, mobile: false });
+await send('Page.navigate', { url });
+await new Promise((r) => setTimeout(r, 1200));
+// Fonts and the logo have to be in before the pixels are read, or the capture
+// races the swap and lands on the fallback face.
+await ev(`document.fonts.ready.then(()=>'ok')`);
+await ev(`Promise.all([...document.images].map(i=>i.complete?1:new Promise(r=>{i.onload=i.onerror=r}))).then(()=>'ok')`);
+console.log('fonts loaded :', await ev(`[...document.fonts].filter(f=>f.status==='loaded').map(f=>f.family+' '+f.weight).join(', ')`));
+console.log('h1 lines :', await ev(`(()=>{const e=document.querySelector('h1');const cs=getComputedStyle(e);return Math.round(e.getBoundingClientRect().height/parseFloat(cs.lineHeight))+' line(s), '+Math.round(e.getBoundingClientRect().width)+'px wide'})()`));
+console.log('overflow :', await ev(`document.documentElement.scrollWidth+'x'+document.documentElement.scrollHeight`));
+
+const { data } = await send('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: +w, height: +h, scale: 1 }, captureBeyondViewport: true });
+await fs.writeFile(out, Buffer.from(data, 'base64'));
+console.log('wrote', out);
+ws.close(); proc.kill(); process.exit(0);