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
37 changes: 37 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,43 @@ quantization is what keeps that a non-issue. Only CSS `background-image` would a
not worth trading `<img alt>` on the page's content images to save one
language's worth of cached bytes.

**The site is registered with Google Search Console, and the SEO is three
files' worth of plumbing rather than copywriting.** The landing page went a
month without appearing in a search, and the first suspicion — that something
was blocking crawlers — was wrong: there is no `robots.txt` anywhere, no
`noindex`, no `X-Robots-Tag`, and the repository is public. It had simply never
been registered, and one thing about it was genuinely broken. **A crawler
renders the page, so it negotiates like a browser** — Googlebot's
`navigator.languages` is `en-US` — and the Japanese `<section>` therefore goes
`display:none` on the one URL anyone links to. The Japanese page was not
ranking badly; it did not exist. The fix keeps every decision above intact:
hreflang alternates naming `?lang=en`, `?lang=ja` and the bare URL as
`x-default`, real `<a href="?lang=…">` links in both footers because a
`<select>` is not something a crawler can follow, and a `data-description` on
each section beside the `data-title` that was already there — so adding a
language is still a code, an `<option>` and a translated `<section>`.
**There is deliberately no `rel=canonical`.** It would have to differ per URL,
and one file cannot say three things: a static one pointing anywhere folds the
other two into it and un-indexes the Japanese page, while a script-written one
is the case Google says not to rely on. hreflang carries the relationship, and
that is what it is for. The `?lang=` spelling is not free to change either —
it is what `appstore/metadata*/*/marketing_url.txt` and `privacy_url.txt`
already point at, so a move to `/ja/` paths would mean re-pushing both
listings.
`site/sitemap.xml` lists six URLs rather than two, because an hreflang set has
to name every variant from every variant. It carries no `lastmod`: the site is
deployed by hand at release time, so the date would go stale the first time
someone forgot it, and Google ignores a lastmod it cannot trust. **It is
submitted in Search Console, not announced from a `robots.txt`** — a
`robots.txt` is only read at the *host* root, `temoki.github.io/robots.txt`,
which belongs to no repository here; one placed in `site/` would be served,
ignored, and mistaken for working. A sitemap has no such rule and may sit at
any level at or above the URLs it lists.
The JSON-LD `SoftwareApplication` block is the weakest of these and is kept
honest on purpose: every value in it is checkable against the App Store
listing, `LICENSE`, or the specs table on the page itself, and there is no
`aggregateRating` — nor may one ever be added that nobody left.

**The landing page is written for parents and teachers**, not for the kid and
not for a developer — the technical account lives in the README. Three things
about it are decisions rather than taste. The App Store badge is Apple's own
Expand Down
81 changes: 78 additions & 3 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,23 @@
<meta name="description" content="Tortoise Blocks is a visual programming app for kids on iPad, Mac and Apple Vision Pro: snap blocks together, press play, and watch the tortoise draw. Tortoise Blocks は、ブロックをならべてカメに絵をかいてもらう、iPad と Mac と Apple Vision Pro のための子ども向けプログラミングアプリです。">
<link rel="icon" href="icon-176.png">

<!-- One card serves both languages, because both are served from one URL. -->
<!-- Each language is a URL, so each language is a page a search engine can
index on its own. The set is identical on every variant, self-reference
included, which is exactly what lets it be static in one file that serves
all three; `x-default` is the bare URL because that is the one that
negotiates. There is deliberately no `rel=canonical`: it would have to
differ per URL, and one file cannot say three things — a static one
pointing anywhere folds the other two into it and un-indexes the Japanese
page, while a script-written one is the case Google says not to rely on.
hreflang carries the relationship instead, which is what it is for. -->
<link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/?lang=en">
<link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/?lang=ja">
<link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/">

<!-- One card for both languages, and English by default. The alternates
above give each language a URL, but a social scraper is not a browser: it
does not run the script that picks a language, so a per-language card
would have to be written by JavaScript nothing would execute. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Tortoise Blocks">
<meta property="og:title" content="Tortoise Blocks">
Expand All @@ -19,6 +35,49 @@
<meta property="og:image:alt" content="The Tortoise Blocks tortoise, in a purple beret with a paintbrush for a tail.">
<meta name="twitter:card" content="summary_large_image">

<!-- What the page says about the app, in a form a search engine reads without
guessing. Every value here is checkable against something: the price, the
age rating and the seller come from the App Store listing, the license
from LICENSE, the requirements from the specs table below. There is no
aggregateRating and there must never be one that nobody left.

