Skip to content

Commit 2fa6fab

Browse files
committed
seo audit
1 parent 4f254c7 commit 2fa6fab

11 files changed

Lines changed: 454 additions & 19 deletions

File tree

.vscode/settings.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"workbench.colorCustomizations": {
3+
"statusBar.background": "#3b82f6",
4+
"statusBar.foreground": "#e7e7e7",
5+
"statusBarItem.hoverBackground": "#6ca1f8",
6+
"statusBarItem.remoteBackground": "#3b82f6",
7+
"statusBarItem.remoteForeground": "#e7e7e7",
8+
"tab.activeBorder": "#6ca1f8"
9+
},
10+
"peacock.color": "#3b82f6"
11+
}

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ENV NODE_ENV=production
2020

2121
# adapter-node: HOST defaults to 0.0.0.0; Railway sets PORT.
2222
COPY --from=builder /app/build ./build
23+
COPY --from=builder /app/server.js ./server.js
2324
COPY --from=builder /app/package.json ./package.json
2425
COPY --from=builder /app/node_modules ./node_modules
2526

@@ -28,4 +29,5 @@ USER node
2829

2930
EXPOSE 3000
3031

31-
CMD ["node", "build"]
32+
# Custom server wraps adapter-node's handler to add security headers to every response.
33+
CMD ["node", "server.js"]

bitext/server.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Production server: wraps adapter-node's request handler so baseline security headers
2+
// are added to EVERY response — including prerendered pages and static assets, which the
3+
// SvelteKit `handle` hook does not cover. Run via `node server.js` (see Dockerfile).
4+
import http from 'node:http';
5+
import process from 'node:process';
6+
import { handler } from './build/handler.js';
7+
8+
const host = process.env.HOST || '0.0.0.0';
9+
const port = Number(process.env.PORT || 3000);
10+
const shutdownTimeout = Number(process.env.SHUTDOWN_TIMEOUT || 30) * 1000;
11+
12+
// HSTS is safe because the site is HTTPS-only (Railway terminates TLS, redirects HTTP).
13+
// No CSP yet: the app loads Google Fonts, GA, Tally, and a DigitalOcean CDN, so a correct
14+
// policy needs its own change.
15+
const SECURITY_HEADERS = {
16+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
17+
'X-Content-Type-Options': 'nosniff',
18+
'X-Frame-Options': 'SAMEORIGIN',
19+
'Referrer-Policy': 'strict-origin-when-cross-origin',
20+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()'
21+
};
22+
23+
const server = http.createServer((req, res) => {
24+
// Set before the handler writes; merged into the final response headers.
25+
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
26+
res.setHeader(name, value);
27+
}
28+
handler(req, res, () => {
29+
res.statusCode = 404;
30+
res.end('Not Found');
31+
});
32+
});
33+
34+
server.listen(port, host, () => {
35+
console.log(`Listening on http://${host}:${port}`);
36+
});
37+
38+
function gracefulShutdown() {
39+
server.closeIdleConnections?.();
40+
server.close(() => process.exit(0));
41+
setTimeout(() => process.exit(0), shutdownTimeout).unref();
42+
}
43+
44+
process.on('SIGTERM', gracefulShutdown);
45+
process.on('SIGINT', gracefulShutdown);

bitext/src/lib/brand.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export const ALIGNER_SITE_HOST = 'aligner.tinygods.dev';
77
/** Public contact for privacy, about, and similar pages. */
88
export const SITE_CONTACT_EMAIL = 'dani@tinygods.dev';
99

10+
/** Creator attribution (footer, structured data). */
11+
export const SITE_AUTHOR_NAME = 'Dani Polani';
12+
export const SITE_AUTHOR_URL = 'https://danipolani.github.io/en/';
13+
1014
/** User-facing product name in copy (legal/site title may differ). */
1115
export const ALIGNER_DISPLAY_NAME = 'Aligner';
1216

