@upstart.gg/vite-plugins 0.1.60 → 0.1.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/page-meta.js +533 -0
- package/dist/page-meta.js.map +1 -0
- package/dist/site-meta.d.ts +28 -0
- package/dist/site-meta.d.ts.map +1 -0
- package/dist/site-meta.js +207 -0
- package/dist/site-meta.js.map +1 -0
- package/dist/upstart-editor-api.d.ts +135 -1
- package/dist/upstart-editor-api.d.ts.map +1 -1
- package/dist/upstart-editor-api.js +756 -1
- package/dist/upstart-editor-api.js.map +1 -1
- package/dist/vite-plugin-upstart-attrs.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-attrs.js +130 -10
- package/dist/vite-plugin-upstart-attrs.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/index.js +35 -0
- package/dist/vite-plugin-upstart-editor/runtime/index.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.d.ts.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js +139 -14
- package/dist/vite-plugin-upstart-editor/runtime/text-editor.js.map +1 -1
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts +3 -0
- package/dist/vite-plugin-upstart-editor/runtime/types.d.ts.map +1 -1
- package/package.json +8 -3
- package/src/page-meta.ts +678 -0
- package/src/site-meta.ts +226 -0
- package/src/tests/site-meta.test.ts +158 -0
- package/src/tests/upstart-editor-api-page-meta.test.ts +635 -0
- package/src/tests/vite-plugin-upstart-attrs.test.ts +224 -13
- package/src/upstart-editor-api.ts +941 -0
- package/src/vite-plugin-upstart-attrs.ts +253 -14
- package/src/vite-plugin-upstart-editor/runtime/index.ts +38 -0
- package/src/vite-plugin-upstart-editor/runtime/text-editor.ts +141 -14
- package/src/vite-plugin-upstart-editor/runtime/types.ts +2 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { UpstartEditorAPI } from "../upstart-editor-api";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
|
|
7
|
+
describe("UpstartEditorAPI page meta", () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
let api: UpstartEditorAPI;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upstart-meta-test-"));
|
|
13
|
+
api = new UpstartEditorAPI(tempDir, path.join(tempDir, "registry.json"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function writeFile(relativePath: string, content: string) {
|
|
21
|
+
const filePath = path.join(tempDir, relativePath);
|
|
22
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
23
|
+
await fs.writeFile(filePath, content);
|
|
24
|
+
return filePath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const writeRoute = (routeId: string, content: string) => writeFile(`app/${routeId}.tsx`, content);
|
|
28
|
+
const readRoute = (routeId: string) => fs.readFile(path.join(tempDir, "app", `${routeId}.tsx`), "utf-8");
|
|
29
|
+
|
|
30
|
+
const writeLocale = (language: string, data: Record<string, unknown>) =>
|
|
31
|
+
writeFile(`app/locales/${language}/translation.json`, `${JSON.stringify(data, null, 2)}\n`);
|
|
32
|
+
|
|
33
|
+
const readLocale = async (language: string) =>
|
|
34
|
+
JSON.parse(
|
|
35
|
+
await fs.readFile(path.join(tempDir, "app", "locales", language, "translation.json"), "utf-8"),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** The shape the AI assistant generates: loader reads i18n, component renders the tags. */
|
|
39
|
+
const i18nRoute = (titleKey: string, descriptionKey: string, titleExpr = "{title}") => `
|
|
40
|
+
import { getInstance } from "~/middlewares/i18next.server";
|
|
41
|
+
import type { Route } from "./+types/_layout.about";
|
|
42
|
+
|
|
43
|
+
export async function loader({ context }: Route.LoaderArgs) {
|
|
44
|
+
const i18n = getInstance(context);
|
|
45
|
+
return {
|
|
46
|
+
title: i18n.t("${titleKey}"),
|
|
47
|
+
description: i18n.t("${descriptionKey}"),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function AboutPage({ loaderData }: Route.ComponentProps) {
|
|
52
|
+
const { title, description } = loaderData;
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<title>${titleExpr}</title>
|
|
56
|
+
<meta name="description" content={description} />
|
|
57
|
+
<h1>Hello</h1>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
describe("getPageMeta — translated routes", () => {
|
|
64
|
+
test("reads the values from the locale files", async () => {
|
|
65
|
+
await writeRoute("routes/_layout.about", i18nRoute("about.meta.title", "about.meta.description"));
|
|
66
|
+
await writeLocale("en", {
|
|
67
|
+
"about.meta.title": "About us",
|
|
68
|
+
"about.meta.description": "Who we are",
|
|
69
|
+
});
|
|
70
|
+
await writeLocale("fr", {
|
|
71
|
+
"about.meta.title": "À propos",
|
|
72
|
+
"about.meta.description": "Qui sommes-nous",
|
|
73
|
+
});
|
|
74
|
+
await writeFile("app/config/site.json", JSON.stringify({ defaultLanguage: "en" }));
|
|
75
|
+
|
|
76
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout.about" });
|
|
77
|
+
|
|
78
|
+
expect(result).toMatchObject({
|
|
79
|
+
success: true,
|
|
80
|
+
mode: "jsx",
|
|
81
|
+
language: "en",
|
|
82
|
+
languages: ["en", "fr"],
|
|
83
|
+
title: {
|
|
84
|
+
value: "About us",
|
|
85
|
+
editable: true,
|
|
86
|
+
translated: true,
|
|
87
|
+
i18nKey: "about.meta.title",
|
|
88
|
+
shared: false,
|
|
89
|
+
},
|
|
90
|
+
description: { value: "Who we are", editable: true, translated: true },
|
|
91
|
+
robotsIndexing: true,
|
|
92
|
+
robotsEditable: true,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("reads another language on request", async () => {
|
|
97
|
+
await writeRoute("routes/_layout.about", i18nRoute("about.meta.title", "about.meta.description"));
|
|
98
|
+
await writeLocale("en", { "about.meta.title": "About us" });
|
|
99
|
+
await writeLocale("fr", { "about.meta.title": "À propos" });
|
|
100
|
+
|
|
101
|
+
const result = await api.getPageMeta({
|
|
102
|
+
action: "getPageMeta",
|
|
103
|
+
routeId: "routes/_layout.about",
|
|
104
|
+
language: "fr",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(result).toMatchObject({ success: true, language: "fr", title: { value: "À propos" } });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("flags a key that is also used elsewhere as shared", async () => {
|
|
111
|
+
await writeRoute("routes/_layout.atelier", i18nRoute("atelier.title", "atelier.subtitle"));
|
|
112
|
+
await writeFile(
|
|
113
|
+
"app/components/Hero.tsx",
|
|
114
|
+
`export const Hero = () => <h1><Trans i18nKey="atelier.title" /></h1>;\n`,
|
|
115
|
+
);
|
|
116
|
+
await writeLocale("en", { "atelier.title": "Workshop", "atelier.subtitle": "Our workshop" });
|
|
117
|
+
|
|
118
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout.atelier" });
|
|
119
|
+
|
|
120
|
+
expect(result).toMatchObject({
|
|
121
|
+
success: true,
|
|
122
|
+
title: { shared: true },
|
|
123
|
+
description: { shared: false },
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("keeps the static text around a composed title", async () => {
|
|
128
|
+
await writeRoute(
|
|
129
|
+
"routes/_layout.atelier",
|
|
130
|
+
i18nRoute("atelier.meta.title", "atelier.meta.description", "{`${title} | Acme`}"),
|
|
131
|
+
);
|
|
132
|
+
await writeLocale("en", { "atelier.meta.title": "Workshop" });
|
|
133
|
+
|
|
134
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout.atelier" });
|
|
135
|
+
|
|
136
|
+
expect(result).toMatchObject({
|
|
137
|
+
success: true,
|
|
138
|
+
title: { value: "Workshop", editable: true, prefix: "", suffix: " | Acme" },
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("edits the first translated part of a title composed of several texts", async () => {
|
|
143
|
+
await writeRoute(
|
|
144
|
+
"routes/_layout._index",
|
|
145
|
+
`
|
|
146
|
+
import { getInstance } from "~/middlewares/i18next.server";
|
|
147
|
+
|
|
148
|
+
export async function loader({ context }) {
|
|
149
|
+
const i18n = getInstance(context);
|
|
150
|
+
return { title: i18n.t("site.title"), tagline: i18n.t("site.tagline") };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default function HomePage({ loaderData }) {
|
|
154
|
+
const { title, tagline } = loaderData;
|
|
155
|
+
return (
|
|
156
|
+
<>
|
|
157
|
+
<title>{\`\${title} - \${tagline}\`}</title>
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
await writeLocale("en", { "site.title": "Acme", "site.tagline": "We build things" });
|
|
164
|
+
|
|
165
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout._index" });
|
|
166
|
+
|
|
167
|
+
// The first addressable part is the value; the other translation is shown as context.
|
|
168
|
+
expect(result).toMatchObject({
|
|
169
|
+
success: true,
|
|
170
|
+
title: { value: "Acme", editable: true, i18nKey: "site.title", suffix: " - We build things" },
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("edits a title composed inside the loader", async () => {
|
|
175
|
+
await writeRoute(
|
|
176
|
+
"routes/_layout.cloud",
|
|
177
|
+
`
|
|
178
|
+
import { getInstance } from "~/middlewares/i18next.server";
|
|
179
|
+
|
|
180
|
+
export async function loader({ context }) {
|
|
181
|
+
const i18n = getInstance(context);
|
|
182
|
+
return { pageTitle: \`\${i18n.t("cloud.title")} — \${i18n.t("site.title")}\` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default function CloudPage({ loaderData }) {
|
|
186
|
+
const { pageTitle } = loaderData;
|
|
187
|
+
return (<><title>{pageTitle}</title></>);
|
|
188
|
+
}
|
|
189
|
+
`,
|
|
190
|
+
);
|
|
191
|
+
await writeLocale("en", { "cloud.title": "Your cloud", "site.title": "Acme" });
|
|
192
|
+
|
|
193
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout.cloud" });
|
|
194
|
+
|
|
195
|
+
expect(result).toMatchObject({
|
|
196
|
+
success: true,
|
|
197
|
+
title: { value: "Your cloud", editable: true, i18nKey: "cloud.title", suffix: " — Acme" },
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("marks a database-driven title as read-only", async () => {
|
|
202
|
+
await writeRoute(
|
|
203
|
+
"routes/_layout.projects.$slug",
|
|
204
|
+
`
|
|
205
|
+
export async function loader() {
|
|
206
|
+
const project = await getProject();
|
|
207
|
+
return { title: \`\${project.title} — Studio\`, description: project.summary };
|
|
208
|
+
}
|
|
209
|
+
export default function Project({ loaderData }) {
|
|
210
|
+
const { title, description } = loaderData;
|
|
211
|
+
return (<><title>{title}</title><meta name="description" content={description} /></>);
|
|
212
|
+
}
|
|
213
|
+
`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const result = await api.getPageMeta({
|
|
217
|
+
action: "getPageMeta",
|
|
218
|
+
routeId: "routes/_layout.projects.$slug",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(result).toMatchObject({
|
|
222
|
+
success: true,
|
|
223
|
+
title: { editable: false },
|
|
224
|
+
description: { editable: false },
|
|
225
|
+
});
|
|
226
|
+
expect(result).toHaveProperty("title.reason");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("routes delegating to a shared SEO component", () => {
|
|
231
|
+
/** Some sites render their meta through a component instead of inline tags. */
|
|
232
|
+
const seoComponent = `
|
|
233
|
+
export default function SeoHead({ title, description, image = "/og.png" }: Props) {
|
|
234
|
+
const fullTitle = \`\${title} | Acme\`;
|
|
235
|
+
return (
|
|
236
|
+
<>
|
|
237
|
+
<title>{fullTitle}</title>
|
|
238
|
+
<meta name="description" content={description} />
|
|
239
|
+
<meta property="og:image" content={image} />
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
const delegatingRoute = (descriptionExpr: string) => `
|
|
246
|
+
import SeoHead from "~/components/SeoHead";
|
|
247
|
+
import { getInstance } from "~/middlewares/i18next.server";
|
|
248
|
+
|
|
249
|
+
export async function loader({ context }) {
|
|
250
|
+
const i18n = getInstance(context);
|
|
251
|
+
return { title: i18n.t("about.meta.title"), description: i18n.t("about.meta.description") };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export default function AboutPage({ loaderData }) {
|
|
255
|
+
const { title, description } = loaderData;
|
|
256
|
+
return (
|
|
257
|
+
<>
|
|
258
|
+
<SeoHead title={title} description={${descriptionExpr}} />
|
|
259
|
+
<h1>About</h1>
|
|
260
|
+
</>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
`;
|
|
264
|
+
|
|
265
|
+
beforeEach(async () => {
|
|
266
|
+
await writeFile("app/components/SeoHead.tsx", seoComponent);
|
|
267
|
+
await writeLocale("en", {
|
|
268
|
+
"about.meta.title": "About us",
|
|
269
|
+
"about.meta.description": "Who we are",
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("resolves the values through the component's props", async () => {
|
|
274
|
+
await writeRoute("routes/_layout.about", delegatingRoute("description"));
|
|
275
|
+
|
|
276
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout.about" });
|
|
277
|
+
|
|
278
|
+
expect(result).toMatchObject({
|
|
279
|
+
success: true,
|
|
280
|
+
mode: "jsx",
|
|
281
|
+
// The suffix is built inside the component, around the prop.
|
|
282
|
+
title: { value: "About us", editable: true, i18nKey: "about.meta.title", suffix: " | Acme" },
|
|
283
|
+
description: { value: "Who we are", editable: true, i18nKey: "about.meta.description" },
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("writes a translated value to the locale, leaving both source files alone", async () => {
|
|
288
|
+
await writeRoute("routes/_layout.about", delegatingRoute("description"));
|
|
289
|
+
const routeBefore = await readRoute("routes/_layout.about");
|
|
290
|
+
|
|
291
|
+
const result = await api.setPageMeta({
|
|
292
|
+
action: "setPageMeta",
|
|
293
|
+
routeId: "routes/_layout.about",
|
|
294
|
+
language: "en",
|
|
295
|
+
title: "About our studio",
|
|
296
|
+
description: "Who we are",
|
|
297
|
+
keywords: "",
|
|
298
|
+
robotsIndexing: true,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(result.success).toBe(true);
|
|
302
|
+
expect(await readLocale("en")).toMatchObject({ "about.meta.title": "About our studio" });
|
|
303
|
+
expect(await readRoute("routes/_layout.about")).toBe(routeBefore);
|
|
304
|
+
expect(await fs.readFile(path.join(tempDir, "app", "components", "SeoHead.tsx"), "utf-8")).toBe(
|
|
305
|
+
seoComponent,
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("edits a value the route passes as a plain string", async () => {
|
|
310
|
+
await writeRoute("routes/_layout.about", delegatingRoute('"Static description"'));
|
|
311
|
+
|
|
312
|
+
const before = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/_layout.about" });
|
|
313
|
+
expect(before).toMatchObject({ description: { value: "Static description", translated: false } });
|
|
314
|
+
|
|
315
|
+
await api.setPageMeta({
|
|
316
|
+
action: "setPageMeta",
|
|
317
|
+
routeId: "routes/_layout.about",
|
|
318
|
+
language: "en",
|
|
319
|
+
title: "About us",
|
|
320
|
+
description: "A new description",
|
|
321
|
+
keywords: "",
|
|
322
|
+
robotsIndexing: true,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// The literal belongs to the route, not to the shared component.
|
|
326
|
+
expect(await readRoute("routes/_layout.about")).toContain('description={"A new description"}');
|
|
327
|
+
expect(await fs.readFile(path.join(tempDir, "app", "components", "SeoHead.tsx"), "utf-8")).toBe(
|
|
328
|
+
seoComponent,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("adds page-specific tags next to the component rather than inside it", async () => {
|
|
333
|
+
await writeRoute("routes/_layout.about", delegatingRoute("description"));
|
|
334
|
+
|
|
335
|
+
await api.setPageMeta({
|
|
336
|
+
action: "setPageMeta",
|
|
337
|
+
routeId: "routes/_layout.about",
|
|
338
|
+
language: "en",
|
|
339
|
+
title: "About us",
|
|
340
|
+
description: "Who we are",
|
|
341
|
+
keywords: "about, team",
|
|
342
|
+
robotsIndexing: false,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const code = await readRoute("routes/_layout.about");
|
|
346
|
+
expect(code).toContain('<meta name="keywords" content="about, team" />');
|
|
347
|
+
expect(code).toContain('<meta name="robots" content="noindex, nofollow" />');
|
|
348
|
+
expect(code.indexOf("<SeoHead")).toBeLessThan(code.indexOf('name="keywords"'));
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("setPageMeta — translated routes", () => {
|
|
353
|
+
test("writes the value to the locale of the selected language only", async () => {
|
|
354
|
+
await writeRoute("routes/_layout.about", i18nRoute("about.meta.title", "about.meta.description"));
|
|
355
|
+
await writeLocale("en", { "about.meta.title": "About us", "about.meta.description": "Who we are" });
|
|
356
|
+
await writeLocale("fr", {
|
|
357
|
+
"about.meta.title": "À propos",
|
|
358
|
+
"about.meta.description": "Qui sommes-nous",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const result = await api.setPageMeta({
|
|
362
|
+
action: "setPageMeta",
|
|
363
|
+
routeId: "routes/_layout.about",
|
|
364
|
+
language: "fr",
|
|
365
|
+
title: "À propos de nous",
|
|
366
|
+
description: "Qui sommes-nous",
|
|
367
|
+
keywords: "",
|
|
368
|
+
robotsIndexing: true,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(result.success).toBe(true);
|
|
372
|
+
expect(await readLocale("fr")).toMatchObject({ "about.meta.title": "À propos de nous" });
|
|
373
|
+
expect(await readLocale("en")).toMatchObject({ "about.meta.title": "About us" });
|
|
374
|
+
// Only the locale changed — the route source was left alone.
|
|
375
|
+
expect(result.success && result.filePaths).toHaveLength(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("moves a shared key onto a dedicated one instead of rewriting the shared text", async () => {
|
|
379
|
+
await writeRoute("routes/_layout.atelier", i18nRoute("atelier.title", "atelier.subtitle"));
|
|
380
|
+
await writeFile(
|
|
381
|
+
"app/components/Hero.tsx",
|
|
382
|
+
`export const Hero = () => <h1><Trans i18nKey="atelier.title" /></h1>;\n`,
|
|
383
|
+
);
|
|
384
|
+
await writeLocale("en", { "atelier.title": "Workshop", "atelier.subtitle": "Our workshop" });
|
|
385
|
+
await writeLocale("fr", { "atelier.title": "Atelier", "atelier.subtitle": "Notre atelier" });
|
|
386
|
+
|
|
387
|
+
const result = await api.setPageMeta({
|
|
388
|
+
action: "setPageMeta",
|
|
389
|
+
routeId: "routes/_layout.atelier",
|
|
390
|
+
language: "en",
|
|
391
|
+
title: "Workshop — Acme",
|
|
392
|
+
description: "Our workshop",
|
|
393
|
+
keywords: "",
|
|
394
|
+
robotsIndexing: true,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(result.success).toBe(true);
|
|
398
|
+
const code = await readRoute("routes/_layout.atelier");
|
|
399
|
+
// The loader now points at the page's own key…
|
|
400
|
+
expect(code).toContain(`i18n.t("atelier.meta.title")`);
|
|
401
|
+
expect(code).not.toContain(`i18n.t("atelier.title")`);
|
|
402
|
+
// …the heading keeps the original text in every language…
|
|
403
|
+
expect(await readLocale("en")).toMatchObject({
|
|
404
|
+
"atelier.title": "Workshop",
|
|
405
|
+
"atelier.meta.title": "Workshop — Acme",
|
|
406
|
+
});
|
|
407
|
+
// …and the untouched languages get the previous value copied over, not an empty string.
|
|
408
|
+
expect(await readLocale("fr")).toMatchObject({
|
|
409
|
+
"atelier.title": "Atelier",
|
|
410
|
+
"atelier.meta.title": "Atelier",
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("does not split a shared key when the value did not change", async () => {
|
|
415
|
+
await writeRoute("routes/_layout.atelier", i18nRoute("atelier.title", "atelier.subtitle"));
|
|
416
|
+
await writeFile("app/components/Hero.tsx", `<Trans i18nKey="atelier.title" />\n`);
|
|
417
|
+
await writeLocale("en", { "atelier.title": "Workshop", "atelier.subtitle": "Our workshop" });
|
|
418
|
+
const before = await readRoute("routes/_layout.atelier");
|
|
419
|
+
|
|
420
|
+
await api.setPageMeta({
|
|
421
|
+
action: "setPageMeta",
|
|
422
|
+
routeId: "routes/_layout.atelier",
|
|
423
|
+
language: "en",
|
|
424
|
+
title: "Workshop",
|
|
425
|
+
description: "Our workshop",
|
|
426
|
+
keywords: "",
|
|
427
|
+
robotsIndexing: true,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(await readRoute("routes/_layout.atelier")).toBe(before);
|
|
431
|
+
expect(await readLocale("en")).toEqual({
|
|
432
|
+
"atelier.title": "Workshop",
|
|
433
|
+
"atelier.subtitle": "Our workshop",
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("leaves languages that never translated the shared key untouched", async () => {
|
|
438
|
+
await writeRoute("routes/_layout.atelier", i18nRoute("atelier.title", "atelier.subtitle"));
|
|
439
|
+
await writeFile("app/components/Hero.tsx", `<Trans i18nKey="atelier.title" />\n`);
|
|
440
|
+
await writeLocale("fr", { "atelier.title": "Atelier", "atelier.subtitle": "Notre atelier" });
|
|
441
|
+
await writeLocale("en", { "dummy.example": "placeholder" });
|
|
442
|
+
|
|
443
|
+
await api.setPageMeta({
|
|
444
|
+
action: "setPageMeta",
|
|
445
|
+
routeId: "routes/_layout.atelier",
|
|
446
|
+
language: "fr",
|
|
447
|
+
title: "Atelier & réparation",
|
|
448
|
+
description: "Notre atelier",
|
|
449
|
+
keywords: "",
|
|
450
|
+
robotsIndexing: true,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(await readLocale("fr")).toMatchObject({ "atelier.meta.title": "Atelier & réparation" });
|
|
454
|
+
// No empty key created where the shared one did not exist either.
|
|
455
|
+
expect(await readLocale("en")).toEqual({ "dummy.example": "placeholder" });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("adds and removes the keywords and robots tags", async () => {
|
|
459
|
+
await writeRoute("routes/_layout.about", i18nRoute("about.meta.title", "about.meta.description"));
|
|
460
|
+
await writeLocale("en", { "about.meta.title": "About us", "about.meta.description": "Who we are" });
|
|
461
|
+
|
|
462
|
+
await api.setPageMeta({
|
|
463
|
+
action: "setPageMeta",
|
|
464
|
+
routeId: "routes/_layout.about",
|
|
465
|
+
language: "en",
|
|
466
|
+
title: "About us",
|
|
467
|
+
description: "Who we are",
|
|
468
|
+
keywords: "about, team",
|
|
469
|
+
robotsIndexing: false,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
let code = await readRoute("routes/_layout.about");
|
|
473
|
+
expect(code).toContain(`<meta name="keywords" content="about, team" />`);
|
|
474
|
+
expect(code).toContain(`<meta name="robots" content="noindex, nofollow" />`);
|
|
475
|
+
|
|
476
|
+
await api.setPageMeta({
|
|
477
|
+
action: "setPageMeta",
|
|
478
|
+
routeId: "routes/_layout.about",
|
|
479
|
+
language: "en",
|
|
480
|
+
title: "About us",
|
|
481
|
+
description: "Who we are",
|
|
482
|
+
keywords: "about, team",
|
|
483
|
+
robotsIndexing: true,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
code = await readRoute("routes/_layout.about");
|
|
487
|
+
expect(code).toContain(`name="keywords"`);
|
|
488
|
+
expect(code).not.toContain(`name="robots"`);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("rewrites a plain string title in the source", async () => {
|
|
492
|
+
await writeRoute(
|
|
493
|
+
"routes/_layout.legal",
|
|
494
|
+
`export default function Legal() {\n return (<><title>Legal</title></>);\n}\n`,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
await api.setPageMeta({
|
|
498
|
+
action: "setPageMeta",
|
|
499
|
+
routeId: "routes/_layout.legal",
|
|
500
|
+
language: "en",
|
|
501
|
+
title: "Legal notice",
|
|
502
|
+
description: "",
|
|
503
|
+
keywords: "",
|
|
504
|
+
robotsIndexing: true,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(await readRoute("routes/_layout.legal")).toContain("<title>Legal notice</title>");
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe("meta export fallback", () => {
|
|
512
|
+
test("reads a static meta export when the route renders no meta tags", async () => {
|
|
513
|
+
await writeRoute(
|
|
514
|
+
"routes/about",
|
|
515
|
+
`export const meta = () => [
|
|
516
|
+
{ title: "About us" },
|
|
517
|
+
{ name: "description", content: "Who we are" },
|
|
518
|
+
{ name: "robots", content: "noindex, nofollow" },
|
|
519
|
+
];
|
|
520
|
+
`,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const result = await api.getPageMeta({ action: "getPageMeta", routeId: "routes/about" });
|
|
524
|
+
|
|
525
|
+
expect(result).toMatchObject({
|
|
526
|
+
success: true,
|
|
527
|
+
mode: "meta-export",
|
|
528
|
+
title: { value: "About us", editable: true, translated: false },
|
|
529
|
+
description: { value: "Who we are" },
|
|
530
|
+
robotsIndexing: false,
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("creates a meta export after the imports when the route has none", async () => {
|
|
535
|
+
await writeRoute(
|
|
536
|
+
"routes/_index",
|
|
537
|
+
`import { Welcome } from "~/components/Welcome";
|
|
538
|
+
|
|
539
|
+
export default function Index() {
|
|
540
|
+
return <Welcome />;
|
|
541
|
+
}
|
|
542
|
+
`,
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const result = await api.setPageMeta({
|
|
546
|
+
action: "setPageMeta",
|
|
547
|
+
routeId: "routes/_index",
|
|
548
|
+
language: "en",
|
|
549
|
+
title: "Home",
|
|
550
|
+
description: "The home page",
|
|
551
|
+
keywords: "",
|
|
552
|
+
robotsIndexing: true,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(result.success).toBe(true);
|
|
556
|
+
const code = await readRoute("routes/_index");
|
|
557
|
+
expect(code).toContain(`export const meta = () => [\n { title: "Home" },`);
|
|
558
|
+
expect(code).toContain(`{ name: "description", content: "The home page" },`);
|
|
559
|
+
expect(code).not.toContain("keywords");
|
|
560
|
+
expect(code.indexOf("export const meta")).toBeGreaterThan(code.indexOf("import"));
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("preserves unmanaged entries and drops cleared ones", async () => {
|
|
564
|
+
await writeRoute(
|
|
565
|
+
"routes/about",
|
|
566
|
+
`export const meta = () => [
|
|
567
|
+
{ title: "Old title" },
|
|
568
|
+
{ property: "og:image", content: "/og.png" },
|
|
569
|
+
{ name: "keywords", content: "old" },
|
|
570
|
+
];
|
|
571
|
+
`,
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
await api.setPageMeta({
|
|
575
|
+
action: "setPageMeta",
|
|
576
|
+
routeId: "routes/about",
|
|
577
|
+
language: "en",
|
|
578
|
+
title: "New title",
|
|
579
|
+
description: "",
|
|
580
|
+
keywords: "",
|
|
581
|
+
robotsIndexing: true,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const code = await readRoute("routes/about");
|
|
585
|
+
expect(code).toContain(`{ title: "New title" },`);
|
|
586
|
+
expect(code).toContain(`{ property: "og:image", content: "/og.png" },`);
|
|
587
|
+
expect(code).not.toContain("keywords");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("refuses to rewrite a computed meta export", async () => {
|
|
591
|
+
const original = `export const meta = ({ data }) => [{ title: data.title }];\n`;
|
|
592
|
+
await writeRoute("routes/post.$slug", original);
|
|
593
|
+
|
|
594
|
+
const result = await api.setPageMeta({
|
|
595
|
+
action: "setPageMeta",
|
|
596
|
+
routeId: "routes/post.$slug",
|
|
597
|
+
language: "en",
|
|
598
|
+
title: "Nope",
|
|
599
|
+
description: "",
|
|
600
|
+
keywords: "",
|
|
601
|
+
robotsIndexing: true,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(result.success).toBe(false);
|
|
605
|
+
expect(await readRoute("routes/post.$slug")).toBe(original);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("accepts the punctuation flat-route ids use", async () => {
|
|
610
|
+
await writeRoute(
|
|
611
|
+
"routes/_layout.($lang)._index",
|
|
612
|
+
`export default function P() {\n return <title>Home</title>;\n}\n`,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
const result = await api.getPageMeta({
|
|
616
|
+
action: "getPageMeta",
|
|
617
|
+
routeId: "routes/_layout.($lang)._index",
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(result).toMatchObject({ success: true, title: { value: "Home" } });
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("rejects route ids that escape the app directory", async () => {
|
|
624
|
+
const result = await api.setPageMeta({
|
|
625
|
+
action: "setPageMeta",
|
|
626
|
+
routeId: "../../etc/passwd",
|
|
627
|
+
language: "en",
|
|
628
|
+
title: "x",
|
|
629
|
+
description: "",
|
|
630
|
+
keywords: "",
|
|
631
|
+
robotsIndexing: true,
|
|
632
|
+
});
|
|
633
|
+
expect(result.success).toBe(false);
|
|
634
|
+
});
|
|
635
|
+
});
|