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
62 changes: 62 additions & 0 deletions scripts/covers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Makes the WebP copies of every photo cover — a post whose front matter has a
// `coverImage:` that is a PNG or a JPEG and no drawn `thumbnail:` — that the
// pages show in its place:
//
// <file>.webp the cover at full size, above the post
// <file>-square.webp 176px square, for the 88px tile in post lists
//
// The original stays where it is: social cards and the feed still point at it,
// and a scraper that cannot read WebP is not worth losing a preview to. A post
// whose copies are missing just keeps showing the original (see `coverWebp` in
// src/lib/data/blog-posts/utils.ts). Existing copies are never overwritten;
// delete one to make it again.
//
// node scripts/covers.mjs
//
// Needs ImageMagick 7 (`magick`) on the PATH.
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const posts = join(root, 'src/routes/(blog-article)');
const QUALITY = '80';

let made = 0;
for (const slug of readdirSync(posts)) {
const file = join(posts, slug, '+page.md');
if (!existsSync(file)) continue;
const front = readFileSync(file, 'utf8').split(/^---$/m)[1] ?? '';
const cover = front.match(/^coverImage:\s*'?([^'\s]+)'?\s*$/m)?.[1];
if (!cover || /^thumbnail:/m.test(front)) continue;
const base = cover.replace(/\.(png|jpe?g)$/i, '');
if (base === cover) continue;

const source = join(root, 'static', cover);
if (!existsSync(source)) continue;
const full = join(root, 'static', `${base}.webp`);
const square = join(root, 'static', `${base}-square.webp`);

if (!existsSync(full)) {
execFileSync('magick', [source, '-quality', QUALITY, full]);
made++;
}
if (!existsSync(square)) {
// `^` fills the square, the crop takes its middle: what object-fit: cover did.
execFileSync('magick', [
source,
'-resize',
'176x176^',
'-gravity',
'center',
'-extent',
'176x176',
'-quality',
QUALITY,
square
]);
made++;
}
}
console.log(`${made} file${made === 1 ? '' : 's'} written`);
18 changes: 16 additions & 2 deletions src/lib/components/atoms/Image.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@
interface Props {
src: string;
alt: string;
/** A WebP copy of `src`, offered first; `src` stays for anything that cannot read it. */
webp?: string | undefined;
fullBleed?: boolean | undefined;
}

let { src, alt, fullBleed = undefined }: Props = $props();
let { src, alt, webp = undefined, fullBleed = undefined }: Props = $props();
</script>