It is written once and in English rather than per language, because these
are facts about the app rather than prose — and because a script-written
one would arrive after the crawl for the same reason the OG card is
single. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Tortoise Blocks",
"description": "A visual programming app for kids on iPad, Mac and Apple Vision Pro. Snap blocks into a program, press play, and watch a tortoise draw your picture.",
"url": "https://temoki.github.io/TortoiseBlocks/",
"applicationCategory": "EducationalApplication",
"operatingSystem": "iPadOS 26 or later, macOS 26 or later, visionOS 26 or later",
"downloadUrl": "https://apps.apple.com/app/id6798677334",
"installUrl": "https://apps.apple.com/app/id6798677334",
"screenshot": "https://temoki.github.io/TortoiseBlocks/shots/ipad-en-1.png",
"inLanguage": [
"en",
"ja"
],
"isFamilyFriendly": true,
"author": {
"@type": "Person",
"name": "Tomoki Kobayashi"
},
"license": "https://opensource.org/license/mit",
"isAccessibleForFree": true,
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"privacyPolicy": "https://temoki.github.io/TortoiseBlocks/privacy.html",
"codeRepository": "https://github.com/temoki/TortoiseBlocks"
}
</script>

<style>
/* The palette is the app's own: the accent is the mascot's purple, the same
literal both appearances use in the app. Kept in step with privacy.html. */
Expand Down Expand Up @@ -239,7 +298,9 @@
<main>

<!-- ============================ English ============================= -->
<section lang="en" data-title="Tortoise Blocks — Snap blocks together, watch the tortoise draw">
<section lang="en"
data-title="Tortoise Blocks — Snap blocks together, watch the tortoise draw"
data-description="A visual programming app for kids on iPad, Mac and Apple Vision Pro. Snap blocks into a program, press play, and watch a tortoise draw your picture.">

<div class="hero">
<img class="icon" src="icon-256.png" alt="" width="112" height="112">
Expand Down Expand Up @@ -451,7 +512,9 @@ <h2>Quiet about the child using it</h2>
</section>

<!-- ============================ 日本語 ============================== -->
<section lang="ja" data-title="Tortoise Blocks — ブロックをならべて、カメに絵をかいてもらおう">
<section lang="ja"
data-title="Tortoise Blocks — ブロックをならべて、カメに絵をかいてもらおう"
data-description="iPad と Mac と Apple Vision Pro のための子ども向けビジュアルプログラミングアプリ。ブロックをならべて再生ボタンを押すと、カメが1本ずつ線をかいていきます。">

<div class="hero">
<img class="icon" src="icon-256.png" alt="" width="112" height="112">
Expand Down Expand Up @@ -608,6 +671,11 @@ <h2>使う子どものことを、何も集めません</h2>
<a href="https://github.com/temoki/TortoiseBlocks">GitHub</a>
<a href="privacy.html">Privacy / プライバシー</a>
<a href="https://github.com/temoki/TortoiseBlocks/issues">Issues</a>
<!-- Real links, and not a second picker: a <select> is not something a
crawler can follow, so without these the Japanese URL is declared in the
head and reachable from nowhere. -->
<a href="?lang=en" hreflang="en" lang="en">English</a>
<a href="?lang=ja" hreflang="ja" lang="ja">日本語</a>
<span>© hiraku.space</span>
</footer>

