feat: Add default canonical link (#4776)

## Description

1. What is this PR about (link the issue and add a short description)

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Ivan Starkov
2025-01-22 21:22:40 +03:00
committed by GitHub
parent 627ab09cea
commit 373fb5f11d
28 changed files with 220 additions and 75 deletions

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -1,10 +1,10 @@
{
"build": {
"id": "1bd755a0-9c8a-416b-b373-2123f3568393",
"id": "3173a7d8-1af3-4e23-87fd-94c3b0cb1018",
"projectId": "cddc1d44-af37-4cb6-a430-d300cf6f932d",
"version": 464,
"createdAt": "2025-01-19T14:02:13.405+00:00",
"updatedAt": "2025-01-19T14:02:13.405+00:00",
"version": 474,
"createdAt": "2025-01-22T17:59:53.714+00:00",
"updatedAt": "2025-01-22T17:59:53.714+00:00",
"pages": {
"meta": {
"siteName": "KittyGuardedZone",
@ -2979,6 +2979,26 @@
"type": "boolean",
"value": true
}
],
[
"DohJcBH-kmzk2lvN_F2hS",
{
"id": "DohJcBH-kmzk2lvN_F2hS",
"instanceId": "56x98yMSVYpp7eVo8wuzK",
"name": "rel",
"type": "string",
"value": "canonical"
}
],
[
"_wNeTsN1d6jG8ZM5ud05d",
{
"id": "_wNeTsN1d6jG8ZM5ud05d",
"instanceId": "56x98yMSVYpp7eVo8wuzK",
"name": "href",
"type": "string",
"value": "https://overwritten.slot/head-slot-tag"
}
]
],
"dataSources": [
@ -5026,6 +5046,10 @@
{
"type": "id",
"value": "EveN4Skg9xb8Lj1QJcW52"
},
{
"type": "id",
"value": "56x98yMSVYpp7eVo8wuzK"
}
]
}
@ -5102,6 +5126,16 @@
}
]
}
],
[
"56x98yMSVYpp7eVo8wuzK",
{
"type": "instance",
"id": "56x98yMSVYpp7eVo8wuzK",
"component": "HeadLink",
"label": "Link",
"children": []
}
]
],
"deployment": {

View File

@ -1,26 +1,26 @@
export const sitemap = [
{
path: "/",
lastModified: "2025-01-19",
lastModified: "2025-01-22",
},
{
path: "/_route_with_symbols_",
lastModified: "2025-01-19",
lastModified: "2025-01-22",
},
{
path: "/form",
lastModified: "2025-01-19",
lastModified: "2025-01-22",
},
{
path: "/heading-with-id",
lastModified: "2025-01-19",
lastModified: "2025-01-22",
},
{
path: "/resources",
lastModified: "2025-01-19",
lastModified: "2025-01-22",
},
{
path: "/nested/nested-page",
lastModified: "2025-01-19",
lastModified: "2025-01-22",
},
];

View File

@ -43,6 +43,10 @@ const Page = ({}: { system: any }) => {
<HeadLink rel={"help"} href={"/help-head-slot"} />
<HeadMeta name={"keywords"} content={"Head Slot Content"} />
<HeadMeta content={"Head Slot Content"} property={"og:title"} />
<HeadLink
rel={"canonical"}
href={"https://overwritten.slot/head-slot-tag"}
/>
</HeadSlot>
<Heading className={`w-heading`}>{"Test Head Slot"}</Heading>
<Link href={"/"} className={`w-link`}>

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -6,7 +6,7 @@
"typecheck": "tsc",
"cli": "NODE_OPTIONS='--conditions=webstudio --import=tsx' webstudio",
"fixtures:link": "pnpm cli link --link https://p-cddc1d44-af37-4cb6-a430-d300cf6f932d-dot-${BUILDER_HOST:-main.development.webstudio.is}'?authToken=1cdc6026-dd5b-4624-b89b-9bd45e9bcc3d'",
"fixtures:sync": "pnpm cli sync --buildId 1bd755a0-9c8a-416b-b373-2123f3568393 && pnpm prettier --write ./.webstudio/",
"fixtures:sync": "pnpm cli sync --buildId 3173a7d8-1af3-4e23-87fd-94c3b0cb1018 && pnpm prettier --write ./.webstudio/",
"fixtures:build": "pnpm cli build --template vercel --template internal --template ./.template && pnpm prettier --write ./app/ ./package.json ./tsconfig.json"
},
"private": true,

View File

@ -13,85 +13,92 @@ export const dedupeMeta: Plugin = {
return;
}
console.log("req.url", req.url);
// Capture the original response
const originalWrite = res.write;
const originalEnd = res.end;
if (req.url?.endsWith("/head-tag")) {
// Capture the original response
const originalWrite = res.write;
const originalEnd = res.end;
let body = "";
let body = "";
res.write = (chunk) => {
body += chunk.toString();
return true;
};
res.write = (chunk) => {
res.end = (chunk) => {
if (chunk) {
body += chunk.toString();
return true;
};
}
res.end = (chunk) => {
if (chunk) {
body += chunk.toString();
}
const response = new Response(body);
const response = new Response(body);
const metasSet = new Set<string>();
let hasTitle = false;
let hasCanonicalLink = false;
const metasSet = new Set<string>();
let hasTitle = false;
const rewriter = new HTMLRewriter()
.on("meta", {
element(element) {
const propertyOrName =
element.getAttribute("property") ||
element.getAttribute("name");
const rewriter = new HTMLRewriter()
.on("meta", {
element(element) {
const propertyOrName =
element.getAttribute("property") ||
element.getAttribute("name");
if (propertyOrName === null) {
return;
}
if (propertyOrName === null) {
return;
}
if (propertyOrName === "viewport") {
// Allow "viewport" property deduplication
return;
}
if (propertyOrName === "viewport") {
// Allow "viewport" property deduplication
return;
}
if (metasSet.has(propertyOrName)) {
console.info(
`Duplicate meta with name|property = ${propertyOrName} removed`
);
element.remove();
return;
}
if (metasSet.has(propertyOrName)) {
console.info(
`Duplicate meta with name|property = ${propertyOrName} removed`
);
element.remove();
return;
}
metasSet.add(propertyOrName);
},
})
.on("title", {
element(element) {
if (hasTitle) {
console.info(`Duplicate title removed`);
element.remove();
return;
}
metasSet.add(propertyOrName);
},
})
.on("title", {
element(element) {
if (hasTitle) {
element.remove();
return;
}
hasTitle = true;
},
})
.on('link[rel="canonical"]', {
element(element) {
if (hasCanonicalLink) {
console.info(`Duplicate link rel canonical removed`);
element.remove();
return;
}
hasTitle = true;
},
});
rewriter
// @ts-ignore
.transform(response)
.text()
.then((cleanedHtml) => {
// Send the modified response
res.setHeader("Content-Length", Buffer.byteLength(cleanedHtml));
originalWrite.call(res, cleanedHtml, "utf-8");
originalEnd.call(res, "", "utf-8");
});
hasCanonicalLink = true;
},
});
rewriter
// @ts-ignore
.transform(response)
.text()
.then((cleanedHtml) => {
// Send the modified response
res.setHeader("Content-Length", Buffer.byteLength(cleanedHtml));
originalWrite.call(res, cleanedHtml, "utf-8");
originalEnd.call(res, "", "utf-8");
});
return res;
};
return res;
};
next();
} else {
next();
}
next();
});
},
};

