diff --git a/gatsby-node.js b/gatsby-node.js
index 255557c589db2b..8e173faf84c789 100644
--- a/gatsby-node.js
+++ b/gatsby-node.js
@@ -12,6 +12,16 @@ const { paginate } = require("gatsby-awesome-pagination");
const { createFilePath } = require("gatsby-source-filesystem");
const RelatedPostsFactory = require("./src/components/Related-Posts/relatedPostsFactory");
const config = require("./gatsby-config");
+const { toEditUrl } = require("./src/utils/edit-link.js");
+
+exports.onCreatePage = ({ page }) => {
+ const editUrl = toEditUrl(page.component, __dirname);
+ if (editUrl) {
+ page.context = { ...page.context, editUrl };
+ }
+ return page;
+};
+
const {
getExcludedCollections,
isFullSiteBuild,
@@ -61,7 +71,11 @@ exports.createPages = async ({ actions, graphql, reporter }) => {
`Duplicate page path "${page.path}" from ${previous} and ${page.component}. Give one of them a unique title or permalink.`,
);
}
- return actions.createPage(page);
+
+ const editUrl = toEditUrl(page.component, __dirname);
+ return actions.createPage(
+ editUrl ? { ...page, context: { ...page.context, editUrl } } : page,
+ );
};
const envCreatePage = (props) => {
diff --git a/package.json b/package.json
index 3989cdfcedee5d..caf0d09da54bf8 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"lint": "eslint --fix .",
"checklint": "eslint .",
"check:contributing-versions": "node .github/scripts/check-contributing-versions.cjs",
- "test:unit": "node --test src/utils/build-collections.test.js src/utils/page-path-guard.test.js src/utils/resources-url.test.js",
+ "test:unit": "node --test src/utils/build-collections.test.js src/utils/page-path-guard.test.js src/utils/edit-link.test.js src/utils/resources-url.test.js",
"pretest": "eslint --ignore-path .gitignore .",
"deploy": "gatsby build && gh-pages -d public -b master",
"noIndex": "gatsby build && echo 'User-agent: *\nDisallow: /' > public/robots.txt",
diff --git a/page-wrapper.js b/page-wrapper.js
index 34dc1c2d21f9da..869ab799ebf632 100644
--- a/page-wrapper.js
+++ b/page-wrapper.js
@@ -1,8 +1,8 @@
import React from "react";
import Layout from "./src/components/layout.js";
-export const wrapPageElement = ({ element }) => (
-
+export const wrapPageElement = ({ element, props }) => (
+
{element}
-);
\ No newline at end of file
+);
diff --git a/src/components/layout.js b/src/components/layout.js
index b7aa355292edcc..3702d474670af9 100644
--- a/src/components/layout.js
+++ b/src/components/layout.js
@@ -16,7 +16,7 @@ import Footer from "../sections/General/Footer";
import { GlobalStyle } from "../sections/app.style";
import CookieConsent from "./CookieConsent";
-const Layout = ({ children }) => {
+const Layout = ({ children, pageContext }) => {
return (
<>
@@ -26,13 +26,14 @@ const Layout = ({ children }) => {
{children}
-
+
>
);
};
Layout.propTypes = {
- children: PropTypes.node.isRequired
+ children: PropTypes.node.isRequired,
+ pageContext: PropTypes.object,
};
export default Layout;
diff --git a/src/sections/General/Footer/index.js b/src/sections/General/Footer/index.js
index 5216b3017ef8d7..a50907297cf05b 100644
--- a/src/sections/General/Footer/index.js
+++ b/src/sections/General/Footer/index.js
@@ -7,83 +7,9 @@ import Button from "../../../reusecore/Button";
import FooterWrapper from "./footer.style";
import bubblesElement from "./images/bubbles-element.svg";
-const Footer = ({ location }) => {
+const Footer = ({ editUrl }) => {
var currentYear = new Date().getFullYear();
- const getUrl = (pathname) => {
- // remove ".html" that results in live production build
- if (pathname.endsWith(".html")) {
- pathname = pathname.replace(".html", "");
- }
-
- const indexUrl = [
- "/",
- "/blog",
- "/careers",
- "/meshery",
- "/kanvas",
- "/community",
- "/handbook",
- "projects",
- "/resources",
- "/learn",
- ];
-
- const test = {
- mdx: function (path) {
- let returnPath = "";
- return (
- [
- "/blog/",
- "/careers/",
- "/community/events",
- "/integrations/",
- "/landscape/",
- "/members/",
- "/news/",
- "/programs",
- "/projects/",
- "/resources/",
- "/workshops/",
- ].some((check) => {
- returnPath = check;
- return path.startsWith(check);
- }) && `src/collections${returnPath}`
- );
- },
-
- learningPath: function (path) {
- return (
- path.startsWith("/learn/learning-paths/") &&
- `content-learn${pathname.replace("learn/learning-paths/", "")}`
- );
- },
-
- communityMember: function (path) {
- if (path.startsWith("/community/members/")) {
- const memberId = path.replace("/community/members/", "");
- return `src/collections/members/${memberId}/index.mdx`;
- }
- return null;
- },
- };
-
- if (pathname.startsWith("/community/handbook/")) {
- const page = pathname.replace("/community/handbook/", "");
- return `https://github.com/layer5io/layer5/blob/master/src/pages/community/handbook/${page}.js`;
- }
-
- return test.mdx(pathname)
- ? `https://github.com/layer5io/layer5/tree/master/${test.mdx(pathname)}`
- : test.learningPath(pathname)
- ? `https://github.com/layer5io/layer5/tree/master/${test.learningPath(pathname)}`
- : test.communityMember(pathname)
- ? `https://github.com/layer5io/layer5/blob/master/${test.communityMember(pathname)}`
- : `https://github.com/layer5io/layer5/blob/master/src/pages${
- pathname == "/" ? "" : pathname
- }${indexUrl.some((str) => pathname.endsWith(str)) ? "/index" : ""}.js`;
- };
-
return (
{
- -
-
- Edit This Page
-
-
+ {editUrl && (
+ -
+
+ Edit This Page
+
+
+ )}
-
{
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ return raw;
+ }
+};
+
+const sourceFromComponent = (component, root) => {
+ if (typeof component !== "string") return "";
+ const q = component.indexOf("?");
+ if (q === -1) return component;
+
+ const query = component.slice(q + 1);
+ const start = query.indexOf(CONTENT_PARAM);
+ if (start === -1) return component.slice(0, q);
+
+ const raw = query.slice(start + CONTENT_PARAM.length);
+ const exportAt = raw.indexOf("&export=");
+ const contentPath = exportAt === -1 ? raw : raw.slice(0, exportAt);
+
+ const decoded = decodePath(contentPath);
+ if (decoded !== contentPath && root) {
+ const resolve = (candidate) =>
+ path.isAbsolute(candidate) ? candidate : path.resolve(root, candidate);
+ if (fs.existsSync(resolve(contentPath))) return contentPath;
+ if (fs.existsSync(resolve(decoded))) return decoded;
+ }
+
+ return decoded || component.slice(0, q);
+};
+
+const toEditUrl = (component, root) => {
+ const src = sourceFromComponent(component || "", root);
+ if (!src) return null;
+
+ const abs = path.isAbsolute(src) ? src : path.resolve(root, src);
+ const hasContentPath =
+ typeof component === "string" && component.includes(CONTENT_PARAM);
+ const templatesRoot = path.resolve(root, "src", "templates");
+ if (
+ !hasContentPath &&
+ (abs === templatesRoot || abs.startsWith(`${templatesRoot}${path.sep}`))
+ ) {
+ return null;
+ }
+
+ const rel = path.relative(root, abs);
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`)) return null;
+ if (path.isAbsolute(rel)) return null;
+ if (rel.split(path.sep).some((seg) => IGNORED.has(seg))) return null;
+ if (!fs.existsSync(abs)) return null;
+
+ const encoded = rel.split(path.sep).map(encodeURIComponent).join("/");
+ return `https://github.com/${REPO}/blob/master/${encoded}`;
+};
+
+module.exports = { sourceFromComponent, toEditUrl };
diff --git a/src/utils/edit-link.test.js b/src/utils/edit-link.test.js
new file mode 100644
index 00000000000000..1a64bd1f35cc3e
--- /dev/null
+++ b/src/utils/edit-link.test.js
@@ -0,0 +1,203 @@
+/* eslint-env node */
+
+const { describe, it } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+
+const { sourceFromComponent, toEditUrl } = require("./edit-link");
+
+const ROOT = path.resolve(__dirname, "..", "..");
+const REPO_URL = "https://github.com/layer5io/layer5";
+const src = (...parts) => path.join(ROOT, ...parts);
+
+describe("sourceFromComponent", () => {
+ it("returns a plain component path unchanged", () => {
+ const component = src("src", "pages", "404.js");
+ assert.equal(sourceFromComponent(component), component);
+ });
+
+ it("prefers __contentFilePath over the template path", () => {
+ const mdx = src(
+ "src",
+ "collections",
+ "members",
+ "Ibrahim Mbaziira",
+ "index.mdx",
+ );
+ const component = `${src("src", "templates", "member-single.js")}?__contentFilePath=${mdx}`;
+
+ assert.equal(sourceFromComponent(component), mdx);
+ });
+
+ it("decodes an encoded content path", () => {
+ const mdx = src(
+ "src",
+ "collections",
+ "members",
+ "Ibrahim Mbaziira",
+ "index.mdx",
+ );
+ const component = `${src("src", "templates", "member-single.js")}?__contentFilePath=${encodeURIComponent(mdx)}`;
+
+ assert.equal(sourceFromComponent(component, ROOT), mdx);
+ });
+
+ it("prefers the raw content path when a file with percent sequences exists on disk", () => {
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "edit-link-"));
+ try {
+ const literalFile = path.join(tmpRoot, "chapter%20one.mdx");
+ fs.writeFileSync(literalFile, "# Chapter 1\n");
+ const component = `${path.join(tmpRoot, "template.js")}?__contentFilePath=chapter%20one.mdx`;
+
+ assert.equal(
+ sourceFromComponent(component, tmpRoot),
+ "chapter%20one.mdx",
+ );
+ assert.equal(
+ toEditUrl(component, tmpRoot),
+ `${REPO_URL}/blob/master/chapter%2520one.mdx`,
+ );
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to the decoded path when only it exists on disk", () => {
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "edit-link-"));
+ try {
+ const decodedFile = path.join(tmpRoot, "chapter one.mdx");
+ fs.writeFileSync(decodedFile, "# Chapter 1\n");
+ const component = `${path.join(tmpRoot, "template.js")}?__contentFilePath=chapter%20one.mdx`;
+
+ assert.equal(sourceFromComponent(component, tmpRoot), "chapter one.mdx");
+ assert.equal(
+ toEditUrl(component, tmpRoot),
+ `${REPO_URL}/blob/master/chapter%20one.mdx`,
+ );
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("preserves literal plus signs in file paths", () => {
+ const mdx = src(
+ "src",
+ "collections",
+ "news",
+ "2020",
+ "2020-12-02-kubecon+cloudnativecon-service-mesh-battle-stories-and-fixes",
+ "index.mdx",
+ );
+ const component = `${src("src", "templates", "news-single.js")}?__contentFilePath=${mdx}`;
+
+ assert.equal(sourceFromComponent(component), mdx);
+ });
+
+ it("ignores unrelated query params", () => {
+ const mdx = src("src", "collections", "blog", "2026", "post", "index.mdx");
+ const component = `${src("src", "templates", "blog-single.js")}?__contentFilePath=${mdx}&export=default`;
+
+ assert.equal(sourceFromComponent(component), mdx);
+ });
+
+ it("falls back to the component path when no content path is present", () => {
+ const component = `${src("src", "pages", "404.js")}?export=default`;
+
+ assert.equal(sourceFromComponent(component), src("src", "pages", "404.js"));
+ });
+
+ it("returns an empty string for an empty component", () => {
+ assert.equal(sourceFromComponent(""), "");
+ });
+});
+
+describe("toEditUrl", () => {
+ it("links a plain page file", () => {
+ assert.equal(
+ toEditUrl(src("src", "pages", "pricing", "index.js"), ROOT),
+ `${REPO_URL}/blob/master/src/pages/pricing/index.js`,
+ );
+ });
+
+ it("rejects a queryless template component", () => {
+ assert.equal(
+ toEditUrl(src("src", "templates", "member-single.js"), ROOT),
+ null,
+ );
+ assert.equal(
+ toEditUrl(src("src", "templates", "lite-placeholder.js"), ROOT),
+ null,
+ );
+ });
+
+ it("keeps a template component that carries a content path", () => {
+ const mdx = src(
+ "src",
+ "collections",
+ "members",
+ "Ibrahim Mbaziira",
+ "index.mdx",
+ );
+ const component = `${src("src", "templates", "member-single.js")}?__contentFilePath=${mdx}`;
+
+ assert.equal(
+ toEditUrl(component, ROOT),
+ `${REPO_URL}/blob/master/src/collections/members/Ibrahim%20Mbaziira/index.mdx`,
+ );
+ });
+
+ it("links the MDX file for collection pages and encodes spaces", () => {
+ const mdx = src(
+ "src",
+ "collections",
+ "members",
+ "Ibrahim Mbaziira",
+ "index.mdx",
+ );
+ const component = `${src("src", "templates", "member-single.js")}?__contentFilePath=${mdx}`;
+
+ assert.equal(
+ toEditUrl(component, ROOT),
+ `${REPO_URL}/blob/master/src/collections/members/Ibrahim%20Mbaziira/index.mdx`,
+ );
+ });
+
+ it("encodes a plus sign in a real news path", () => {
+ const mdx = src(
+ "src",
+ "collections",
+ "news",
+ "2020",
+ "2020-12-02-kubecon+cloudnativecon-service-mesh-battle-stories-and-fixes",
+ "index.mdx",
+ );
+ const component = `${src("src", "templates", "news-single.js")}?__contentFilePath=${mdx}`;
+
+ assert.equal(
+ toEditUrl(component, ROOT),
+ `${REPO_URL}/blob/master/src/collections/news/2020/2020-12-02-kubecon%2Bcloudnativecon-service-mesh-battle-stories-and-fixes/index.mdx`,
+ );
+ });
+
+ it("returns null for paths outside the repo", () => {
+ assert.equal(toEditUrl(path.resolve(ROOT, "..", "outside.js"), ROOT), null);
+ });
+
+ it("returns null for ignored directories", () => {
+ assert.equal(toEditUrl(src(".cache", "dev-404-page.js"), ROOT), null);
+ assert.equal(toEditUrl(src("public", "index.html"), ROOT), null);
+ });
+
+ it("returns null when the source file does not exist", () => {
+ assert.equal(
+ toEditUrl(src("src", "pages", "does-not-exist.js"), ROOT),
+ null,
+ );
+ });
+
+ it("returns null for an empty component", () => {
+ assert.equal(toEditUrl("", ROOT), null);
+ });
+});