diff --git a/.changeset/techdocs-eight-camels-hear.md b/.changeset/techdocs-eight-camels-hear.md
new file mode 100644
index 0000000000..0d6c16f725
--- /dev/null
+++ b/.changeset/techdocs-eight-camels-hear.md
@@ -0,0 +1,8 @@
+---
+'@backstage/plugin-techdocs': minor
+---
+
+Add feedback link icon in Techdocs Reader that directs to Gitlab or Github repo issue page with prefilled title and source link.
+For link to appear, requires repo_url and edit_uri to be filled in mkdocs.yml, as per https://www.mkdocs.org/user-guide/configuration. edit_uri will need to be specified for self-hosted Gitlab/Github with different hostnames.
+Gitlab or Github detection checks for 'gitlab' or 'github' in hostname of source in repo_url;
+additionally 'techdocs.feedback.gitlab' and '.github' in app config can be used to specify hostnames that should be recognised as the respective Git hosting for the feedback link.
diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx
index feed758caa..ff4f47dca0 100644
--- a/plugins/techdocs/src/reader/components/Reader.tsx
+++ b/plugins/techdocs/src/reader/components/Reader.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
-import { useApi } from '@backstage/core';
+import { useApi, configApiRef } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import { useTheme } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
@@ -31,6 +31,7 @@ import transformer, {
rewriteDocLinks,
sanitizeDOM,
simplifyMkdocsFooter,
+ addGitFeedbackLink,
} from '../transformers';
import { TechDocsNotFound } from './TechDocsNotFound';
import TechDocsProgressBar from './TechDocsProgressBar';
@@ -52,6 +53,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
const [loadedPath, setLoadedPath] = useState('');
const [atInitialLoad, setAtInitialLoad] = useState(true);
const [newerDocsExist, setNewerDocsExist] = useState(false);
+ const configApi = useApi(configApiRef);
const {
value: isSynced,
@@ -142,6 +144,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
rewriteDocLinks(),
removeMkdocsHeader(),
simplifyMkdocsFooter(),
+ addGitFeedbackLink(configApi),
injectCss({
css: `
body {
@@ -313,6 +316,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
name,
newerDocsExist,
isSynced,
+ configApi,
]);
// docLoadError not considered an error state if sync request is still ongoing
diff --git a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts
new file mode 100644
index 0000000000..cc451c3a25
--- /dev/null
+++ b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createTestShadowDom } from '../../test-utils';
+import { addGitFeedbackLink } from './addGitFeedbackLink';
+
+const configApi = {
+ getOptionalString: function getOptionalString(key: string) {
+ return key === 'gitlab' ? 'gitlab.com' : 'github.com';
+ },
+};
+
+describe('addGitFeedbackLink', () => {
+ it('adds a feedback link when a Gitlab source edit link is available', () => {
+ const shadowDom = createTestShadowDom(
+ `
+
+
+
+ HeaderText
+ >
+
+
+ `,
+ {
+ preTransformers: [addGitFeedbackLink(configApi)],
+ postTransformers: [],
+ },
+ );
+
+ expect(shadowDom.querySelector('#git-feedback-link')).toBeTruthy();
+ expect(
+ (shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
+ ).toEqual(
+ 'https://gitlab.com/reponame/username/issues/new?issue[title]=Documentation%20Feedback%3A%20HeaderText&issue[description]=Page%20source%3A%0Ahttps%3A%2F%2Fgitlab.com%2Freponame%2Fusername%2Fdocs%2FTestDoc.md%0A%0AFeedback%3A',
+ );
+ });
+
+ it('adds a feedback link when a Github source edit link is available', () => {
+ const shadowDom = createTestShadowDom(
+ `
+
+
+
+ HeaderText
+ >
+
+
+ `,
+ {
+ preTransformers: [addGitFeedbackLink(configApi)],
+ postTransformers: [],
+ },
+ );
+
+ expect(shadowDom.querySelector('#git-feedback-link')).toBeTruthy();
+ expect(
+ (shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
+ ).toEqual(
+ 'https://github.com/reponame/username/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fgithub.com%2Freponame%2Fusername%2Fdocs%2FTestDoc.md%0A%0AFeedback%3A',
+ );
+ });
+
+ it('does not add a feedback link when no source edit link is available', () => {
+ const shadowDom = createTestShadowDom(
+ `
+
+
+
+ Conveyor Pipelines
+
+
+ `,
+ {
+ preTransformers: [addGitFeedbackLink(configApi)],
+ postTransformers: [],
+ },
+ );
+
+ expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
+ });
+
+ it('does not add a feedback link when a Gitlab or Github source edit link is not available', () => {
+ const shadowDom = createTestShadowDom(
+ `
+
+
+
+ Conveyor Pipelines
+
+
+
+ `,
+ {
+ preTransformers: [addGitFeedbackLink(configApi)],
+ postTransformers: [],
+ },
+ );
+
+ expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
+ });
+});
diff --git a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts
new file mode 100644
index 0000000000..d19d90228c
--- /dev/null
+++ b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Transformer } from './index';
+import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+// requires repo
+export const addGitFeedbackLink = (configApi: any): Transformer => {
+ return dom => {
+ // attempting to use selectors that are more likely to be static as MkDocs updates over time
+ const sourceAnchor = dom.querySelector(
+ '[title="Edit this page"]',
+ ) as HTMLAnchorElement;
+
+ // don't show if edit link not available in raw page
+ if (!sourceAnchor || !sourceAnchor.href) {
+ return dom;
+ }
+ let gitHost = '';
+
+ const sourceURL = new URL(sourceAnchor.href);
+ const githubHosts = configApi
+ .getConfigArray('integrations.github')
+ .map((integration: any) => integration.data.host);
+ const gitlabHosts = configApi
+ .getConfigArray('integrations.gitlab')
+ .map((integration: any) => integration.data.host);
+
+ // don't show if can't identify edit link hostname as a gitlab/github hosting
+ if (
+ githubHosts.includes(sourceURL.hostname) ||
+ sourceURL.origin.includes('github')
+ ) {
+ gitHost = 'github';
+ } else if (
+ gitlabHosts.includes(sourceURL.hostname) ||
+ sourceURL.origin.includes('gitlab')
+ ) {
+ gitHost = 'gitlab';
+ } else {
+ return dom;
+ }
+
+ // topmost h1 only contains title for whole page
+ const title = (dom.querySelector('article>h1') as HTMLElement).childNodes[0]
+ .textContent;
+ const issueTitle = encodeURIComponent(`Documentation Feedback: ${title}`);
+ const issueDesc = encodeURIComponent(
+ `Page source:\n${sourceAnchor.href}\n\nFeedback:`,
+ );
+ const repoPath = sourceURL.pathname.split('/').slice(0, 3).join('/');
+
+ const feedbackLink = sourceAnchor.cloneNode() as HTMLAnchorElement;
+ switch (gitHost) {
+ case 'gitlab':
+ feedbackLink.href = `${sourceURL.origin}${repoPath}/issues/new?issue[title]=${issueTitle}&issue[description]=${issueDesc}`;
+ break;
+ case 'github':
+ feedbackLink.href = `${sourceURL.origin}${repoPath}/issues/new?title=${issueTitle}&body=${issueDesc}`;
+ break;
+ default:
+ return dom;
+ }
+ ReactDOM.render(React.createElement(FeedbackOutlinedIcon), feedbackLink);
+ feedbackLink.style.paddingLeft = '5px';
+ feedbackLink.title = 'Leave feedback for this page';
+ feedbackLink.id = 'git-feedback-link';
+ sourceAnchor?.insertAdjacentElement('beforebegin', feedbackLink);
+ return dom;
+ };
+};
diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts
index 4da4cb3add..a005ad470b 100644
--- a/plugins/techdocs/src/reader/transformers/index.ts
+++ b/plugins/techdocs/src/reader/transformers/index.ts
@@ -15,6 +15,7 @@
*/
export * from './addBaseUrl';
+export * from './addGitFeedbackLink';
export * from './rewriteDocLinks';
export * from './addLinkClickListener';
export * from './removeMkdocsHeader';