View File

@ -20,6 +20,7 @@ import {
ReactSdkContext,
PageSettingsMeta,
PageSettingsTitle,
PageSettingsCanonicalLink,
} from "@webstudio-is/react-sdk/runtime";
import {
Page,
@ -288,6 +289,7 @@ const Outlet = () => {
imageLoader={imageLoader}
/>
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
<PageSettingsCanonicalLink href={url} />
</ReactSdkContext.Provider>
);
};

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { isElementRenderedWithReact } from "./page-settings-meta";
type PageSettingsCanonicalLinkProps = {
href: string;
};
const isServer = typeof window === "undefined";
/**
* Link canonical tag are deduplicated on the server using the HTMLRewriter interface.
* This is not full deduplication. We simply skip rendering Page Setting link
* if it has already been rendered using HeadSlot/HeadLink.
* To prevent React on the client from re-adding the removed link tag, we skip rendering them client-side.
* This approach works because React retains server-rendered link tag as long as they are not re-rendered by the client.
*
* The following component behavior ensures this:
* 1. On the server: Render link tag as usual.
* 2. On the client: Before rendering, remove any link tag with the same `name` or `property` that were not rendered by Client React,
* and then proceed with rendering as usual.
*/
export const PageSettingsCanonicalLink = (
props: PageSettingsCanonicalLinkProps
) => {
const [localProps, setLocalProps] = useState<
PageSettingsCanonicalLinkProps | undefined
>();
useEffect(() => {
const selector = `head > link[rel="canonical"]`;
let allLinks = document.querySelectorAll(selector);
for (const meta of allLinks) {
if (!isElementRenderedWithReact(meta)) {
meta.remove();
}
}
allLinks = document.querySelectorAll(selector);
if (allLinks.length === 0) {
setLocalProps(props);
}
}, [props]);
if (isServer) {
return <link rel="canonical" {...props} />;
}
if (localProps === undefined) {
// This method also works during hydration because React retains server-rendered tags
// as long as they are not re-rendered by the client.
return;
}
return <link rel="canonical" {...localProps} />;
};

View File

@ -3,6 +3,7 @@ export * from "./hook";
export * from "./variable-state";
export { PageSettingsMeta } from "./page-settings-meta";
export { PageSettingsTitle } from "./page-settings-title";
export { PageSettingsCanonicalLink } from "./page-settings-canonical-link";
/**
* React has issues rendering certain elements, such as errors when a <link> element has children.