bitext/src/lib/components/seo/JsonLd.svelte

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,66 @@
11
<script lang="ts">
2+
import {
3+
ALIGNER_SITE_URL,
4+
SITE_AUTHOR_NAME,
5+
SITE_AUTHOR_URL,
6+
SITE_CONTACT_EMAIL
7+
} from '$lib/brand.js';
8+
import { SITE_NAME, DEFAULT_DESCRIPTION } from '$lib/seo/metadata.js';
9+
210
/**
3-
* FAQPage JSON-LD for the visible FAQ block in `SeoSections.svelte`.
11+
* Homepage structured data: WebSite, WebApplication, and FAQPage.
412
*
5-
* The `SoftwareApplication` schema used to live here too, but Google's docs require a real
6-
* `aggregateRating` or `review` on that type — with no legitimate review data we were shipping
7-
* invalid markup that Google ignores. We will add it back when there is something to rate.
13+
* WebApplication intentionally omits `aggregateRating`/`offers.review`. Google's *rich result*
14+
* for software needs a real rating, which we do not have, so this will not produce a rich
15+
* result — but the markup is valid and helps search engines and LLMs identify what the tool is
16+
* (entity resolution, AI Overviews/ChatGPT/Perplexity citation). Add a rating block here only
17+
* when there is legitimate review data.
818
*
919
* FAQ rich results are effectively restricted to well-known health/government sites since 2023
10-
* (https://developers.google.com/search/blog/2023/08/howto-faq-changes) so this markup is kept
11-
* mainly for semantic coverage and ordinary snippet quality. The answer text here mirrors the
12-
* visible FAQ copy on the page, which Google requires for any FAQPage markup.
20+
* (https://developers.google.com/search/blog/2023/08/howto-faq-changes) so the FAQPage block is
21+
* kept mainly for semantic coverage and AI citation. The answer text mirrors the visible FAQ
22+
* copy on the page, which Google requires for any FAQPage markup.
1323
*/
24+
const creator = {
25+
'@type': 'Person',
26+
name: SITE_AUTHOR_NAME,
27+
url: SITE_AUTHOR_URL,
28+
email: SITE_CONTACT_EMAIL
29+
};
30+
31+
const website = {
32+
'@context': 'https://schema.org',
33+
'@type': 'WebSite',
34+
name: SITE_NAME,
35+
url: ALIGNER_SITE_URL + '/',
36+
description: DEFAULT_DESCRIPTION,
37+
inLanguage: 'en',
38+
publisher: creator
39+
};
40+
41+
const webApplication = {
42+
'@context': 'https://schema.org',
43+
'@type': 'WebApplication',
44+
name: SITE_NAME,
45+
url: ALIGNER_SITE_URL + '/',
46+
description: DEFAULT_DESCRIPTION,
47+
applicationCategory: 'EducationalApplication',
48+
operatingSystem: 'Any',
49+
browserRequirements: 'Requires a modern web browser with JavaScript enabled',
50+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
51+
featureList: [
52+
'Word-by-word translation visualization',
53+
'Interlinear gloss support',
54+
'IPA tier support',
55+
'Right-to-left script support (Hebrew, Arabic)',
56+
'Export to PNG, SVG, PDF, and HTML',
57+
'Shareable URLs',
58+
'Free REST API'
59+
],
60+
inLanguage: 'en',
61+
creator
62+
};
63+
1464
const faq = {
1565
'@context': 'https://schema.org',
1666
'@type': 'FAQPage',
@@ -71,5 +121,7 @@
71121
<!-- JSON-LD is trusted (serialized from app constants); @html avoids nested script parsing issues -->
72122
<!-- eslint-disable svelte/no-at-html-tags -->
73123
<svelte:head>
124+
{@html safeJsonLd(website)}
125+
{@html safeJsonLd(webApplication)}
74126
{@html safeJsonLd(faq)}
75127
</svelte:head>

bitext/src/lib/seo/metadata.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
export const SITE_NAME = 'Word Aligner';
22

3+
/**
4+
* Last meaningful content update, used for `<lastmod>` in the sitemap. Google reads lastmod as a
5+
* crawl-scheduling hint, so keep this honest — bump it when page copy or examples change, not on
6+
* every deploy. A single accurate date is better than a fabricated per-deploy timestamp.
7+
*/
8+
export const SITE_LASTMOD = '2026-06-24';
9+
310
/**
411
* Title and description favor colloquial, user-first phrasing while keeping the formal category
512
* term ("word-by-word translation visualizer") so the page still matches exact-intent searches.
613
* Audience signals (learners, teachers, conlang posts) help Google pair the page with relevant
714
* long-tail queries without spamming keywords.
815
*/
916
export const DEFAULT_TITLE = 'Word-by-word translation visualizer — see which words match';
17+
/** Kept ≤160 chars so Google does not truncate the SERP snippet. */
1018
export const DEFAULT_DESCRIPTION =
11-
'Word Aligner: free word-by-word translation visualizer. Stack multiple lines, link adjacent rows in the preview, optional gloss/IPA lines, exports (PNG, SVG, PDF, HTML), and shareable URLs. For learners, teachers, linguists, and conlang posts.';
19+
'Free word-by-word translation visualizer. Stack lines, add gloss/IPA, draw connectors, then export PNG, SVG, or PDF. For learners, teachers, and linguists.';

bitext/src/routes/+layout.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import '../app.css';
55
import { registerAffiliateLinkClickTracking } from '$lib/analytics/affiliate-link-tracking.js';
66
import { GA_MEASUREMENT_ID } from '$lib/brand.js';
7+
import { SITE_NAME } from '$lib/seo/metadata.js';
78
import { flowbiteTheme } from '$lib/flowbite-theme.js';
89
import { settingsStore } from '$lib/state/settings.svelte.js';
910
import { viewportStore } from '$lib/state/viewport.svelte.js';
@@ -43,6 +44,7 @@
4344
</script>
4445

4546
<svelte:head>
47+
<meta property="og:site_name" content={SITE_NAME} />
4648
<meta name="impact-site-verification" value="a52264b4-df2c-48b2-90cb-a4c5e81c208b" />
4749
<link rel="icon" href="/favicon.ico" sizes="any" />
4850
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />

bitext/src/routes/api/og/+server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export const GET: RequestHandler = async ({ url }) => {
2929
headers: {
3030
'Content-Type': 'image/png',
3131
'Content-Length': String(buffer.length),
32-
'Cache-Control': 'public, max-age=3600'
32+
'Cache-Control': 'public, max-age=3600',
33+
// Generated social-card image, not a document — keep it out of the index.
34+
'X-Robots-Tag': 'noindex'
3335
}
3436
});
3537
};

