Skip to content

Commit ca9c893

Browse files
[DONE] 링크를 메신저나 X 에 붙이면 쪽마다 제 제목과 그림이 뜬다 (#97)
* 시작 — Patternflow 2바퀴 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb * web: give every page its own link preview A page that set openGraph lost the root's picture, and a page that set none previewed as the home page; twitter:title was the home title on all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb * web: keep the link preview test in app/, where it may import pages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb * web: say plainly what the root layout's preview used to carry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6d4eb1 commit ca9c893

15 files changed

Lines changed: 189 additions & 22 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Metadata } from "next";
2+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
3+
4+
// page.tsx is a client component, and a client component cannot export
5+
// metadata. Without this the guide's browser tab and its link preview were
6+
// the home page's.
7+
export const metadata: Metadata = {
8+
title: "Breadboard Build Guide / Patternflow",
9+
description:
10+
"Build Patternflow with no custom PCB: one snapped-off breadboard power strip and a handful of jumper wires. The guide leads with the picture, because the wiring is the hard part.",
11+
alternates: { canonical: "/build/breadboard" },
12+
openGraph: { ...SITE_OPEN_GRAPH, url: "/build/breadboard" },
13+
};
14+
15+
export default function BreadboardLayout({ children }: { children: React.ReactNode }) {
16+
return children;
17+
}

‎web/src/app/build/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { Metadata } from "next";
22
import HomeView from "@/components/HomeView";
3+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
34

45
export const metadata: Metadata = {
56
title: "Build Your Own — Patternflow",
67
description:
78
"Build the open-source Patternflow LED synthesizer from scratch: the hand-soldered v3.9 board, a printed or laser-cut case, and firmware flashed from the browser. Every file and the full build guide.",
89
alternates: { canonical: "/build" },
910
openGraph: {
11+
...SITE_OPEN_GRAPH,
1012
title: "Build Your Own — Patternflow",
1113
description:
1214
"Build the open-source Patternflow LED synthesizer from scratch — firmware, PCB, a printed or laser-cut case, and the browser flasher.",

‎web/src/app/contact/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Metadata } from "next";
2+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
23
import ContactPage from "@/components/contact/ContactPage";
34

45
export const metadata: Metadata = {
56
title: "Contact / Patternflow",
67
description:
78
"Partnership, installation, distribution, and custom AV inquiries for Patternflow.",
9+
openGraph: { ...SITE_OPEN_GRAPH, url: "/contact" },
810
};
911

1012
export default function ContactRoute() {

‎web/src/app/features/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import Link from "next/link";
3+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
34
import shelf from "../editions/Editions.module.css";
45
import ShelfTabs from "../editions/ShelfTabs";
56
import styles from "./Features.module.css";
@@ -26,6 +27,7 @@ export const metadata: Metadata = {
2627
description:
2728
"What a Patternflow panel can do, one feature at a time: a microphone, audio from a browser or phone, OSC, MIDI, a clock, MQTT, sequences, the weather. What each needs, which firmware carries it, and a reel of it running.",
2829
alternates: { canonical: "/features" },
30+
openGraph: { ...SITE_OPEN_GRAPH, url: "/features" },
2931
};
3032

3133
export const dynamic = "force-static";

‎web/src/app/inside/[build]/page.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { notFound } from "next/navigation";
33
import HomeView from "@/components/HomeView";
44
import { builds, buildBySlug } from "@/components/sections/InsideGlobe/builds";
5+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
56

67
const SITE_URL = "https://patternflow.work";
78

@@ -26,10 +27,12 @@ export async function generateMetadata({ params }: RouteParams): Promise<Metadat
2627
description: build.description,
2728
alternates: { canonical: `/inside/${build.slug}` },
2829
openGraph: {
30+
...SITE_OPEN_GRAPH,
2931
title,
3032
description: build.description,
3133
url,
32-
images: photo ? [{ url: `${SITE_URL}${photo.src}`, alt: photo.alt }] : undefined,
34+
// The build's own photo when it has one, the site's picture when not.
35+
...(photo ? { images: [{ url: `${SITE_URL}${photo.src}`, alt: photo.alt }] } : {}),
3336
},
3437
};
3538
}

‎web/src/app/inside/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { Metadata } from "next";
22
import HomeView from "@/components/HomeView";
3+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
34