<img {src} {alt} loading="lazy" decoding="async" class:full-bleed={fullBleed} />
<!--
The picture only when there is a WebP to offer. Inside a post the image is
placed by the article's grid (`> .full-bleed`), which a wrapper would hide
it from.
-->
{#if webp}
<picture>
<source srcset={webp} type="image/webp" />
<img {src} {alt} loading="lazy" decoding="async" class:full-bleed={fullBleed} />
</picture>
{:else}
<img {src} {alt} loading="lazy" decoding="async" class:full-bleed={fullBleed} />
{/if}

<style lang="scss">
img {
Expand Down
9 changes: 7 additions & 2 deletions src/lib/components/atoms/Seo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@
}: Props = $props();

// A long title already fills a results line; the suffix would only push
// the words that matter past the cut.
let pageTitle = $derived(title ? (title.length > 56 ? title : `${title} | LangX`) : defaultTitle);
// the words that matter past the cut. It goes on only when the whole fits
// the sixty characters a results line shows: at 56, as it was, a title of
// 53 to 56 came out at 61 to 64, and that was more than a hundred pages.
const SUFFIX = ' | LangX';
let pageTitle = $derived(
title ? (title.length + SUFFIX.length > 60 ? title : `${title}${SUFFIX}`) : defaultTitle
);
let canonical = $derived(`${siteBaseUrl}${path}`);
let cardImage = $derived(ogImage ?? image);
let cardAlt = $derived(ogImage ? (ogImageAlt ?? pageTitle) : imageAlt);
Expand Down
8 changes: 7 additions & 1 deletion src/lib/components/molecules/BlogPostCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
/** A row in a list of posts — the app's own list grammar, not a card. */
title: string;
coverImage?: string | undefined;
/** The cover's WebP copies; the tile takes the small square. */
coverWebp?: { full: string; square: string } | undefined;
/** A square made for lists; the cover is cropped to fit where there is none. */
thumbnail?: string | undefined;
excerpt: string;
Expand All @@ -21,6 +23,7 @@
let {
title,
coverImage = undefined,
coverWebp = undefined,
thumbnail = undefined,
excerpt,
slug,
Expand All @@ -42,7 +45,10 @@
{#if showImage}
<span class="thumb">
{#if photo}
<img src={photo} alt="" loading="lazy" decoding="async" />
<picture>
{#if coverWebp}<source srcset={coverWebp.square} type="image/webp" />{/if}
<img src={photo} alt="" loading="lazy" decoding="async" />
</picture>
{:else}
<PostThumb {title} {slug} {tags} {apps} />
{/if}
Expand Down
9 changes: 7 additions & 2 deletions src/lib/components/organisms/Footer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
<div class="container groups">
{#each groups as group}
<div class="group">
<h3>{group.title}</h3>
<h2>{group.title}</h2>
<ul role="list">
{#each group.links as link}
<li>
Expand Down Expand Up @@ -193,10 +193,15 @@
}

.group {
h3 {
// An h2, not the h3 it was: on a page with no h2 of its own (the legal
// pages, most tool hubs) an h3 here skipped a level straight after the
// h1. The two lines below undo what the base h2 adds over an h3.
h2 {
margin: 0 0 6px;
font-size: 0.8125rem;
font-weight: 800;
line-height: 1.25;
letter-spacing: normal;
color: var(--color--text);
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/organisms/Hero.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
data-reveal-children
use:reveal={{ children: true, onLoad: true, stagger: 0.12 }}
>
<h1>The friendly way to practise a language with real people</h1>
<h1>The friendly language exchange app: practise with real people</h1>
<div class="buttons" use:ownsPrimary>
<Button href="https://get.langx.io" variant="primary" size="lg" block>Start for free</Button>
<Button href="https://get.langx.io" variant="secondary" size="lg" block>
Expand Down
20 changes: 20 additions & 0 deletions src/lib/data/blog-posts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Prism from 'prismjs';
// Referenced so the import above is not tree-shaken away.
void Prism;
import 'prism-svelte';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Component } from 'svelte';
import { render as renderToHtml } from 'svelte/server';
import readingTime from 'reading-time';
Expand All @@ -29,6 +31,7 @@ export const importPosts = (render = false) => {
if (post) {
posts.push({
...post.metadata,
coverWebp: coverWebp(post.metadata),
// Svelte 5 components have no render() of their own; this is its
// replacement. The comments are hydration markers, of no use in a feed.
html: render ? renderToHtml(post.default).body.replace(/<!--[\s\S]*?-->/g, '') : undefined
Expand All @@ -39,6 +42,23 @@ export const importPosts = (render = false) => {
return posts;
};

/**
* The WebP copies `scripts/covers.mjs` makes of a photo cover, if both are
* there. Looked for rather than assumed: a picture's WebP source that is
* missing is a broken image, not a fall back to the original. Paths are from
* the project root, where the build and the dev server both run.
*/
const coverWebp = ({ coverImage, thumbnail }: BlogPost) => {
if (!coverImage || thumbnail) return undefined;
const base = coverImage.replace(/\.(png|jpe?g)$/i, '');
if (base === coverImage) return undefined;
const full = `${base}.webp`;
const square = `${base}-square.webp`;
return existsSync(join('static', full)) && existsSync(join('static', square))
? { full, square }
: undefined;
};

/**
* A post's text with each tag turned into a space. Svelte 5 renders less
* whitespace between tags than Svelte 4 did, and stripping tags outright would
Expand Down
2 changes: 1 addition & 1 deletion src/lib/data/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const description =
* names the category people search for; the old slogan on its own told a
* results page nothing about what LangX is.
*/
export const title = 'LangX – Social Language Exchange App | Practice with Real People';
export const title = 'LangX – Language Exchange App: Practice with Real People';

// The social card, cut to the 1200x630 every scraper expects by
// `scripts/og/render.mjs`. Declaring the size stops X and Slack from guessing
Expand Down
7 changes: 7 additions & 0 deletions src/lib/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export type BlogPost = {
hidden: boolean;
slug: string;
title: string;
/**
* A shorter title for the results page, when `title` runs past the sixty
* characters one shows. The heading, the lists and the drawn card keep `title`.
*/
seoTitle?: string;
author: {
name: string;
url: string;
Expand All @@ -37,6 +42,8 @@ export type BlogPost = {
/** Apps from `COMPETITORS` the post is about, title first; drives the art above it. */
apps?: string[];
coverImage: string | undefined;
/** WebP copies of a photo cover, from scripts/covers.mjs: full size, and a square for lists. */
coverWebp?: { full: string; square: string };
/** Square image for post lists, drawn by scripts/og/posts.mjs. */
thumbnail?: string;
};
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(blog-article)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@

{#if post}
<Seo
title={post.title}
title={post.seoTitle ?? post.title}
description={post.excerpt}
path="/{post.slug}"
type="article"
Expand Down Expand Up @@ -123,7 +123,7 @@
-->
{#if post && post.coverImage && !post.thumbnail}
<div class="cover-image">
<Image src={post.coverImage} alt={post.title} />
<Image src={post.coverImage} webp={post.coverWebp?.full} alt={post.title} />
</div>
{:else if post}
<div class="art">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
slug: ai-conversation-partner-vs-language-exchange
title: 'AI Conversation Partner vs Language Exchange: Which Helps You Speak?'
seoTitle: 'AI Conversation Partner vs Language Exchange'
date: 2026-09-23T05:50:00.000Z
excerpt: 'AI conversation partner or real language exchange? An honest look at what each does best for speaking, where each falls short, and how to combine them.'
coverImage: /images/posts/ai-conversation-partner-vs-language-exchange.png
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(blog-article)/armenian-alphabet-guide/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
slug: armenian-alphabet-guide
title: 'The Armenian Alphabet: Letters, Sounds and How to Read'
date: 2026-09-23T17:50:00.000Z
excerpt: 'The Armenian alphabet explained: 36 letters by Mesrop Mashtots plus later additions, capital and small letters, Eastern vs Western sounds and look-alike letters.'
excerpt: 'The Armenian alphabet explained: 36 letters by Mesrop Mashtots plus later additions, capital and small letters, Eastern vs Western sounds and look-alikes.'
coverImage: /images/posts/armenian-alphabet-guide.png
thumbnail: /images/posts/armenian-alphabet-guide-thumb.png
author:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
slug: best-language-exchange-apps-2027
title: 'Best Language Exchange Apps for 2027: What to Pick and What''s Changing'
seoTitle: 'Best Language Exchange Apps for 2027: What to Pick'
date: 2026-09-23T05:45:00.000Z
excerpt: 'The best language exchange apps for 2027: current picks by need, the language learning trends heading into 2027, and what to look for before you choose.'
coverImage: /images/posts/best-language-exchange-apps-2027.png
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
slug: family-words-in-different-languages
title: 'Family Words in Different Languages: Mother, Aunt, Husband and More'
seoTitle: 'Family Words in Different Languages: Mother, Aunt and More'
date: 2026-09-23T05:00:00.000Z
excerpt: 'Mother, father, aunt, uncle, husband, wife and more family words in different languages, from real usage data, including where maternal and paternal differ.'
coverImage: /images/posts/family-words-in-different-languages.png
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
slug: free-language-exchange-apps
title: 'The Best Free Language Exchange Apps (2026): What You Really Get Free'
seoTitle: 'Best Free Language Exchange Apps (2026): What You Get Free'
date: 2026-09-23T05:30:00.000Z
excerpt: 'Which language exchange apps are really free in 2026? What each free tier includes, its daily limits, which ones show ads, and which need no subscription.'
coverImage: /images/posts/free-language-exchange-apps.png
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
---
slug: get-rewarded-for-teaching-your-language
title: 'Get rewarded for teaching your language: how LangX scores a conversation'
seoTitle: 'Get rewarded for teaching your language: how it works'
date: 2026-09-23T09:00:00.000Z
excerpt: In a language exchange the valuable thing is helping somebody else, so that is what LangX pays for. A correction is worth ten messages, and here is every other number behind it.
excerpt: In a language exchange the valuable thing is helping someone else, so that is what LangX pays for. A correction is worth ten messages; here is the rest.
coverImage: /images/posts/get-rewarded-for-teaching-your-language.png
author:
name: xuelink
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(blog-article)/is-hellotalk-safe/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
slug: is-hellotalk-safe
title: 'Is HelloTalk Safe? Privacy, Scams and How to Stay Safe (2026)'
date: 2026-09-23T04:54:00.000Z
excerpt: 'Is HelloTalk safe? A balanced 2026 look at its report tools, age rules, the scams HelloTalk itself warns about, what its privacy policy says, and practical tips.'
excerpt: 'Is HelloTalk safe? A balanced 2026 look at its report tools, age rules, the scams HelloTalk itself warns about, what its privacy policy says, plus safety tips.'
coverImage: /images/posts/is-hellotalk-safe.png
thumbnail: /images/posts/is-hellotalk-safe-thumb.png
author:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
slug: social-alternative-to-duolingo
title: 'The Social Alternative to Duolingo: Practice with Real People'
date: 2026-09-23T05:55:00.000Z
excerpt: 'Looking for a social alternative to Duolingo? LangX is a social language app where the lesson is a real conversation with a native speaker learning your language.'
excerpt: 'Looking for a social alternative to Duolingo? LangX is a social language app where the lesson is a real chat with a native speaker learning your language.'
coverImage: /images/posts/social-alternative-to-duolingo.png
thumbnail: /images/posts/social-alternative-to-duolingo-thumb.png
author:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ tags:
import AppDemo from "$lib/components/blog/AppDemo.svelte";
</script>

*Welcome back to **lol LangX**, the column for language learners. Check out our past posts [here](https://langx.io/blog). 🌟🗣️*
*Welcome back to **lol LangX**, the column for language learners. Check out [our past posts on the LangX blog](/blog). 🌟🗣️*

Hey language lovers! This week, we're tackling a common enemy: forgetting those hard-earned language skills. See if you can relate to this:

Expand Down
1 change: 1 addition & 0 deletions src/routes/(blog-article)/tandem-vs-hellotalk/+page.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
slug: tandem-vs-hellotalk
title: 'Tandem vs HelloTalk (2026): Which Language Exchange App Is Better?'
seoTitle: 'Tandem vs HelloTalk (2026): Which App Is Better?'
date: 2026-09-23T04:30:00.000Z
excerpt: 'Tandem vs HelloTalk in 2026: signup, community, corrections, calls, free vs paid and safety compared fairly, with a clear verdict for each kind of learner.'
coverImage: /images/posts/tandem-vs-hellotalk.png
Expand Down
14 changes: 13 additions & 1 deletion src/routes/(site)/blog/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,24 @@ export async function load() {
// keywords, author and three related posts in full, and serialised for
// sixty posts that was 145 KB of a 244 KB page.
posts: filteredPosts.map(
({ slug, title, date, excerpt, coverImage, thumbnail, readingTime, tags, apps }) => ({
({
slug,
title,
date,
excerpt,
coverImage,
coverWebp,
thumbnail,
readingTime,
tags,
apps
}) => ({
slug,
title,
date,
excerpt,
coverImage,
coverWebp,
thumbnail,
readingTime,
tags,
Expand Down
1 change: 1 addition & 0 deletions src/routes/(site)/blog/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
<BlogPostCard
title={post.title}
coverImage={post.coverImage}
coverWebp={post.coverWebp}
thumbnail={post.thumbnail}
excerpt={post.excerpt}
readingTime={post.readingTime}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/(site)/compare/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ export async function load() {
});
return {
posts: posts.map(
({ slug, title, excerpt, coverImage, thumbnail, readingTime, tags, apps }) => ({
({ slug, title, excerpt, coverImage, coverWebp, thumbnail, readingTime, tags, apps }) => ({
slug,
title,
excerpt,
coverImage,
coverWebp,
thumbnail,
readingTime,
tags,
Expand Down
Loading
Loading