bitext/src/routes/sitemap.xml/+server.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import type { RequestHandler } from '@sveltejs/kit';
22
import { GALLERY_EXAMPLES } from '$lib/examples/catalog.js';
3+
import { SITE_LASTMOD } from '$lib/seo/metadata.js';
34

5+
// `changefreq`/`priority` are omitted on purpose — Google ignores both. Only `loc` and `lastmod`
6+
// carry weight.
47
export const GET: RequestHandler = ({ url }) => {
58
const base = url.origin;
6-
const exampleUrls = GALLERY_EXAMPLES.map(
7-
(e) =>
8-
` <url><loc>${base}/examples/${e.slug}</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`
9-
).join('\n');
9+
const entry = (loc: string) => ` <url><loc>${loc}</loc><lastmod>${SITE_LASTMOD}</lastmod></url>`;
10+
const exampleUrls = GALLERY_EXAMPLES.map((e) => entry(`${base}/examples/${e.slug}`)).join('\n');
1011
const body = `<?xml version="1.0" encoding="UTF-8"?>
1112
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
12-
<url><loc>${base}/</loc><changefreq>weekly</changefreq><priority>1</priority></url>
13-
<url><loc>${base}/examples</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>
13+
${entry(`${base}/`)}
14+
${entry(`${base}/examples`)}
1415
${exampleUrls}
15-
<url><loc>${base}/about</loc><changefreq>monthly</changefreq><priority>0.6</priority></url>
16-
<url><loc>${base}/api</loc><changefreq>monthly</changefreq><priority>0.4</priority></url>
17-
<url><loc>${base}/privacy</loc><changefreq>yearly</changefreq><priority>0.3</priority></url>
16+
${entry(`${base}/about`)}
17+
${entry(`${base}/api`)}
18+
${entry(`${base}/privacy`)}
1819
</urlset>`;
1920
return new Response(body, {
2021
headers: {

bitext/static/llms.txt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Word Aligner
2+
3+
> Free, browser-based tool for visualizing word-by-word translation alignment and interlinear glosses. Stack multiple lines of text, draw connectors between matching words or morphemes, add IPA and gloss tiers, support right-to-left scripts, and export to PNG, SVG, PDF, or HTML. Everything runs client-side; sentences are never stored unless the user chooses to share them. A free, no-auth REST API is available.
4+
5+
Built for language learners, language teachers, linguists, and conlangers. Created by Dani Polani (dani@tinygods.dev).
6+
7+
## Core pages
8+
- [Word Aligner app](https://aligner.tinygods.dev/): The interactive editor — type or paste stacked lines, click a word and its match on the adjacent line to draw a connector, then export or share.
9+
- [About](https://aligner.tinygods.dev/about): What the tool does, the line editor, settings, export and share options, and privacy approach.
10+
- [API](https://aligner.tinygods.dev/api): Free REST API. POST text lines and optional alignment pairs to get a shareable URL; GET to pre-fill the editor. No authentication.
11+
- [Examples](https://aligner.tinygods.dev/examples): Gallery of word alignment and interlinear gloss examples across many languages.
12+
13+
## Example walkthroughs
14+
- [English and French word alignment](https://aligner.tinygods.dev/examples/english-french-word-alignment): Simple bilingual one-to-one and one-to-many word matches.
15+
- [Turkish interlinear gloss with IPA](https://aligner.tinygods.dev/examples/turkish-interlinear-gloss-ipa): Four-line interlinear layout — morpheme glosses, IPA, source text, translation.
16+
- [Hebrew and Arabic with English (RTL)](https://aligner.tinygods.dev/examples/hebrew-arabic-english-rtl): Right-to-left scripts, bound vs free prepositions, crossing links.
17+
- [Japanese, Chinese, and English word order](https://aligner.tinygods.dev/examples/japanese-chinese-english-word-order): SOV vs SVO word order with crossing connectors across three lines.
18+
- [Classical Nahuatl interlinear gloss](https://aligner.tinygods.dev/examples/classical-nahuatl-interlinear-gloss): Polysynthetic verb morphology in an interlinear layout.
19+
- [Lezgian morpheme gloss](https://aligner.tinygods.dev/examples/lezgian-morpheme-gloss): Morpheme-by-morpheme glossing of a low-resource language.
20+
- [Lojban sumti interlinear gloss](https://aligner.tinygods.dev/examples/lojban-sumti-interlinear-gloss): Interlinear gloss of a constructed-language sentence.
21+
22+
## Optional
23+
- [Privacy policy](https://aligner.tinygods.dev/privacy): Browser-only processing; no server-side storage of user text.

0 commit comments

Comments
 (0)