Expand All @@ -624,6 +692,13 @@ <h2>使う子どものことを、何も集めません</h2>
// so a new language needs no edit here.
var section = document.querySelector('section[lang="' + lang + '"]');
if (section && section.dataset.title) document.title = section.dataset.title;
// The static description names both languages, because that is what the
// bare URL and a script-less reader get. Once a language is resolved the
// page is only in that one, and the description should say so.
var desc = document.querySelector('meta[name="description"]');
if (desc && section && section.dataset.description) {
desc.setAttribute("content", section.dataset.description);
}
if (remember) {
// The choice rides in the URL, not in storage: this site promises the
// app stores nothing, and the page has no business doing otherwise —
Expand Down
37 changes: 31 additions & 6 deletions site/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,22 @@
<meta name="description" content="Tortoise Blocks collects no data and makes no network connections. Tortoise Blocks は利用者の情報を一切収集せず、ネットワーク通信も行いません。">
<link rel="icon" href="icon-176.png">

<!-- The same card as the landing page, and English for the same reason: both
languages of this page live at one URL, so a preview can only be one. -->
<!-- Each language is a URL, so each language is a page a search engine can
index on its own. The set is identical on every variant, self-reference
included, which is exactly what lets it be static in one file that serves
all three; `x-default` is the bare URL because that is the one that
negotiates. There is deliberately no `rel=canonical`: it would have to
differ per URL, and one file cannot say three things — a static one
pointing anywhere folds the other two into it and un-indexes the Japanese
page, while a script-written one is the case Google says not to rely on.
hreflang carries the relationship instead, which is what it is for. -->
<link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=en">
<link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=ja">
<link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/privacy.html">

<!-- The same card as the landing page, and English for the same reason: a
social scraper does not run the script that picks a language, so the
per-language URLs declared above buy a preview nothing. -->
<meta property="og:type" content="article">
<meta property="og:site_name" content="Tortoise Blocks">
<meta property="og:title" content="Privacy Policy — Tortoise Blocks">
Expand Down Expand Up @@ -150,7 +164,8 @@ <h1>Tortoise&nbsp;Blocks</h1>
</div>
</header>

<section lang="en">
<section lang="en"
data-description="Tortoise Blocks collects no information about the people who use it: no accounts, no analytics, no advertising, and no network connections at all.">

<h2 style="border-top:0;padding-top:0;margin-top:2.5rem">Privacy Policy</h2>

Expand Down Expand Up @@ -248,7 +263,8 @@ <h2>8. Contact</h2>

</section>

<section lang="ja">
<section lang="ja"
data-description="Tortoise Blocks は利用者の情報を一切収集しません。アカウントも、解析も、広告も、ネットワーク通信もありません。">

<h2 style="border-top:0;padding-top:0;margin-top:2.5rem">プライバシーポリシー</h2>

Expand Down Expand Up @@ -327,7 +343,9 @@ <h2>8. お問い合わせ</h2>

<footer>
<a href="./">Tortoise Blocks</a> ·
<a href="https://github.com/temoki/TortoiseBlocks">GitHub</a>
<a href="https://github.com/temoki/TortoiseBlocks">GitHub</a> ·
<a href="?lang=en" hreflang="en" lang="en">English</a> ·
<a href="?lang=ja" hreflang="ja" lang="ja">日本語</a>
</footer>

</main>
Expand All @@ -343,8 +361,15 @@ <h2>8. お問い合わせ</h2>
select.value = lang;
// Taken from the page rather than a parallel list of titles, so a new
// language needs no edit here.
var heading = document.querySelector('section[lang="' + lang + '"] h2');
var section = document.querySelector('section[lang="' + lang + '"]');
var heading = section && section.querySelector("h2");
if (heading) document.title = heading.textContent.trim() + " — Tortoise Blocks";
// The static description names both languages, because that is what the
// bare URL and a script-less reader get.
var desc = document.querySelector('meta[name="description"]');
if (desc && section && section.dataset.description) {
desc.setAttribute("content", section.dataset.description);
}
if (remember) {
// The choice rides in the URL, not in storage: a page promising no
// data collection has no business writing localStorage, and this
Expand Down
58 changes: 58 additions & 0 deletions site/sitemap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Six URLs, not two: an hreflang set has to name every variant from every
variant, so each of the three addresses each page answers to gets its own
entry carrying the whole set. They are the same alternates the pages
declare in their heads — a sitemap is where a crawler finds the Japanese
URL before it has rendered anything.

No <lastmod>, <changefreq> or <priority>. Google ignores the last two
outright, and lastmod only counts while it is honest: the site is
deployed by hand at release time, so a date in here would be one more
thing to remember and would go stale the first time it was forgotten.

This file is submitted in Search Console rather than announced from a
robots.txt, because a robots.txt is only read at the host root —
temoki.github.io/robots.txt — which belongs to no repository here. A
sitemap has no such rule: it may sit at any level at or above the URLs
it lists.
-->
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://temoki.github.io/TortoiseBlocks/?lang=en</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/?lang=en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/?lang=ja"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/"/>
</url>
<url>
<loc>https://temoki.github.io/TortoiseBlocks/?lang=ja</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/?lang=en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/?lang=ja"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/"/>
</url>
<url>
<loc>https://temoki.github.io/TortoiseBlocks/</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/?lang=en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/?lang=ja"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/"/>
</url>
<url>
<loc>https://temoki.github.io/TortoiseBlocks/privacy.html?lang=en</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=ja"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/privacy.html"/>
</url>
<url>
<loc>https://temoki.github.io/TortoiseBlocks/privacy.html?lang=ja</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=ja"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/privacy.html"/>
</url>
<url>
<loc>https://temoki.github.io/TortoiseBlocks/privacy.html</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=en"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://temoki.github.io/TortoiseBlocks/privacy.html?lang=ja"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://temoki.github.io/TortoiseBlocks/privacy.html"/>
</url>
</urlset>