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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ preview: ## @setup Serve the built site, search included, on the development por
@echo "Built site at $(SITE_URL)"

.PHONY: og-image
og-image: ## @setup Draw the link-preview card into public/og-image.png
og-image: ## @setup Draw the link-preview cards into public/og-image.png and public/og/
@docker run --rm -u $(HOST_USER) -e HOME=/tmp -v $(CURDIR):/app -w /app python:3.12-slim \
sh -c "pip install --quiet --no-cache-dir --user pillow==11.3.0 && python tools/og-image.py"

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ To serve the built site, search included, as it is published, run:
make preview
```

To draw the link-preview card into `public/og-image.png`, run:
To draw the link-preview cards, `public/og-image.png` and one per library in `public/og/`, run the command
below. Run it again after adding a library to the catalog:

```bash
make og-image
Expand Down
Binary file added public/og/building-blocks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/collection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/country.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/currency.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/docker-container.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/encoder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/environment-variable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/http-correlation-id.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/http-error-handler.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/http-health-check.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/http-logging.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/http-query.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/http.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/ksuid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/logger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/mapper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/math.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/outbox.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/time.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/value-object.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/components/LibraryPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const relations = [
description={description}
path={`/${data.name}/`}
kind="article"
image={{ path: `/og/${data.name}.png`, alt: packageName }}
markdown={`/${data.name}.md`}
structuredData={{
'@graph': [
{
Expand Down
49 changes: 49 additions & 0 deletions src/content/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { demote, libraryMarkdown } from './markdown'

describe('the Markdown of a library', () => {
it('opens with what the library is and how to install it, then carries its documentation', () => {
// Given a library with its documentation
const library = {
name: 'time',
title: 'Time',
description: 'Models time as immutable value objects for PHP.',
version: '2.7.2',
body: '\n## Overview\n\nInstants and durations.\n',
}

// When its Markdown is drawn
const markdown = libraryMarkdown(library, 'https://tiny-blocks.github.io/time/')

// Then it names the package, its version and its page, then the documentation follows
expect(markdown).toBe(
[
'# Time',
'',
'> Models time as immutable value objects for PHP.',
'',
'The Composer package `tiny-blocks/time`, at version 2.7.2, documented at https://tiny-blocks.github.io/time/.',
'',
'Install it with `composer require tiny-blocks/time`.',
'',
'## Overview',
'',
'Instants and durations.',
'',
].join('\n'),
)
})
})

describe('a document moved one heading level down', () => {
it('moves every heading down and leaves the lines of a code block alone', () => {
// Given a document with headings and a code block whose comment opens with a hash
const markdown = ['# Encoder', '', '## How to use', '', '```bash', '# Output: Hello', '```'].join('\n')

// When it is moved down
const demoted = demote(markdown)

// Then the headings move and the comment stays
expect(demoted).toBe(['## Encoder', '', '### How to use', '', '```bash', '# Output: Hello', '```'].join('\n'))
})
})
45 changes: 45 additions & 0 deletions src/content/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* The documentation of a library as plain Markdown, for the language models and agents that read the site
* rather than render it: what the library is, how to install it, then its documentation as its page shows it.
*/

export interface MarkdownSource {
readonly name: string
readonly title: string
readonly description: string
readonly version: string
readonly body: string
}

/** Answers the Markdown of a library, whose page is at the given address. */
export function libraryMarkdown(library: MarkdownSource, page: string): string {
const packageName = `tiny-blocks/${library.name}`
return [
`# ${library.title}`,
'',
`> ${library.description}`,
'',
`The Composer package \`${packageName}\`, at version ${library.version}, documented at ${page}.`,
'',
`Install it with \`composer require ${packageName}\`.`,
'',
library.body.trim(),
'',
].join('\n')
}

const FENCE = /^\s*(```|~~~)/

/** Moves every heading one level down, so a document nests under a heading of its own, code left alone. */
export function demote(markdown: string): string {
let fenced = false
return markdown
.split('\n')
.map((line) => {
if (FENCE.test(line)) {
fenced = !fenced
}
return !fenced && /^#{1,5} /.test(line) ? `#${line}` : line
})
.join('\n')
}
20 changes: 17 additions & 3 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,27 @@ interface Props {
noindex?: boolean
/** The schema.org description of the page, published as JSON-LD for search engines. */
structuredData?: Record<string, unknown>
/** The link-preview card of the page from the root, and what it shows. The card of the site otherwise. */
image?: { path: string; alt: string }
/** The address from the root of the same page as plain Markdown, for language models and agents. */
markdown?: string
}
const { title, description, path, kind = 'website', noindex = false, structuredData } = Astro.props
const {
title,
description,
path,
kind = 'website',
noindex = false,
structuredData,
image = { path: '/og-image.png', alt: SITE.name },
markdown,
} = Astro.props
const documentTitle = title ? `${title} · ${SITE.name}` : SITE.meta.title
const documentDescription = description ?? SITE.meta.description

const origin = Astro.site ?? Astro.url.origin
const canonical = path === null ? null : new URL(path, origin).href
const ogImage = new URL('/og-image.png', origin).href
const ogImage = new URL(image.path, origin).href
/** Escapes `<` so nothing in the data can close the script element it is published in. */
const jsonLd = structuredData
? JSON.stringify({ '@context': 'https://schema.org', ...structuredData }).replace(/</g, '\\u003c')
Expand Down Expand Up @@ -63,11 +76,12 @@ const jsonLd = structuredData
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={SITE.name} />
<meta property="og:image:alt" content={image.alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="alternate" type="text/plain" href="/llms.txt" title="Tiny Blocks for language models" />
{markdown ? <link rel="alternate" type="text/markdown" href={markdown} /> : null}
{jsonLd ? <script type="application/ld+json" set:html={jsonLd} /> : null}
</head>
<body>
Expand Down
18 changes: 18 additions & 0 deletions src/pages/[library].md.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { APIRoute, GetStaticPaths } from 'astro'
import { type Library, libraries } from '../content'
import { libraryMarkdown } from '../content/markdown'

export const getStaticPaths = (async () =>
(await libraries()).map((library) => ({
params: { library: library.data.name },
props: { library },
}))) satisfies GetStaticPaths

/** The documentation of one library as plain Markdown, beside its page, for language models and agents. */
export const GET: APIRoute<{ library: Library }> = ({ props, site, url }) => {
const { data, body } = props.library
const page = new URL(`/${data.name}/`, site ?? url.origin).href
return new Response(libraryMarkdown({ ...data, body: body ?? '' }, page), {
headers: { 'content-type': 'text/markdown; charset=utf-8' },
})
}
25 changes: 25 additions & 0 deletions src/pages/llms-full.txt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro'
import { libraries } from '../content'
import { demote, libraryMarkdown } from '../content/markdown'
import { SITE, TEXT } from '../content/site'

/**
* The whole documentation of the site for language models in one file, the companion of llms.txt: what
* Tiny Blocks is, then every library as its Markdown, one after another under its own heading.
*/
export const GET: APIRoute = async ({ site, url }) => {
const origin = site ?? new URL(url.origin)
const sections = (await libraries()).map(({ data, body }) =>
demote(libraryMarkdown({ ...data, body: body ?? '' }, new URL(`/${data.name}/`, origin).href)),
)
const lines = [
`# ${SITE.name}`,
'',
`> ${TEXT.home.lede}`,
'',
'Every library is a Composer package named `tiny-blocks/<name>`, installed with `composer require tiny-blocks/<name>`.',
'',
...sections,
]
return new Response(lines.join('\n'), { headers: { 'content-type': 'text/plain; charset=utf-8' } })
}
7 changes: 4 additions & 3 deletions src/pages/llms.txt.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { APIRoute } from 'astro'
import { libraries } from '../content'
import { rawUrl } from '../content/catalog'
import { SITE, TEXT } from '../content/site'

/**
* The index of the site for language models, in the llms.txt format: what Tiny Blocks is and every library
* with its summary, its page and the README it is documented by, as plain Markdown.
* with its summary, its page and its documentation as plain Markdown, beside the whole of it in llms-full.txt.
*/
export const GET: APIRoute = async ({ site, url }) => {
const origin = site ?? new URL(url.origin)
Expand All @@ -16,11 +15,13 @@ export const GET: APIRoute = async ({ site, url }) => {
'',
'Every library is a Composer package named `tiny-blocks/<name>`, installed with `composer require tiny-blocks/<name>`.',
'',
`The documentation of every library in one file: ${new URL('/llms-full.txt', origin).href}`,
'',
'## Libraries',
'',
...(await libraries()).map(
(library) =>
`- [tiny-blocks/${library.data.name}](${new URL(`/${library.data.name}/`, origin).href}): ${library.data.description} README: ${rawUrl(library.data.name, 'README.md')}`,
`- [tiny-blocks/${library.data.name}](${new URL(`/${library.data.name}/`, origin).href}): ${library.data.description} Markdown: ${new URL(`/${library.data.name}.md`, origin).href}`,
),
'',
]
Expand Down
55 changes: 47 additions & 8 deletions tools/og-image.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Draws the link-preview card of the site, `public/og-image.png`, at 1200 by 630.
"""Draws the link-preview cards of the site at 1200 by 630: `public/og-image.png`, and one per library at
`public/og/<name>.png`.

The card carries the logo (`public/logo.png`, exported from `public/logo.svg`), the name and the promise of
Tiny Blocks over a faint grid of dots, in the faces of the site. Run it through `make og-image`, which
supplies Pillow in a container.
The card of the site carries the logo (`public/logo.png`, exported from `public/logo.svg`), the name and the
promise of Tiny Blocks over a faint grid of dots, in the faces of the site. The card of a library carries
the logo and the name of its package instead. The libraries are the ones the catalog of the site lists, so
adding one to the catalog asks for running it again. Run it through `make og-image`, which supplies Pillow
in a container.
"""

import sys
import re
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont
Expand Down Expand Up @@ -34,17 +37,53 @@ def studs(card: Image.Image) -> None:
card.alpha_composite(layer)


def main(output: str) -> None:
def libraries() -> list[str]:
"""The name of every library the catalog of the site lists, in its order."""
catalog = (ROOT / 'src' / 'content' / 'catalog.ts').read_text()
return re.findall(r"\{ name: '([\w-]+)', area:", catalog)


def blank() -> tuple[Image.Image, ImageDraw.ImageDraw]:
"""A card with the studs and the logo, ready to be written on."""
card = Image.new('RGBA', (WIDTH, HEIGHT), PAGE)
studs(card)
logo = Image.open(ROOT / 'public' / 'logo.png').convert('RGBA').resize((170, 170), Image.LANCZOS)
card.alpha_composite(logo, (64, 80))
draw = ImageDraw.Draw(card)
return card, ImageDraw.Draw(card)


def fitted(text: str, family: str, weight: int, size: int, width: int) -> ImageFont.FreeTypeFont:
"""The face at the given size, or smaller until the text fits the given width."""
face = font(family, weight, size)
while face.getlength(text) > width:
size -= 4
face = font(family, weight, size)
return face


def site(output: Path) -> None:
card, draw = blank()
draw.text((80, 280), 'Tiny Blocks', font=font('bricolage-grotesque', 700, 120), fill=INK)
draw.text((84, 430), 'Small, focused PHP libraries.', font=font('ibm-plex-sans', 400, 40), fill=MUTED)
draw.text((84, 520), 'tiny-blocks.github.io', font=font('jetbrains-mono', 500, 28), fill=ACCENT)
card.convert('RGB').save(output, optimize=True)


def library(name: str, output: Path) -> None:
card, draw = blank()
draw.text((84, 290), 'tiny-blocks/', font=font('jetbrains-mono', 500, 40), fill=MUTED)
draw.text((80, 340), name, font=fitted(name, 'bricolage-grotesque', 700, 120, WIDTH - 160), fill=INK)
draw.text((84, 520), f'tiny-blocks.github.io/{name}/', font=font('jetbrains-mono', 500, 28), fill=ACCENT)
card.convert('RGB').save(output, optimize=True)


def main() -> None:
site(ROOT / 'public' / 'og-image.png')
cards = ROOT / 'public' / 'og'
cards.mkdir(exist_ok=True)
for name in libraries():
library(name, cards / f'{name}.png')


if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'public/og-image.png')
main()
17 changes: 17 additions & 0 deletions tools/og-image.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { existsSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'

describe('the link-preview cards of the catalog', () => {
it('has a card drawn for every library, so make og-image ran after the last one was added', () => {
// Given every library of the catalog, read the way tools/og-image.py reads it
const catalog = readFileSync('src/content/catalog.ts', 'utf8')
const names = [...catalog.matchAll(/\{ name: '([\w-]+)', area:/g)].map((match) => match[1])

// When the cards in public/og are looked up
const missing = names.filter((name) => !existsSync(`public/og/${name}.png`))

// Then the catalog is read and none is missing
expect(names.length).toBeGreaterThan(0)
expect(missing).toEqual([])
})
})