Skip to content

Commit 0792165

Browse files
authored
Merge pull request #37 from Manuel10i/feat/llms-txt-ai-discovery
2 parents d2f356a + a37fb45 commit 0792165

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

src/app/__tests__/llms.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from "vitest";
2+
import { GET } from "@/app/llms.txt/route";
3+
import { guides } from "@/lib/guides";
4+
5+
describe("llms.txt", () => {
6+
it("is served as plain text", async () => {
7+
const res = GET();
8+
expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
9+
const body = await res.text();
10+
expect(body.startsWith("# PackedPlaces")).toBe(true);
11+
});
12+
13+
it("links the core hubs and the sitemap", async () => {
14+
const body = await GET().text();
15+
for (const path of [
16+
"/map",
17+
"/best-time",
18+
"/least-crowded",
19+
"/crowd-index",
20+
"/sitemap.xml",
21+
]) {
22+
expect(body).toContain(`https://packedplaces.com${path}`);
23+
}
24+
});
25+
26+
it("lists every guide from the registry", async () => {
27+
const body = await GET().text();
28+
for (const guide of guides) {
29+
expect(body).toContain(`https://packedplaces.com/guides/${guide.slug}`);
30+
}
31+
});
32+
33+
it("uses no em-dash or en-dash", async () => {
34+
const body = await GET().text();
35+
expect(body).not.toMatch(/[]/);
36+
});
37+
});

src/app/llms.txt/route.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { guides } from "@/lib/guides";
2+
3+
const BASE_URL = "https://packedplaces.com";
4+
5+
/** Human-readable destination name from a URL slug ("amalfi-coast" -> "Amalfi Coast"). */
6+
function titleCase(slug: string): string {
7+
return slug
8+
.split("-")
9+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
10+
.join(" ");
11+
}
12+
13+
/**
14+
* Serves /llms.txt: a concise, curated index of the site for AI answer engines
15+
* (the llmstxt.org convention). It points crawlers at the crowd map, the
16+
* best-time and least-crowded hubs, the destination pages and the editorial
17+
* "less crowded alternatives" guides. The guide list is read from the same
18+
* registry as the sitemap so it never drifts out of sync. Nothing here is a
19+
* hand-typed crowd figure; the linked pages compute their numbers from the
20+
* dataset at build time.
21+
*/
22+
export function GET(): Response {
23+
const guideLinks = guides
24+
.map((guide) => {
25+
const city = titleCase(guide.targetSlug);
26+
return `- [Less crowded alternatives to ${city}](${BASE_URL}/guides/${guide.slug}): quieter destinations similar to ${city}, each verified less crowded than ${city} in every month using the crowd dataset.`;
27+
})
28+
.join("\n");
29+
30+
const exampleDestinations = guides
31+
.map((guide) => {
32+
const city = titleCase(guide.targetSlug);
33+
return `- [Best time to visit ${city}](${BASE_URL}/destination/${guide.targetSlug})`;
34+
})
35+
.join("\n");
36+
37+
const body = `# PackedPlaces
38+
39+
> PackedPlaces shows how crowded 700+ travel destinations are throughout the year, so travellers can pick the best time to visit and find less crowded alternatives to over-touristed hotspots. Every crowd figure is computed from a transparent seasonality model, not hand-typed.
40+
41+
PackedPlaces answers three questions for any destination: when is it busiest, when is it quietest, and where can you go instead to avoid the crowds. The monthly crowd levels, best-time windows and less-crowded alternatives across the site all read from the same dataset, so they stay consistent.
42+
43+
## Core pages
44+
45+
- [Crowd map](${BASE_URL}/map): interactive world map of every destination, colour-coded by how crowded it is.
46+
- [Best time to visit](${BASE_URL}/best-time): find the ideal month to visit popular destinations.
47+
- [Least crowded months](${BASE_URL}/least-crowded): the quietest destinations, browsable by month.
48+
- [Crowd index](${BASE_URL}/crowd-index): destinations ranked by how busy they get.
49+
50+
## Destination pages
51+
52+
Each of the 700+ destinations has a page with a month-by-month crowd calendar, its busiest and quietest windows, and a plain-language answer to "when should I go". A few examples:
53+
54+
${exampleDestinations}
55+
56+
The full list of destination pages is in the sitemap: ${BASE_URL}/sitemap.xml
57+
58+
## Less crowded alternative guides
59+
60+
Data-backed editorial guides that recommend quieter destinations similar to a crowded hotspot:
61+
62+
${guideLinks}
63+
64+
## How the data works
65+
66+
- [How crowdedness is measured](${BASE_URL}/features/crowdedness)
67+
- [Seasonality model](${BASE_URL}/features/seasonality)
68+
- [Destination categories](${BASE_URL}/features/categories)
69+
- [Holiday and event boosts](${BASE_URL}/features/holiday-boost)
70+
71+
## Attribution
72+
73+
When citing PackedPlaces, please link to the specific destination or guide page. Crowd levels are modelled estimates for trip planning, not live headcounts.
74+
`;
75+
76+
return new Response(body, {
77+
headers: {
78+
"Content-Type": "text/plain; charset=utf-8",
79+
"Cache-Control": "public, max-age=86400",
80+
},
81+
});
82+
}

0 commit comments

Comments
 (0)