Skip to content

Commit d4fc682

Browse files
dani-polaniclaude
andcommitted
feat(seo): structured data, keyword titles, image dimensions, author bio
- H1: BreadcrumbList + TechArticle JSON-LD on example pages; breadcrumbs on /about, /api, /examples (reusable StructuredData component + builders) - H2: "Word Aligner" brand in SEO titles/og, keyword-lengthened inner titles; "Aligner" kept in product UI; add missing og:image to /api - M4: real width/height on example previews (CLS) via generated preview-dimensions.ts (examples:dimensions script) + og:image dims - M6: "About the creator" bio section + Person JSON-LD on /about Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2fa6fab commit d4fc682

10 files changed

Lines changed: 346 additions & 37 deletions

File tree

bitext/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"audit": "npm audit",
2121
"audit:ci": "npm audit --audit-level=high",
2222
"examples:render": "tsx scripts/render-example-previews.ts",
23+
"examples:dimensions": "tsx scripts/write-example-dimensions.ts",
2324
"examples:upload": "tsx scripts/upload-example-previews.ts"
2425
},
2526
"devDependencies": {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Reads the rendered preview PNGs in `.cache/example-previews/` and writes their intrinsic
3+
* pixel dimensions to `src/lib/examples/preview-dimensions.ts`. The example pages use these to
4+
* set `width`/`height` on the preview <img> (prevents layout shift / CLS) and og:image dimensions.
5+
*
6+
* Run after `npm run examples:render` (which populates the cache): `npm run examples:dimensions`.
7+
*/
8+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
9+
import { dirname, join } from 'node:path';
10+
import { fileURLToPath } from 'node:url';
11+
import { GALLERY_SLUGS } from './gallery-slugs.js';
12+
13+
const __dirname = dirname(fileURLToPath(import.meta.url));
14+
const BITEXT_ROOT = join(__dirname, '..');
15+
const CACHE_DIR = join(BITEXT_ROOT, '.cache', 'example-previews');
16+
const OUT_FILE = join(BITEXT_ROOT, 'src', 'lib', 'examples', 'preview-dimensions.ts');
17+
18+
/** Read width/height from a PNG IHDR chunk (bytes 16–24, big-endian). */
19+
function readPngSize(path: string): { width: number; height: number } {
20+
const buf = readFileSync(path);
21+
if (buf.length < 24 || buf.toString('ascii', 12, 16) !== 'IHDR') {
22+
throw new Error(`Not a PNG with IHDR: ${path}`);
23+
}
24+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
25+
}
26+
27+
function main(): void {
28+
const available = new Set(
29+
readdirSync(CACHE_DIR)
30+
.filter((f) => f.endsWith('.png'))
31+
.map((f) => f.replace(/\.png$/, ''))
32+
);
33+
34+
const entries: string[] = [];
35+
for (const slug of GALLERY_SLUGS) {
36+
if (!available.has(slug)) {
37+
throw new Error(`Missing preview PNG for "${slug}" — run \`npm run examples:render\` first.`);
38+
}
39+
const { width, height } = readPngSize(join(CACHE_DIR, `${slug}.png`));
40+
entries.push(`\t'${slug}': { width: ${width}, height: ${height} }`);
41+
}
42+
43+
const file = `// Generated by \`npm run examples:dimensions\` from .cache/example-previews/*.png.
44+
// Do not edit by hand — re-run after rendering previews.
45+
export interface PreviewDimensions {
46+
width: number;
47+
height: number;
48+
}
49+
50+
export const EXAMPLE_PREVIEW_DIMENSIONS: Record<string, PreviewDimensions> = {
51+
${entries.join(',\n')}
52+
};
53+
`;
54+
writeFileSync(OUT_FILE, file);
55+
console.log(`Wrote ${GALLERY_SLUGS.length} dimensions to ${OUT_FILE}`);
56+
}
57+
58+
main();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script lang="ts">
2+
/** Emits one or more JSON-LD blocks into <head>. Data is trusted (built from app constants). */
3+
let { data }: { data: object | object[] } = $props();
4+
const blocks = $derived(Array.isArray(data) ? data : [data]);
5+
6+
function safeJsonLd(obj: object): string {
7+
return (
8+
'<script type="application/ld+json">' +
9+
JSON.stringify(obj).replace(/</g, '\\u003c') +
10+
'</scr' +
11+
'ipt>'
12+
);
13+
}
14+
</script>
15+
16+
<!-- @html avoids nested <script> parsing issues; content is serialized from trusted constants -->
17+
<!-- eslint-disable svelte/no-at-html-tags -->
18+
<svelte:head>
19+
{#each blocks as block (block)}
20+
{@html safeJsonLd(block)}
21+
{/each}
22+
</svelte:head>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Generated by `npm run examples:dimensions` from .cache/example-previews/*.png.
2+
// Do not edit by hand — re-run after rendering previews.
3+
export interface PreviewDimensions {
4+
width: number;
5+
height: number;
6+
}
7+
8+
export const EXAMPLE_PREVIEW_DIMENSIONS: Record<string, PreviewDimensions> = {
9+
'english-french-word-alignment': { width: 1920, height: 622 },
10+
'turkish-interlinear-gloss-ipa': { width: 1920, height: 812 },
11+
'hebrew-arabic-english-rtl': { width: 1920, height: 952 },
12+
'tagalog-compound-word-alignment': { width: 1920, height: 598 },
13+
'japanese-chinese-english-word-order': { width: 1920, height: 940 },
14+
'classical-nahuatl-interlinear-gloss': { width: 1920, height: 696 },
15+
'nahuatl-leipzig-glossing-abbreviations': { width: 1920, height: 696 },
16+
'taiwanese-minnan-interlinear-gloss': { width: 1920, height: 696 },
17+
'lezgian-morpheme-gloss': { width: 1920, height: 696 },
18+
'turkish-infinitive-gloss-come-out': { width: 1920, height: 708 },
19+
'latin-zero-morpheme-gloss': { width: 1920, height: 708 },
20+
'tagalog-reduplication-interlinear': { width: 1920, height: 708 },
21+
'turkish-ablative-interlinear-gloss': { width: 1920, height: 696 },
22+
'french-clitic-pronoun-gloss': { width: 1920, height: 696 },
23+
'tagalog-verbal-aspect-paradigm': { width: 1920, height: 672 },
24+
'german-umlaut-plural-gloss': { width: 1920, height: 696 },
25+
'avar-camel-theft-interlinear': { width: 1920, height: 696 },
26+
'lojban-sumti-interlinear-gloss': { width: 1920, height: 696 },
27+
'russian-evening-run-interlinear': { width: 1920, height: 696 }
28+
};
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
ALIGNER_SITE_URL,
3+
SITE_AUTHOR_NAME,
4+
SITE_AUTHOR_URL,
5+
SITE_CONTACT_EMAIL
6+
} from '$lib/brand.js';
7+
import { SITE_LASTMOD } from '$lib/seo/metadata.js';
8+
9+
const author = {
10+
'@type': 'Person',
11+
name: SITE_AUTHOR_NAME,
12+
url: SITE_AUTHOR_URL
13+
};
14+
15+
/** Standalone Person entity for the creator (used on /about for authorship/E-E-A-T). */
16+
export function personCreator(): object {
17+
return {
18+
'@context': 'https://schema.org',
19+
'@type': 'Person',
20+
name: SITE_AUTHOR_NAME,
21+
url: SITE_AUTHOR_URL,
22+
email: SITE_CONTACT_EMAIL,
23+
description:
24+
'Fantasy author, creator of the constructed language Lemu Teloku, and maker of tools for conlangers and linguists. Psychologist and linguist by training, self-taught software developer.',
25+
knowsAbout: [
26+
'Constructed languages',
27+
'Interlinear glossing',
28+
'Leipzig Glossing Rules',
29+
'Word alignment',
30+
'Linguistics',
31+
'Software development'
32+
],
33+
sameAs: [SITE_AUTHOR_URL]
34+
};
35+
}
36+
37+
/** Absolute URL on the production origin (safe for prerendered pages). */
38+
export function absoluteUrl(path: string): string {
39+
return ALIGNER_SITE_URL + (path.startsWith('/') ? path : `/${path}`);
40+
}
41+
42+
export interface Crumb {
43+
name: string;
44+
path: string;
45+
}
46+
47+
export function breadcrumbList(crumbs: Crumb[]): object {
48+
return {
49+
'@context': 'https://schema.org',
50+
'@type': 'BreadcrumbList',
51+
itemListElement: crumbs.map((crumb, index) => ({
52+
'@type': 'ListItem',
53+
position: index + 1,
54+
name: crumb.name,
55+
item: absoluteUrl(crumb.path)
56+
}))
57+
};
58+
}
59+
60+
export interface TechArticleInput {
61+
headline: string;
62+
description: string;
63+
path: string;
64+
image: { url: string; width: number; height: number; alt: string };
65+
}
66+
67+
/**
68+
* TechArticle for an example page. `datePublished`/`dateModified` use the site-wide content date
69+
* (SITE_LASTMOD) — we have no per-example dates, and a single honest date is enough to qualify for
70+
* Article rich results while staying truthful.
71+
*/
72+
export function techArticle(input: TechArticleInput): object {
73+
return {
74+
'@context': 'https://schema.org',
75+
'@type': 'TechArticle',
76+
headline: input.headline,
77+
description: input.description,
78+
url: absoluteUrl(input.path),
79+
mainEntityOfPage: { '@type': 'WebPage', '@id': absoluteUrl(input.path) },
80+
image: {
81+
'@type': 'ImageObject',
82+
url: input.image.url,
83+
width: input.image.width,
84+
height: input.image.height,
85+
caption: input.image.alt
86+
},
87+
author,
88+
publisher: author,
89+
datePublished: SITE_LASTMOD,
90+
dateModified: SITE_LASTMOD,
91+
inLanguage: 'en'
92+
};
93+
}

bitext/src/routes/about/+page.svelte

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,28 @@
77
import PartnerBannerRailway from '$lib/components/partners/PartnerBannerRailway.svelte';
88
import PartnerBannerWise from '$lib/components/partners/PartnerBannerWise.svelte';
99
import SiteFooter from '$lib/components/layout/SiteFooter.svelte';
10+
import StructuredData from '$lib/components/seo/StructuredData.svelte';
11+
import { breadcrumbList, personCreator } from '$lib/seo/structured-data.js';
12+
import { SITE_AUTHOR_URL } from '$lib/brand.js';
1013
import { settingsStore } from '$lib/state/settings.svelte.js';
1114
1215
const TITLE = 'About';
1316
const DISPLAY_NAME = 'Word Aligner';
17+
const SEO_TITLE = 'About Word Aligner — Free Word Alignment & Gloss Tool';
1418
const DESCRIPTION =
1519
'Word Aligner: multi-line word alignment, interlinear glosses and IPA, RTL scripts, word-splitting rules, per-line typography, exports (PNG, SVG, PDF, HTML), and shareable URLs — for learners, teachers, and linguists.';
1620
1721
const canonical = $derived(page.url.origin + page.url.pathname);
1822
const ogImage = $derived(`${page.url.origin}/api/og`);
1923
24+
const structuredData = [
25+
breadcrumbList([
26+
{ name: DISPLAY_NAME, path: '/' },
27+
{ name: TITLE, path: '/about' }
28+
]),
29+
personCreator()
30+
];
31+
2032
const siteTheme = $derived(settingsStore.settings.theme);
2133
const themeIconBtn =
2234
'box-border m-0 inline-flex h-9 w-9 shrink-0 items-center justify-center border-0 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-gray-400';
@@ -132,12 +144,12 @@
132144
</script>
133145

134146
<svelte:head>
135-
<title>{TITLE} · {DISPLAY_NAME}</title>
147+
<title>{SEO_TITLE}</title>
136148
<meta name="description" content={DESCRIPTION} />
137149
<link rel="canonical" href={canonical} />
138150
<meta name="robots" content="index,follow" />
139151
<meta property="og:type" content="website" />
140-
<meta property="og:title" content={`${TITLE} · ${DISPLAY_NAME}`} />
152+
<meta property="og:title" content={SEO_TITLE} />
141153
<meta property="og:description" content={DESCRIPTION} />
142154
<meta property="og:url" content={canonical} />
143155
<meta property="og:image" content={ogImage} />
@@ -147,12 +159,14 @@
147159
<meta property="og:image:height" content="630" />
148160
<meta property="og:image:alt" content={`${TITLE} — ${DISPLAY_NAME}`} />
149161
<meta name="twitter:card" content="summary_large_image" />
150-
<meta name="twitter:title" content={`${TITLE} · ${DISPLAY_NAME}`} />
162+
<meta name="twitter:title" content={SEO_TITLE} />
151163
<meta name="twitter:description" content={DESCRIPTION} />
152164
<meta name="twitter:image" content={ogImage} />
153165
<meta name="twitter:image:alt" content={`${TITLE} — ${DISPLAY_NAME}`} />
154166
</svelte:head>
155167

168+
<StructuredData data={structuredData} />
169+
156170
<main
157171
class="mx-auto w-full max-w-3xl min-w-0 px-4 pt-4 pb-16 leading-relaxed text-gray-700 sm:px-6 md:pt-6 md:pb-20 dark:text-gray-300"
158172
>
@@ -246,6 +260,7 @@
246260
<li><a href="#doc-export-share" class={tocLinkClass}>Export and share</a></li>
247261
<li><a href="#doc-examples" class={tocLinkClass}>Examples and motion demos</a></li>
248262
<li><a href="#doc-partners" class={tocLinkClass}>Partner links</a></li>
263+
<li><a href="#doc-creator" class={tocLinkClass}>About the creator</a></li>
249264
<li><a href="#doc-contact" class={tocLinkClass}>Contact</a></li>
250265
<li><a href="#doc-privacy" class={tocLinkClass}>Privacy</a></li>
251266
</ul>
@@ -449,6 +464,26 @@
449464
<PartnerBannerWise />
450465
</div>
451466

467+
<h2 id="doc-creator" class={headingClass}>About the creator</h2>
468+
<p class="mt-3">
469+
{DISPLAY_NAME} is built by Dani Polani — a fantasy author, the creator of the constructed language
470+
Lemu Teloku, and a maker of tools for conlangers and linguists. A psychologist and linguist by training
471+
and a self-taught developer, Dani builds small, focused tools and likes automating the tedious parts.
472+
</p>
473+
<p class="mt-3">
474+
The same attention to interlinear glosses and Leipzig-style conventions that goes into
475+
documenting a constructed language shaped this tool. Alongside the language work there is a
476+
wider creative world — drawings, an encyclopedia of Lemu Teloku and its setting, and other
477+
handmade art projects. Offline, Dani is fond of literature, nineteenth-century technology, cats,
478+
and seals.
479+
</p>
480+
<p class="mt-3">
481+
More of Dani's work and tools:
482+
<a href={SITE_AUTHOR_URL} class={linkClass} target="_blank" rel="noopener noreferrer">
483+
danipolani.github.io
484+
</a>.
485+
</p>
486+
452487
<h2 id="doc-contact" class={headingClass}>Contact</h2>
453488
<p class="mt-3">
454489
Questions or feedback about {DISPLAY_NAME}:

bitext/src/routes/api/+page.svelte

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@
33
import { resolve } from '$app/paths';
44
import { ALIGNER_SITE_HOST } from '$lib/brand.js';
55
import SiteFooter from '$lib/components/layout/SiteFooter.svelte';
6+
import StructuredData from '$lib/components/seo/StructuredData.svelte';
7+
import { SITE_NAME } from '$lib/seo/metadata.js';
8+
import { breadcrumbList } from '$lib/seo/structured-data.js';
69
710
const TITLE = 'API';
11+
const SEO_TITLE = 'Word Aligner API — Generate Alignment URLs Programmatically';
812
const DESCRIPTION =
913
'Word Aligner API: generate a pre-filled alignment link by posting text lines and optional word-pair data. Free, no auth required.';
1014
1115
const canonical = $derived(page.url.origin + page.url.pathname);
1216
const apiBase = $derived(page.url.origin);
17+
const ogImage = $derived(`${page.url.origin}/api/og`);
18+
19+
const structuredData = breadcrumbList([
20+
{ name: SITE_NAME, path: '/' },
21+
{ name: TITLE, path: '/api' }
22+
]);
1323
1424
const linkClass =
1525
'font-medium text-primary-700 underline decoration-primary-700/40 underline-offset-2 hover:text-primary-800 hover:decoration-primary-800 dark:text-primary-400 dark:decoration-primary-400/50 dark:hover:text-primary-300';
@@ -31,16 +41,28 @@
3141
</script>
3242

3343
<svelte:head>
34-
<title>{TITLE} · Word Aligner</title>
44+
<title>{SEO_TITLE}</title>
3545
<meta name="description" content={DESCRIPTION} />
3646
<link rel="canonical" href={canonical} />
3747
<meta name="robots" content="index,follow" />
3848
<meta property="og:type" content="website" />
39-
<meta property="og:title" content="{TITLE} · Word Aligner" />
49+
<meta property="og:title" content={SEO_TITLE} />
4050
<meta property="og:description" content={DESCRIPTION} />
4151
<meta property="og:url" content={canonical} />
52+
<meta property="og:image" content={ogImage} />
53+
<meta property="og:image:secure_url" content={ogImage} />
54+
<meta property="og:image:type" content="image/png" />
55+
<meta property="og:image:width" content="1200" />
56+
<meta property="og:image:height" content="630" />
57+
<meta property="og:image:alt" content={SEO_TITLE} />
58+
<meta name="twitter:card" content="summary_large_image" />
59+
<meta name="twitter:title" content={SEO_TITLE} />
60+
<meta name="twitter:description" content={DESCRIPTION} />
61+
<meta name="twitter:image" content={ogImage} />
4262
</svelte:head>
4363

64+
<StructuredData data={structuredData} />
65+
4466
<main
4567
class="mx-auto w-full max-w-3xl min-w-0 px-4 pt-4 pb-16 leading-relaxed text-gray-700 sm:px-6 md:pt-6 md:pb-20 dark:text-gray-300"
4668
>

0 commit comments

Comments
 (0)