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
33 changes: 13 additions & 20 deletions src/lib/components/Breadcrumbs.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
*
* Pass an ordered list of crumbs from the site root to the current page. The
* final crumb is rendered as the current page - no link, `aria-current="page"`.
* The component also emits a matching schema.org `BreadcrumbList` so the trail
* is eligible for breadcrumb rich results in search.
* The component also emits a matching schema.org `BreadcrumbList` (via the
* shared breadcrumbSchema builder + <JsonLd>) so the trail is eligible for
* breadcrumb rich results in search.
*
* Usage:
* <Breadcrumbs
Expand All @@ -16,36 +17,28 @@
* ]}
* />
*/
import JsonLd from './JsonLd.svelte';
import { breadcrumbSchema } from '$lib/seo/schema';

export interface Crumb {
label: string;
/** Absolute or root-relative path. Omit on the final (current) crumb. */
href?: string;
}
Comment on lines 23 to 27

let { items, origin = 'https://cropwatch.io' }: { items: Crumb[]; origin?: string } =
$props();
let { items }: { items: Crumb[] } = $props();

const lastIndex = $derived(items.length - 1);

// BreadcrumbList JSON-LD: absolute URLs from each href; the current page omits
// `item`. `<` is escaped so a label can never break out of the script tag.
const jsonLd = $derived(
JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((crumb, i) => ({
'@type': 'ListItem',
position: i + 1,
name: crumb.label,
...(crumb.href ? { item: origin + crumb.href } : {})
}))
}).replace(/</g, '\\u003c')
// BreadcrumbList JSON-LD from the shared builder: absolute URLs (this site's
// origin, via site.ts) from each href; the current/name-only crumb omits
// `item`. Escaping is handled inside <JsonLd>.
const breadcrumbLd = $derived(
breadcrumbSchema(items.map((crumb) => ({ name: crumb.label, path: crumb.href })))
);
</script>

<svelte:head>
{@html `<script type="application/ld+json">${jsonLd}</script>`}
</svelte:head>
<JsonLd data={breadcrumbLd} />

<nav class="pcrumb" aria-label="Breadcrumb">
<div class="wrap">
Expand Down
15 changes: 15 additions & 0 deletions src/lib/components/JsonLd.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
// Renders one schema.org JSON-LD block into <head>. Accepts a single schema
// object or an array of them. `<` is escaped so the payload can never break
// out of the <script> element. Self-injects via <svelte:head>, so place this
// component anywhere in markup (not inside a parent <svelte:head>).
//
// Byte-identical on the .io and .co.jp branches — keep in sync (see site.ts).
let { data }: { data: Record<string, unknown> | Record<string, unknown>[] } = $props();

const json = $derived(JSON.stringify(data).replace(/</g, '\\u003c'));
</script>

<svelte:head>
{@html `<script type="application/ld+json">${json}<\/script>`}
</svelte:head>
Comment on lines +13 to +15
176 changes: 176 additions & 0 deletions src/lib/seo/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// JSON-LD (schema.org) builders. Each returns a plain object that <JsonLd>
// serialises into a <script type="application/ld+json"> tag. Pure functions →
// trivial to reuse across routes and SSR/prerender-safe (no browser APIs).
//
// This file is LOCALE-NEUTRAL and intended to be byte-identical on both the .io
// and .co.jp branches — every site-specific value comes from ./site.ts. Keep the
// two copies in sync (see the note in site.ts).

import { ORG, SITE_ALT_NAME, SITE_LANG, SITE_NAME, SITE_ORIGIN, absUrl } from './site';

type Json = Record<string, unknown>;

const ORG_ID = `${SITE_ORIGIN}/#organization`;
const WEBSITE_ID = `${SITE_ORIGIN}/#website`;

