diff --git a/ui/eslint-rules/no-root-relative-url.mjs b/ui/eslint-rules/no-root-relative-url.mjs new file mode 100644 index 000000000..15c783112 --- /dev/null +++ b/ui/eslint-rules/no-root-relative-url.mjs @@ -0,0 +1,51 @@ +const TARGET_ATTRIBUTES = new Set(["href", "src"]); + +const getStaticPrefix = (node) => { + if (!node) { + return; + } + + if (node.type === "Literal") { + return typeof node.value === "string" ? node.value : void 0; + } + + if (node.type === "TemplateLiteral") { + return node.quasis[0]?.value.raw; + } + + if (node.type === "BinaryExpression" && node.operator === "+") { + return getStaticPrefix(node.left); + } +}; + +export default { + meta: { + type: "problem", + docs: { + description: "Disallow root-relative URLs in JSX href and src attributes", + }, + messages: { + useBasePath: "Root-relative {{attribute}} bypasses the application base path. Wrap the URL with withBasePath().", + }, + schema: [], + }, + create(context) { + return { + JSXAttribute(node) { + const attribute = node.name.type === "JSXIdentifier" ? node.name.name : ""; + if (!TARGET_ATTRIBUTES.has(attribute)) { + return; + } + + const value = node.value?.type === "JSXExpressionContainer" ? getStaticPrefix(node.value.expression) : getStaticPrefix(node.value); + if (value?.startsWith("/") && !value.startsWith("//")) { + context.report({ + node: node.value ?? node, + messageId: "useBasePath", + data: { attribute }, + }); + } + }, + }; + }, +}; diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs index 0e8af6895..ca9573752 100644 --- a/ui/eslint.config.mjs +++ b/ui/eslint.config.mjs @@ -7,6 +7,14 @@ import reactHooksPlugin from "eslint-plugin-react-hooks"; import reactRefreshPlugin from "eslint-plugin-react-refresh"; import typescriptPlugin from "typescript-eslint"; +import noRootRelativeUrlRule from "./eslint-rules/no-root-relative-url.mjs"; + +const certimatePlugin = { + rules: { + "no-root-relative-url": noRootRelativeUrlRule, + }, +}; + /** * @type {import("eslint").Linter.Config[]} */ @@ -137,6 +145,17 @@ export default defineConfig( }, }, + // Certimate + { + name: "certimate", + plugins: { + certimate: certimatePlugin, + }, + rules: { + "certimate/no-root-relative-url": "error", + }, + }, + // TailwindCSS { name: "tailwindcss", diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index e52827d11..21be1ee09 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -1,4 +1,4 @@ -import { resolveAppPath } from "@/utils/url"; +import { withBasePath } from "@/utils/url"; interface BaseProvider
{
type: P;
@@ -298,7 +298,7 @@ export const accessProvidersMap: Map
+
+