Skip to content

Feature: Add copy as markdown button to docs #3290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ Thumbs.db

# compiled templates
src/app/homepage/pages/**/*.html

# generated markdown content
src/assets/content/
56 changes: 49 additions & 7 deletions src/app/homepage/homepage.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@
}
}

.content blockquote strong, .content #carbonads a, .content blockquote a {
.content blockquote strong,
.content #carbonads a,
.content blockquote a {
-webkit-text-fill-color: unset !important;
-webkit-background-clip: unset !important;
background: none;
Expand Down Expand Up @@ -258,17 +260,16 @@
}

&::before {
content: "";
content: '';
position: absolute;
inset: 0;
border-radius: 2px;
padding: 2px;
background: var(--primary-gradient);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
-webkit-mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
mask-composite: exclude;
pointer-events: none;
z-index: 2;
}
Expand Down Expand Up @@ -432,10 +433,14 @@
}
}

.github-links {
.content-actions {
float: right;
margin-top: 24px;
display: flex;
gap: 16px;
}

.github-links {
svg {
font-size: 17px;
color: var(--color-1dp);
Expand All @@ -446,6 +451,43 @@
}
}

.content-buttons {
button {
background: none;
border: none;
cursor: pointer;
padding: 0;
}

svg {
font-size: 17px;
}
}

.content-action {
color: var(--color-1dp);

&:hover {
color: var(--primary);
}
}

.content-action--success {
color: var(--success);

&:hover {
color: var(--success);
}
}

.content-action--error {
color: var(--error);

&:hover {
color: var(--error);
}
}

.toc-wrapper {
@include utils.media(large) {
display: none;
Expand All @@ -465,7 +507,7 @@
-webkit-filter: var(--company-filter);
filter: var(--company-filter);
opacity: var(--company-logo-opacity);

&:hover {
-webkit-filter: var(--company-filter-hover);
filter: var(--company-filter-hover);
Expand Down
102 changes: 101 additions & 1 deletion src/app/homepage/pages/page/page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {
ApplicationRef,
Component,
ElementRef,
OnDestroy,
inject,
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import * as Prism from 'prismjs';
import 'prismjs/prism';
import 'prismjs/components/prism-typescript';
Expand All @@ -16,8 +21,12 @@ import 'prismjs/components/prism-bash';
selector: 'app-base-page',
template: ``,
})
export class BasePageComponent implements AfterViewChecked {
export class BasePageComponent implements AfterViewChecked, OnDestroy {
private isHljsInitialized = false;
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
public copyButtonState: 'idle' | 'copied' | 'error' = 'idle';
private copyResetTimeout: number | null = null;

constructor(
private readonly applicationRef: ApplicationRef,
Expand All @@ -32,6 +41,97 @@ export class BasePageComponent implements AfterViewChecked {
return this.isHljsInitialized;
}

private isElement(target: EventTarget | null): target is Element {
return target instanceof Element;
}

async copyAsMarkdown(event: Event) {
if (!this.isElement(event.target)) {
return;
}

const buttonElement = event.target.closest('button');
if (!buttonElement) {
return;
}

try {
// Get the current route path (matches doc.id from Dgeni)
const currentPath = this.router.url.slice(1);
const markdownUrl = `/assets/content/${currentPath}.md`;

// Fetch the cleaned Markdown file
const markdown = await firstValueFrom(
this.http.get(markdownUrl, { responseType: 'text' }),
);

const canCopy = markdown && navigator.clipboard;
if (!canCopy) {
this.setCopyError(buttonElement);
return;
}

await navigator.clipboard.writeText(markdown);
this.setCopySuccess(buttonElement);
} catch (error) {
console.error('Failed to copy markdown:', error);
this.setCopyError(buttonElement);
}
}

private setCopySuccess(buttonElement: Element) {
this.copyButtonState = 'copied';
buttonElement.classList.add('content-action--success');
buttonElement.innerHTML = '<i class="fas fa-check"></i>';
buttonElement.setAttribute('aria-label', 'Copied');
buttonElement.setAttribute('title', 'Copied');
}

private setCopyError(buttonElement: Element) {
this.copyButtonState = 'error';
buttonElement.classList.add('content-action--error');
buttonElement.innerHTML = '<i class="fas fa-times"></i>';
buttonElement.setAttribute('aria-label', 'Copy failed');
buttonElement.setAttribute('title', 'Copy failed');
}

onCopyButtonMouseLeave(event: Event) {
if (this.copyButtonState === 'idle') {
return;
}

if (this.copyResetTimeout) {
clearTimeout(this.copyResetTimeout);
}

this.copyResetTimeout = setTimeout(() => {
this.copyButtonState = 'idle';

if (!this.isElement(event.target)) {
return;
}

const buttonElement = event.target.closest('button');
if (!buttonElement) {
return;
}

// Reset to copy icon
buttonElement.className = 'content-action';
buttonElement.innerHTML = '<i class="fas fa-copy"></i>';
buttonElement.setAttribute('aria-label', 'Copy as Markdown');
buttonElement.setAttribute('title', 'Copy as Markdown');
this.copyResetTimeout = null;
}, 500) as unknown as number;
}

ngOnDestroy() {
// Clean up timeout if component is destroyed
if (this.copyResetTimeout) {
clearTimeout(this.copyResetTimeout);
}
}

ngAfterViewChecked() {
this.initHljs();
}
Expand Down
18 changes: 15 additions & 3 deletions src/scss/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
--primary-accent: #ea2868;
--primary-1dp: #d71e38;
--primary-2dp: #da2640;
--primary-3dp:#db2840;
--primary-3dp: #db2840;
--primary-4dp: #e40020;
--primary-5dp: #ff0023;
--primary-gradient: linear-gradient(90deg, var(--primary) 0%, var(--primary-accent) 100%);
--primary-gradient: linear-gradient(
90deg,
var(--primary) 0%,
var(--primary-accent) 100%
);

--color: #404040;
--color-1dp: #151515;
Expand Down Expand Up @@ -38,6 +42,8 @@
--error: #ed2945;
--error-background: #f9eff1;

--success: #28a745;

--company-filter: grayscale(100%);
--company-filter-hover: grayscale(0%);
--company-logo-filter: grayscale(1);
Expand All @@ -56,7 +62,11 @@
--primary-3dp: #f1455f;
--primary-4dp: #f23c57;
--primary-5dp: #f23551;
--primary-gradient: linear-gradient(90deg, var(--primary) 0%, var(--primary-accent) 100%);
--primary-gradient: linear-gradient(
90deg,
var(--primary) 0%,
var(--primary-accent) 100%
);

--color: #dfdfe3;
--color-1dp: #d0d0d4;
Expand Down Expand Up @@ -88,6 +98,8 @@
--error: #ff677c;
--error-background: #3a2f30;

--success: #28a745;

--company-filter: contrast(0.5);
--company-filter-hover: opacity(1);
--company-logo-filter: contrast(0.5) grayscale(100%);
Expand Down
2 changes: 2 additions & 0 deletions tools/transforms/content-package/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
computeOutputPathProcessor,
computeWhoUsesProcessor,
extractContentTitleProcessor,
outputCleanMarkdownProcessor,
} from './processors';
import { ContentFileReader, contentFileReader } from './readers';
import { nestjsMarkedNunjucksFilter } from './rendering/filters/nestjs-marked';
Expand All @@ -18,6 +19,7 @@ export default new Package('content', [])
.processor(extractContentTitleProcessor)
.processor(computeOutputPathProcessor)
.processor(computeWhoUsesProcessor)
.processor(outputCleanMarkdownProcessor)
.config((readFilesProcessor: any, contentFileReader: ContentFileReader) => {
readFilesProcessor.fileReaders.push(contentFileReader);
})
Expand Down
3 changes: 2 additions & 1 deletion tools/transforms/content-package/processors/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './extractContentTitle';
export * from './computeOutputPath';
export * from './computeWhoUses';
export * from './extractContentTitle';
export * from './outputCleanMarkdown';
61 changes: 61 additions & 0 deletions tools/transforms/content-package/processors/outputCleanMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { writeFileSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { Processor } from 'dgeni';
import { Document } from '../../shared';

export class OutputCleanMarkdownProcessor implements Processor {
$runAfter = ['readFilesProcessor'];
$runBefore = ['renderDocsProcessor'];

$process(docs: Document[]) {
docs.forEach((doc) => {
if (doc.docType === 'content' && doc.content) {
// Clean the raw markdown content
const cleanedMarkdown = this.cleanMarkdown(doc.content);

// Output to src/assets/content/{id}.md so Angular can serve as assets
const outputPath = join('src/assets/content', `${doc.id}.md`);

// Ensure directory exists
mkdirSync(dirname(outputPath), { recursive: true });

// Write cleaned markdown file
writeFileSync(outputPath, cleanedMarkdown, 'utf8');
}
});
}

private cleanMarkdown(str: string): string {
return (
str
// Fix image sources: /assets/... → https://docs.nestjs.com/assets/...
.replace(/src="(\/[^"]+)"/g, 'src="https://docs.nestjs.com$1"')
// Fix documentation links: [text](/some/path) → [text](https://docs.nestjs.com/some/path)
.replace(/\]\((\/[^)]+)\)/g, '](https://docs.nestjs.com$1)')
// Remove custom Angular components for cleaner portable markdown
.replace(/<app-[^>]*><\/app-[^>]*>/g, '')
// Convert @@filename to comments with .ts extension
.replace(/@@filename\(([^)]+)\)/g, (match, filename) => {
// Add .ts extension if filename doesn't already have a proper file extension
return filename.match(
/\.(ts|js|json|html|css|scss|yaml|yml|xml|dockerfile|md)$/i,
)
? `// ${filename}`
: `// ${filename}.ts`;
})
// Remove empty filename placeholders
.replace(/@@filename\(\)/g, '')
// Split TypeScript/JavaScript versions into separate code blocks
.replace(
/@@switch\n?/g,
'```\n\nJavaScript version:\n\n```javascript\n',
)
// Remove any empty lines left by component removal
.replace(/\n\s*\n\s*\n/g, '\n\n')
);
}
}

export function outputCleanMarkdownProcessor() {
return new OutputCleanMarkdownProcessor();
}
8 changes: 3 additions & 5 deletions tools/transforms/content-package/readers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ export class ContentFileReader implements FileReader {
getDocs(fileInfo: FileInfo) {
return [
{
docType: fileInfo.baseName === 'who-uses'
? 'who-uses'
: 'content',
content: fileInfo.content
}
docType: fileInfo.baseName === 'who-uses' ? 'who-uses' : 'content',
content: fileInfo.content,
},
];
}
}
Expand Down
Loading