diff --git a/app/assets/javascripts/lib/print_markdown_dom.js b/app/assets/javascripts/lib/print_markdown_dom.js
new file mode 100644
index 0000000000000000000000000000000000000000..fb5ea09b6c8341b77703aa0f3c3953126453d3b7
--- /dev/null
+++ b/app/assets/javascripts/lib/print_markdown_dom.js
@@ -0,0 +1,50 @@
+function getPrintContent(target, ignoreSelectors) {
+ const cloneDom = target.cloneNode(true);
+ cloneDom.querySelectorAll('details').forEach((detail) => {
+ detail.setAttribute('open', '');
+ });
+
+ if (Array.isArray(ignoreSelectors) && ignoreSelectors.length > 0) {
+ cloneDom.querySelectorAll(ignoreSelectors.join(',')).forEach((ignoredNode) => {
+ ignoredNode.remove();
+ });
+ }
+
+ cloneDom.querySelectorAll('img').forEach((img) => {
+ img.setAttribute('loading', 'eager');
+ });
+
+ return cloneDom.innerHTML;
+}
+
+function getTitleContent(title) {
+ const titleElement = document.createElement('h2');
+ titleElement.className = 'gl-mt-0 gl-mb-5';
+ titleElement.innerText = title;
+ return titleElement.outerHTML;
+}
+
+export default async function printMarkdownDom({
+ target,
+ title,
+ ignoreSelectors = [],
+ stylesheet = [],
+}) {
+ const printJS = (await import('print-js')).default;
+
+ const printContent = getPrintContent(target, ignoreSelectors);
+
+ const titleElement = title ? getTitleContent(title) : '';
+
+ const markdownElement = `
${printContent}
`;
+
+ const printable = titleElement + markdownElement;
+
+ printJS({
+ printable,
+ type: 'raw-html',
+ documentTitle: title,
+ scanStyles: false,
+ css: stylesheet,
+ });
+}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4d13f25c4cb71e4a741655565e33ebf0d5086678
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue
@@ -0,0 +1,40 @@
+
+
+
+
diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js
index 9906cb595f8054477d7ad51d478f785e0cd9ba79..9bc399d07b3682b466a1a1935537a175c2b465a3 100644
--- a/app/assets/javascripts/pages/shared/wikis/show.js
+++ b/app/assets/javascripts/pages/shared/wikis/show.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Wikis from './wikis';
import WikiContent from './components/wiki_content.vue';
+import WikiExport from './components/wiki_export.vue';
const mountWikiContentApp = () => {
const el = document.querySelector('.js-async-wiki-page-content');
@@ -20,8 +21,28 @@ const mountWikiContentApp = () => {
}
};
+const mountWikiExportApp = () => {
+ const el = document.querySelector('#js-export-actions');
+
+ if (!el) return false;
+ const { target, title, stylesheet } = JSON.parse(el.dataset.options);
+
+ return new Vue({
+ el,
+ provide: {
+ target,
+ title,
+ stylesheet,
+ },
+ render(createElement) {
+ return createElement(WikiExport);
+ },
+ });
+};
+
export const mountApplications = () => {
// eslint-disable-next-line no-new
new Wikis();
mountWikiContentApp();
+ mountWikiExportApp();
};
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 84181a00f34b1f9a7572468aaeb0b77a3a38a06a..c3662c3e6ea81e3129d80543de23fe9953063f49 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -1,3 +1,9 @@
+@import 'framework/variables';
+@import 'framework/variables_overrides';
+
+@import '@gitlab/ui/src/scss/variables';
+@import '@gitlab/ui/src/scss/utility-mixins/index';
+
.md h1,
.md h2,
.md h3,
@@ -20,6 +26,35 @@
font-weight: 600;
}
+.md {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+
+ // fix blockquote style in print
+ blockquote {
+ &::before {
+ position: absolute;
+ top: 0;
+ left: -4px;
+ content: ' ';
+ height: 100%;
+ width: 4px;
+ background-color: $white-dark;
+ }
+
+ position: relative;
+ font-size: inherit;
+ @include gl-text-gray-700;
+ @include gl-py-3;
+ @include gl-pl-6;
+ @include gl-my-3;
+ @include gl-mx-0;
+ @include gl-inset-border-l-4-gray-100;
+ margin-left: 4px;
+ border: 0 !important;
+ }
+}
+
header,
nav,
nav.navbar-collapse,
@@ -40,6 +75,7 @@ ul.notes-form,
.note-action-button,
.right-sidebar,
.flash-container,
+copy-code,
#js-peek {
display: none !important;
}
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index 28699ca27f3eb58d02dbff01a74251f754c73a70..be1f43f44de01fd4a5853db58f14e41620f647a8 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -12,6 +12,8 @@
.nav-controls.pb-md-3.pb-lg-0
= render 'shared/wikis/main_links'
+ - if Feature.enabled?(:print_wiki, current_user)
+ #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } }
- if @page.historical?
= render Pajamas::AlertComponent.new(variant: :warning,
diff --git a/config/feature_flags/development/print_wiki.yml b/config/feature_flags/development/print_wiki.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e04d7dd84bf7299f1e4240d5d44a50e9e7e620ca
--- /dev/null
+++ b/config/feature_flags/development/print_wiki.yml
@@ -0,0 +1,8 @@
+---
+name: print_wiki
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125260
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/414691
+milestone: '16.3'
+type: development
+group: group::knowledge
+default_enabled: false
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ce0ff813bee461af5ca53d8b87791823b790f358..b2cfb93d54217ac35bd41f8f7d0d21f2e94a12d7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -35419,6 +35419,9 @@ msgstr ""
msgid "Primary Action"
msgstr ""
+msgid "Print as PDF"
+msgstr ""
+
msgid "Print codes"
msgstr ""
diff --git a/package.json b/package.json
index 9c80b30bb3d3f3b1132fbf49636a200760f2bbac..ecade4806f0e72de9e9f7739fd54dbc96082af1a 100644
--- a/package.json
+++ b/package.json
@@ -180,6 +180,7 @@
"popper.js": "^1.16.1",
"portal-vue": "^2.1.7",
"postcss": "8.4.14",
+ "print-js": "^1.6.0",
"prosemirror-markdown": "1.11.1",
"raphael": "^2.2.7",
"raw-loader": "^4.0.2",
diff --git a/spec/frontend/lib/print_markdown_dom_spec.js b/spec/frontend/lib/print_markdown_dom_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..7f28417228e1d5fe9eec4d8e7b0eace7b51448ff
--- /dev/null
+++ b/spec/frontend/lib/print_markdown_dom_spec.js
@@ -0,0 +1,102 @@
+import printJS from 'print-js';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+jest.mock('print-js', () => jest.fn());
+
+describe('print util', () => {
+ describe('print markdown dom', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ``;
+ });
+
+ const getTarget = () => document.getElementById('target');
+
+ const contentValues = [
+ {
+ title: 'test title',
+ expectedTitle: 'test title
',
+ content: '',
+ expectedContent: '',
+ },
+ {
+ title: 'test title',
+ expectedTitle: 'test title
',
+ content: 'test content
',
+ expectedContent: '',
+ },
+ {
+ title: 'test title',
+ expectedTitle: 'test title
',
+ content: 'test detail
test detail content
',
+ expectedContent:
+ 'test detail
test detail content
',
+ },
+ {
+ title: undefined,
+ expectedTitle: '',
+ content: '',
+ expectedContent: '',
+ },
+ {
+ title: undefined,
+ expectedTitle: '',
+ content: 'test content
',
+ expectedContent: '',
+ },
+ {
+ title: undefined,
+ expectedTitle: '',
+ content: 'test detail
test detail content
',
+ expectedContent:
+ 'test detail
test detail content
',
+ },
+ ];
+
+ it.each(contentValues)(
+ 'should print with title ($title) and content ($content)',
+ async ({ title, expectedTitle, content, expectedContent }) => {
+ const target = getTarget();
+ target.innerHTML = content;
+ const stylesheet = 'test stylesheet';
+
+ await printMarkdownDom({
+ target,
+ title,
+ stylesheet,
+ });
+
+ expect(printJS).toHaveBeenCalledWith({
+ printable: expectedTitle + expectedContent,
+ type: 'raw-html',
+ documentTitle: title,
+ scanStyles: false,
+ css: stylesheet,
+ });
+ },
+ );
+ });
+
+ describe('ignore selectors', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ``;
+ });
+
+ it('should ignore dom if ignoreSelectors', async () => {
+ const target = document.getElementById('target');
+ const ignoreSelectors = ['.ignore-me'];
+
+ await printMarkdownDom({
+ target,
+ ignoreSelectors,
+ });
+
+ expect(printJS).toHaveBeenCalledWith({
+ printable: '',
+ type: 'raw-html',
+ documentTitle: undefined,
+ scanStyles: false,
+ css: [],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..b7002412561ba38876f3e836cb89e8a60bbc1830
--- /dev/null
+++ b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js
@@ -0,0 +1,48 @@
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import WikiExport from '~/pages/shared/wikis/components/wiki_export.vue';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+jest.mock('~/lib/print_markdown_dom');
+
+describe('pages/shared/wikis/components/wiki_export', () => {
+ let wrapper;
+
+ const createComponent = (provide) => {
+ wrapper = shallowMount(WikiExport, {
+ provide,
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findPrintItem = () =>
+ findDropdown()
+ .props('items')
+ .find((x) => x.text === 'Print as PDF');
+
+ describe('print', () => {
+ beforeEach(() => {
+ document.body.innerHTML = 'Content
';
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('should print the content', () => {
+ createComponent({
+ target: '#content-body',
+ title: 'test title',
+ stylesheet: [],
+ });
+
+ findPrintItem().action();
+
+ expect(printMarkdownDom).toHaveBeenCalledWith({
+ target: document.querySelector('#content-body'),
+ title: 'test title',
+ stylesheet: [],
+ });
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index e4d728799fefac76038859f9db945e44c46a68a2..1fa6ddce695cfd9ed093061d13882c29a46f9c92 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10487,6 +10487,11 @@ pretty@^2.0.0:
extend-shallow "^2.0.1"
js-beautify "^1.6.12"
+print-js@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/print-js/-/print-js-1.6.0.tgz#692b046cf31992b46afa6c6d8a9db1c69d431d1f"
+ integrity sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==
+
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"