/** Site-wide publisher identity. Rendered once in the root layout. */
export function organizationSchema(): Json {
const out: Json = {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': ORG_ID,
name: ORG.name,
// undefined keys are dropped by JSON.stringify, so optional fields vanish.
alternateName: ORG.alternateName,
legalName: ORG.legalName,
description: ORG.description,
url: ORG.url,
logo: ORG.logo,
image: ORG.logo,
email: ORG.email,
telephone: ORG.telephone,
contactPoint: {
'@type': 'ContactPoint',
telephone: ORG.telephone,
email: ORG.email,
contactType: ORG.contact.type,
areaServed: ORG.contact.areaServed,
availableLanguage: ORG.contact.availableLanguage
},
sameAs: ORG.sameAs
};
// Only emit a PostalAddress when a confirmed address exists (the .io entity
// has none published yet; Organization is valid without it).
if (ORG.address) {
out.address = {
'@type': 'PostalAddress',
addressCountry: ORG.address.country,
postalCode: ORG.address.postalCode,
addressRegion: ORG.address.region,
addressLocality: ORG.address.locality,
streetAddress: ORG.address.street
};
}
return out;
}

/** WebSite entity (helps Google understand the site name in results). */
export function websiteSchema(): Json {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': WEBSITE_ID,
name: SITE_NAME,
alternateName: SITE_ALT_NAME,
url: SITE_ORIGIN,
inLanguage: SITE_LANG,
publisher: { '@id': ORG_ID }
};
}

export type Crumb = { name: string; path?: string };

/**
* Breadcrumb trail. Pass paths as site-relative (e.g. '/cold-chain'). Omit
* `path` on the current page (and on name-only parents like Japan's 製品) — those
* crumbs render without an `item`, matching Google's breadcrumb guidance.
*/
export function breadcrumbSchema(items: Crumb[]): Json {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
...(c.path ? { item: absUrl(c.path) } : {})
}))
};
}

export type ProductInput = {
name: string;
description: string;
image?: string;
sku?: string;
category?: string;
url?: string;
};

/**
* A CropWatch hardware product (sensor, gateway, case). Deliberately carries NO
* offers/review/aggregateRating: without a real price or genuine third-party
* ratings a Product is valid markup but not rich-result eligible, and fabricated
* self-serving ratings are against Google's policy.
*/
export function productSchema(p: ProductInput): Json {
const out: Json = {
'@context': 'https://schema.org',
'@type': 'Product',
name: p.name,
description: p.description,
brand: { '@type': 'Brand', name: 'CropWatch' },
manufacturer: { '@id': ORG_ID }
};
Comment on lines +107 to +114
if (p.image) out.image = p.image;
if (p.sku) out.sku = p.sku;
if (p.category) out.category = p.category;
if (p.url) out.url = absUrl(p.url);
return out;
}

export type VideoInput = {
name: string;
description: string;
thumbnailUrl: string | string[];
uploadDate: string; // ISO 8601 — REQUIRED by Google. Only emit when set.
embedUrl?: string;
contentUrl?: string;
duration?: string; // ISO 8601 duration, e.g. PT2M30S
};

/** A VideoObject for an on-page video (e.g. a YouTube embed). */
export function videoObjectSchema(v: VideoInput): Json {
const out: Json = {
'@context': 'https://schema.org',
'@type': 'VideoObject',
name: v.name,
description: v.description,
thumbnailUrl: v.thumbnailUrl,
uploadDate: v.uploadDate
};
if (v.embedUrl) out.embedUrl = v.embedUrl;
if (v.contentUrl) out.contentUrl = v.contentUrl;
if (v.duration) out.duration = v.duration;
return out;
}

export type ArticleInput = {
title: string;
description: string;
path: string;
datePublished: string;
dateModified?: string;
image?: string;
};

/**
* A blog/column article. Shipped ready but currently UNWIRED — neither site has
* live article content yet (.io /news is a stub, .co.jp has no column route).
*/
export function articleSchema(a: ArticleInput): Json {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: a.title,
description: a.description,
mainEntityOfPage: absUrl(a.path),
url: absUrl(a.path),
inLanguage: SITE_LANG,
datePublished: a.datePublished,
dateModified: a.dateModified ?? a.datePublished,
...(a.image ? { image: a.image } : {}),
author: { '@id': ORG_ID },
publisher: { '@id': ORG_ID }
};
}
67 changes: 67 additions & 0 deletions src/lib/seo/site.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Shared site-level SEO constants for cropwatch.io (the US / global English site).
// Kept in one place so titles, canonicals, OG tags, structured data and the
// sitemap all agree on the origin and brand strings.
//
// NOTE: this is the ONLY file in the SEO toolkit that legitimately differs
// between the .io and .co.jp deploy branches — `schema.ts` and `JsonLd.svelte`
// are byte-identical copies (like src/lib/seo/alternates.ts). Keep them in sync.