45
export const metadata: Metadata = {
56
title: "Inside Patternflow — How It's Built",
67
description:
78
"How Patternflow works: an ESP32-S3 driving a HUB75 RGB LED matrix, four rotary encoders, and a modular C++ pattern architecture — a contemporary reinterpretation of Nam June Paik's Participation TV.",
89
alternates: { canonical: "/inside" },
910
openGraph: {
11+
...SITE_OPEN_GRAPH,
1012
title: "Inside Patternflow — How It's Built",
1113
description:
1214
"ESP32-S3, a HUB75 RGB LED matrix, four rotary encoders, and a modular C++ pattern architecture.",

‎web/src/app/layout.tsx‎

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Analytics } from "@vercel/analytics/next";
66
import DeviceHostCapture from "@/components/DeviceHostCapture";
77
import { SpeedInsights } from "@vercel/speed-insights/next";
88
import { LEGACY_GUIDE_REDIRECT } from "@/components/guide/legacy";
9+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
910

1011
const inter = Inter({
1112
variable: "--font-inter",
@@ -64,27 +65,13 @@ export const metadata: Metadata = {
6465
apple: "/apple-touch-icon.png",
6566
shortcut: "/favicon.ico",
6667
},
67-
openGraph: {
68-
type: "website",
69-
url: "https://patternflow.work",
70-
title: "Patternflow — An LED synthesizer",
71-
description:
72-
"An open-source LED synthesizer. Play light patterns with your fingertips — and every Patternflow plays every pattern we make.",
73-
images: [
74-
{
75-
url: "/og-image.png",
76-
width: 1200,
77-
height: 630,
78-
type: "image/png",
79-
},
80-
],
81-
},
68+
// No title, description or url: those are each page's (lib/siteMetadata.ts).
69+
// The home page's own are in page.tsx.
70+
openGraph: SITE_OPEN_GRAPH,
71+
// The card only. Next copies the title, description and picture into it
72+
// from the page's openGraph, and it copies nothing over a title set here.
8273
twitter: {
8374
card: "summary_large_image",
84-
title: "Patternflow — An LED synthesizer",
85-
description:
86-
"Patternflow is an open-source LED synthesizer. Play light with your fingertips.",
87-
images: ["/og-image.png"],
8875
},
8976
alternates: {
9077
types: {

‎web/src/app/metadata.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { Metadata } from "next";
3+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
4+
import { guideMetadata } from "@/components/guide/meta";
5+
import { PAGES, pagePath } from "@/components/guide/pages";
6+
import { builds } from "@/components/sections/InsideGlobe/builds";
7+
import type { GuideScreen } from "@/components/guide/store";
8+
9+
// A link pasted into a chat shows the page's own title, address and a
10+
// picture. Next merges metadata one key deep, so the root layout's openGraph
11+
// and twitter decide what every page without its own carries, and a page that
12+
// sets openGraph keeps the picture only by spreading SITE_OPEN_GRAPH in.
13+
14+
// The pages render nothing here: only their metadata is read.
15+
vi.mock("@/components/HomeView", () => ({ default: () => null }));
16+
vi.mock("next/font/google", () => {
17+
const font = () => ({ variable: "", className: "" });
18+
return { Inter: font, JetBrains_Mono: font, DM_Sans: font, Silkscreen: font, Newsreader: font };
19+
});
20+
21+
type OpenGraph = NonNullable<Metadata["openGraph"]>;
22+
23+
function urlOf(og: OpenGraph): string {
24+
return String(og.url ?? "");
25+
}
26+
27+
describe("site metadata", () => {
28+
it("leaves each page's title and address to the page, in the root layout", async () => {
29+
const { metadata } = await import("@/app/layout");
30+
const og = metadata.openGraph as OpenGraph;
31+
expect(og).toEqual(SITE_OPEN_GRAPH);
32+
expect(og).not.toHaveProperty("title");
33+
expect(og).not.toHaveProperty("url");
34+
// Next copies a page's og:title into twitter:title only when no layout
35+
// set one.
36+
expect(metadata.twitter).toEqual({ card: "summary_large_image" });
37+
});
38+
39+
it.each([
40+
["/", () => import("@/app/page")],
41+
["/pattern", () => import("@/app/pattern/page")],
42+
["/build", () => import("@/app/build/page")],
43+
["/build/breadboard", () => import("@/app/build/breadboard/layout")],
44+
["/inside", () => import("@/app/inside/page")],
45+
["/features", () => import("@/app/features/page")],
46+
["/contact", () => import("@/app/contact/page")],
47+
["/terms", () => import("@/app/terms/page")],
48+
["/update", () => import("@/app/update/page")],
49+
] as const)("%s previews with its own address and the site's picture", async (path, load) => {
50+
const { metadata } = (await load()) as { metadata: Metadata };
51+
const og = metadata.openGraph as OpenGraph;
52+
expect(og.images).toEqual(SITE_OPEN_GRAPH.images);
53+
expect(urlOf(og).replace("https://patternflow.work", "") || "/").toBe(path);
54+
});
55+
56+
it("gives /build/breadboard a title of its own", async () => {
57+
const { metadata } = await import("@/app/build/breadboard/layout");
58+
expect(metadata.title).toBe("Breadboard Build Guide / Patternflow");
59+
});
60+
61+
it("gives every guide page, in both languages, the site's picture", () => {
62+
const screens: GuideScreen[] = ["hub", ...(Object.keys(PAGES) as GuideScreen[])];
63+
for (const page of screens) {
64+
for (const lang of ["en", "ko"] as const) {
65+
const og = guideMetadata(page, lang).openGraph as OpenGraph;
66+
expect(og.images).toEqual(SITE_OPEN_GRAPH.images);
67+
expect(urlOf(og)).toBe(pagePath(page, lang));
68+
}
69+
}
70+
});
71+
72+
it("previews a build on the map with its photo, or the site's picture without one", async () => {
73+
const { generateMetadata } = await import("@/app/inside/[build]/page");
74+
const ogOf = async (slug: string) =>
75+
(await generateMetadata({ params: Promise.resolve({ build: slug }) })).openGraph as OpenGraph;
76+
77+
const withPhoto = builds.find((b) => b.images?.length);
78+
expect(withPhoto).toBeDefined();
79+
expect(await ogOf(withPhoto!.slug)).toMatchObject({
80+
images: [{ url: `https://patternflow.work${withPhoto!.images![0].src}` }],
81+
url: `https://patternflow.work/inside/${withPhoto!.slug}`,
82+
});
83+
84+
const withoutPhoto = builds.find((b) => !b.images?.length);
85+
if (withoutPhoto) {
86+
expect((await ogOf(withoutPhoto.slug)).images).toEqual(SITE_OPEN_GRAPH.images);
87+
}
88+
});
89+
});

‎web/src/app/page.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
1+
import type { Metadata } from "next";
12
import HomeView from "@/components/HomeView";
3+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
4+
5+
// The <title> and description are the root layout's. The preview is here,
6+
// not there, so that a page without one does not borrow the home page's.
7+
export const metadata: Metadata = {
8+
openGraph: {
9+
...SITE_OPEN_GRAPH,
10+
url: "https://patternflow.work",
11+
title: "Patternflow — An LED synthesizer",
12+
description:
13+
"An open-source LED synthesizer. Play light patterns with your fingertips — and every Patternflow plays every pattern we make.",
14+
},
15+
twitter: {
16+
card: "summary_large_image",
17+
description:
18+
"Patternflow is an open-source LED synthesizer. Play light with your fingertips.",
19+
},
20+
};
221

322
export default function Home() {
423
return <HomeView initialTab="hero" />;

‎web/src/app/pattern/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { Metadata } from "next";
22
import HomeView from "@/components/HomeView";
3+
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
34

45
export const metadata: Metadata = {
56
title: "Live Editor & Patterns — Patternflow",
67
description:
78
"Browse a preset library of LED matrix patterns and remix them in the browser Live Editor. Generate your own with AI, preview on virtual knobs, and flash to the device.",
89
alternates: { canonical: "/pattern" },
910
openGraph: {
11+
...SITE_OPEN_GRAPH,
1012
title: "Live Editor & Patterns — Patternflow",
1113
description:
1214
"Browse and remix LED matrix patterns in the browser, generate your own with AI, and flash them to the device.",

0 commit comments

Comments
 (0)