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
10 changes: 10 additions & 0 deletions src/app/city/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
placesByType,
} from "@/lib/city-pages";
import { getPlaces } from "@/lib/places";
import { placeJsonLdScript } from "@/lib/jsonld";
import { humanizeCity, PLACE_TYPE_LABELS } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Expand Down Expand Up @@ -64,6 +65,15 @@ export default async function CityPage({ params }: CityPageProps) {

return (
<PageContainer>
{page.places.map((place) => (
<script
key={place.id}
type="application/ld+json"
// One schema.org/Place node per place: real fields only, no
// placeholders, geo omitted when coordinates are missing (#120).
dangerouslySetInnerHTML={{ __html: placeJsonLdScript(place) }}
/>
))}
<p className="kicker">Places in</p>
<h1 className="mt-3 font-heading text-3xl font-bold tracking-tight sm:text-4xl">
{name}
Expand Down
112 changes: 112 additions & 0 deletions src/lib/jsonld.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";

import { placeJsonLd, placeJsonLdScript, schemaOrgType } from "@/lib/jsonld";
import { site } from "@/lib/site";
import type { Place } from "@/lib/types";

function place(overrides: Partial<Place> = {}): Place {
return {
id: "mum-library-01",
name: "David Sassoon Library",
type: "library",
city: "mumbai",
lat: 18.9674,
lng: 72.8339,
address: "Fort, Mumbai 400001",
gmaps_link: "https://maps.google.com/?q=18.9674,72.8339",
added_by: "test",
...overrides,
};
}

describe("schemaOrgType", () => {
it("maps library, gov_offices and airport to their specific subtypes", () => {
expect(schemaOrgType("library")).toBe("Library");
expect(schemaOrgType("gov_offices")).toBe("GovernmentOffice");
expect(schemaOrgType("airport")).toBe("Airport");
});

it("falls back to Place for types without a genuine schema.org match", () => {
expect(schemaOrgType("sat_centre")).toBe("Place");
expect(schemaOrgType("foreign_lang_exam_centre")).toBe("Place");
expect(schemaOrgType("other_places")).toBe("Place");
});
});

describe("placeJsonLd", () => {
it("emits name, address, geo and the map deep link", () => {
expect(placeJsonLd(place())).toEqual({
"@context": "https://schema.org",
"@type": "Library",
name: "David Sassoon Library",
url: `${site.url}/map?place=mum-library-01`,
address: "Fort, Mumbai 400001",
geo: {
"@type": "GeoCoordinates",
latitude: 18.9674,
longitude: 72.8339,
},
});
});

it("URL-encodes the place id in the deep link", () => {
const p = place({ id: "navi_mumbai-sat_centre-02" });
expect(placeJsonLd(p).url).toBe(
`${site.url}/map?place=navi_mumbai-sat_centre-02`,
);
});

it("uses the specific subtype when the record genuinely is one", () => {
expect(placeJsonLd(place({ type: "gov_offices" }))["@type"]).toBe(
"GovernmentOffice",
);
expect(placeJsonLd(place({ type: "airport" }))["@type"]).toBe("Airport");
expect(placeJsonLd(place({ type: "sat_centre" }))["@type"]).toBe("Place");
});

it("omits address when the record has none instead of emitting a placeholder", () => {
const node = placeJsonLd(place({ address: undefined }));
expect(node).not.toHaveProperty("address");
});

it("omits geo when coordinates are missing instead of emitting nulls", () => {
const withoutLat = ({ ...place(), lat: undefined } as unknown) as Place;
expect(placeJsonLd(withoutLat)).not.toHaveProperty("geo");
const withoutLng = ({ ...place(), lng: undefined } as unknown) as Place;
expect(placeJsonLd(withoutLng)).not.toHaveProperty("geo");
});

it("omits geo when a coordinate is not a finite number", () => {
expect(placeJsonLd(place({ lat: NaN, lng: 72.8339 }))).not.toHaveProperty(
"geo",
);
expect(placeJsonLd(place({ lat: 18.9674, lng: Infinity }))).not.toHaveProperty(
"geo",
);
});

it("emits only real dataset fields — no invented values", () => {
const node = placeJsonLd(place());
expect(Object.keys(node).sort()).toEqual([
"@context",
"@type",
"address",
"geo",
"name",
"url",
]);
});
});

describe("placeJsonLdScript", () => {
it("serializes the node as JSON", () => {
const script = placeJsonLdScript(place());
expect(JSON.parse(script)).toEqual(placeJsonLd(place()));
});

it("escapes < so a name can never break out of the script tag", () => {
const script = placeJsonLdScript(place({ name: "A <b>Library</b>" }));
expect(script).not.toContain("</");
expect(script).toContain("\\u003c");
});
});
63 changes: 63 additions & 0 deletions src/lib/jsonld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { site } from "@/lib/site";
import type { Place } from "@/lib/types";

/**
* The schema.org type for a place, choosing a specific subtype where one
* genuinely fits (a `Library` really is a schema.org Library, etc.) and
* falling back to the generic `Place` everywhere else.
*/
export function schemaOrgType(type: Place["type"]): string {
switch (type) {
case "library":
return "Library";
case "gov_offices":
return "GovernmentOffice";
case "airport":
return "Airport";
default:
return "Place";
}
}

/**
* Build the schema.org/Place JSON-LD graph node for one place.
*
* Only fields the dataset actually holds are emitted — never placeholders.
* `geo` is omitted entirely when coordinates are missing or unusable, and
* `address` is omitted when the record has none, so the structured data can
* never misrepresent the page.
*/
export function placeJsonLd(place: Place): Record<string, unknown> {
const node: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": schemaOrgType(place.type),
name: place.name,
// Deep link straight to the place pin on the map.
url: `${site.url}/map?place=${encodeURIComponent(place.id)}`,
};

if (place.address) {
node.address = place.address;
}

const hasLat = typeof place.lat === "number" && Number.isFinite(place.lat);
const hasLng = typeof place.lng === "number" && Number.isFinite(place.lng);
if (hasLat && hasLng) {
node.geo = {
"@type": "GeoCoordinates",
latitude: place.lat,
longitude: place.lng,
};
}

return node;
}

/**
* The JSON payload for a `<script type="application/ld+json">` block.
* `<` is escaped so a name containing `</script>` can never terminate the
* block early and inject markup.
*/
export function placeJsonLdScript(place: Place): string {
return JSON.stringify(placeJsonLd(place)).replace(/</g, "\\u003c");
}
Loading