export const SITE_ORIGIN = 'https://cropwatch.io';
export const SITE_NAME = 'CropWatch';
export const SITE_ALT_NAME = 'CropWatch';
export const SITE_LANG = 'en';

// Default social-share image (1200x630). Only consumed by future OG helpers, not
// by JSON-LD. No dedicated asset exists yet, so this falls back to the logo.
export const DEFAULT_OG_IMAGE = `${SITE_ORIGIN}/cropwatch_icons/icon-512x512.png`;

interface OrgAddress {
country: string;
postalCode: string;
region: string;
locality: string;
street: string;
}

interface Org {
legalName?: string;
name: string;
alternateName?: string;
description: string;
url: string;
logo: string;
telephone: string;
email: string;
address?: OrgAddress; // omit when no confirmed postal address (US has none yet)
contact: { type: string; areaServed: string; availableLanguage: string[] };
sameAs: string[];
}

// Publisher identity for the .io entity. No US postal address is published on the
// site, so `address` is intentionally omitted — organizationSchema() drops the
// PostalAddress node when it's undefined (Organization is still valid without it).
export const ORG: Org = {
name: SITE_NAME,
description:
'Industrial-grade wireless environmental monitoring for cold chain, smart agriculture and smart livestock. LoRaWAN sensors track temperature, humidity, CO2 and more, automating records and HACCP compliance.',
url: SITE_ORIGIN,
// Google's logo guidelines want a crawlable raster (SVG unsupported), min 112px.
logo: `${SITE_ORIGIN}/cropwatch_icons/icon-512x512.png`,
telephone: '+1-978-381-3105',
email: 'sales@cropwatch.io',
contact: { type: 'sales', areaServed: 'US', availableLanguage: ['en'] },
// Official brand profiles that identify the entity. Add ONLY real, verified
// URLs — a wrong sameAs hurts. TODO(confirm): verify these represent the .io
// (US/global) entity before relying on them.
sameAs: [
'https://www.linkedin.com/company/71224776',
'https://www.youtube.com/@cropwatch4407',
'https://cropwatch.co.jp'
]
};

// Absolute URL for a path on this site (root stays '/'; trailing slash trimmed).
export function absUrl(pathname: string): string {
const path = pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return SITE_ORIGIN + path;
}
5 changes: 5 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import Footer from '$lib/components/Footer.svelte';
import Analytics from '$lib/components/Analytics.svelte';
import { alternatesFor } from '$lib/seo/alternates';
import JsonLd from '$lib/components/JsonLd.svelte';
import { organizationSchema, websiteSchema } from '$lib/seo/schema';
// Self-hosted text faces (replace the former Google Fonts <link>s in app.html):
// same-origin, immutable-cached, no third-party connection blocking first paint.
import '@fontsource-variable/inter';
Expand Down Expand Up @@ -70,6 +72,9 @@
{/each}
</svelte:head>

<!-- Site-wide publisher identity: exactly one Organization + WebSite per page. -->
<JsonLd data={[organizationSchema(), websiteSchema()]} />

<Analytics />

<Header {splash} />
Expand Down
15 changes: 15 additions & 0 deletions src/routes/replacement-case/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import JsonLd from '$lib/components/JsonLd.svelte';
import { productSchema } from '$lib/seo/schema';
import { initDeviceViewer } from '$lib/deviceViewer';

// The rugged IP66 enclosure as a single Product (bare - no price/rating, so
// valid but not rich-snippet eligible).
const caseLd = productSchema({
name: 'Replacement Sensor Case (CW-CASE)',
sku: 'CW-CASE',
category: 'Sensor enclosure',
description:
'The rugged, UV-stable IP66 enclosure that houses every CropWatch sensor. Field-replaceable and washdown-ready, built for freezers, barns and open fields.',
url: '/replacement-case'
});

// Spin up the 3D case viewer on #deviceCanvas (Babylon is bundled locally,
// loaded lazily). Dispose the engine when the page unmounts.
onMount(() => {
Expand Down Expand Up @@ -48,6 +61,8 @@
]}
/>

<JsonLd data={caseLd} />

<!-- 3D hero -->
<section class="phero">
<div class="wrap phero__grid">
Expand Down
Loading