diff --git a/apps/website/src/styles.scss b/apps/website/src/styles.scss
index 66f48a4..4e98a25 100644
--- a/apps/website/src/styles.scss
+++ b/apps/website/src/styles.scss
@@ -16,3 +16,33 @@
.dark .base {
background-image: url('/assets/images/looper-dark.svg');
}
+
+* {
+ scrollbar-color: #d7d7d7 #fafafa;
+
+ .dark & {
+ scrollbar-color: #333 #080e29;
+ }
+}
+
+/* Chrome, Edge, and Safari */
+
+*::-webkit-scrollbar-track {
+ background: white;
+ border-radius: 5px;
+
+ .dark & {
+ background: #011627;
+ }
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: white;
+ border-radius: 14px;
+ border: 3px solid white;
+
+ .dark & {
+ background-color: #011627;
+ border: 3px solid #011627;
+ }
+}
diff --git a/libs/blog/feature-article/src/lib/blog-article.component.html b/libs/blog/feature-article/src/lib/blog-article.component.html
index a7cdbfe..6528c24 100644
--- a/libs/blog/feature-article/src/lib/blog-article.component.html
+++ b/libs/blog/feature-article/src/lib/blog-article.component.html
@@ -145,17 +145,7 @@
>
- @if (node.value.split('\n').length > 1) {
-
- } @else {
-
-
-
- }
+
diff --git a/libs/blog/feature-article/src/lib/blog-article.component.ts b/libs/blog/feature-article/src/lib/blog-article.component.ts
index bfcb74c..150de5b 100644
--- a/libs/blog/feature-article/src/lib/blog-article.component.ts
+++ b/libs/blog/feature-article/src/lib/blog-article.component.ts
@@ -1,13 +1,9 @@
-import { Component, Inject, OnDestroy, OnInit, Renderer2 } from '@angular/core';
-import { CommonModule, DOCUMENT } from '@angular/common';
+import { Component } from '@angular/core';
+import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types';
-import { highlight, languages } from 'prismjs';
-import 'prismjs/components/prism-python';
-import 'prismjs/components/prism-rust';
-
import {
CfRichTextChildrenDirective,
CfRichTextMarkDirective,
@@ -19,7 +15,11 @@ import { bootstrapArrowLeft } from '@ng-icons/bootstrap-icons';
import { Article } from '@valerymelou/blog/data-access';
import { MetadataService } from '@valerymelou/shared/seo';
-import { ButtonComponent, LinkComponent } from '@valerymelou/shared/ui';
+import {
+ ButtonComponent,
+ CodeComponent,
+ LinkComponent,
+} from '@valerymelou/shared/ui';
@Component({
selector: 'blog-article',
@@ -34,11 +34,12 @@ import { ButtonComponent, LinkComponent } from '@valerymelou/shared/ui';
NgIconComponent,
ButtonComponent,
LinkComponent,
+ CodeComponent,
],
templateUrl: './blog-article.component.html',
viewProviders: [provideIcons({ bootstrapArrowLeft })],
})
-export class BlogArticleComponent implements OnInit, OnDestroy {
+export class BlogArticleComponent {
article!: Article;
loaded = false;
ready = false;
@@ -47,13 +48,9 @@ export class BlogArticleComponent implements OnInit, OnDestroy {
readonly MARKS = MARKS;
readonly INLINES = INLINES;
- private codeHighlightCssLink!: HTMLLinkElement;
-
constructor(
- private renderer: Renderer2,
private route: ActivatedRoute,
private metadataService: MetadataService,
- @Inject(DOCUMENT) private document: Document,
) {
this.route.data.subscribe({
next: (data) => {
@@ -67,38 +64,4 @@ export class BlogArticleComponent implements OnInit, OnDestroy {
},
});
}
-
- ngOnInit(): void {
- this.loadCodeHighlightLib();
- }
-
- highlightCode(code: string): string {
- let language = code.split('\n')[0].replace('```', '');
-
- if (languages[language]) {
- code = code.replace('```' + language + '\n', '');
- } else {
- language = 'javascript';
- }
-
- return highlight(code, languages[language], language);
- }
-
- private loadCodeHighlightLib(): void {
- this.codeHighlightCssLink = this.renderer.createElement('link');
- this.codeHighlightCssLink.rel = 'stylesheet';
- this.codeHighlightCssLink.href =
- 'https://cdnjs.cloudflare.com/ajax/libs/prism/9000.0.1/themes/prism-okaidia.min.css';
- this.codeHighlightCssLink.integrity =
- 'sha512-5HvW0a7ihK3ro2KhwEksDHXgIezsTeZybZDIn8d8Y015Ny+t7QWSIjnlCTjFzlK7Klb604HLGjsNqU/i5mJLjQ==';
- this.codeHighlightCssLink.crossOrigin = 'anonymous';
- this.codeHighlightCssLink.referrerPolicy = 'no-referrer';
- this.renderer.appendChild(this.document.head, this.codeHighlightCssLink);
- }
-
- ngOnDestroy(): void {
- if (this.codeHighlightCssLink) {
- this.renderer.removeChild(this.document.head, this.codeHighlightCssLink);
- }
- }
}
diff --git a/libs/shared/ui/src/index.ts b/libs/shared/ui/src/index.ts
index 949c1ba..12dbfe4 100644
--- a/libs/shared/ui/src/index.ts
+++ b/libs/shared/ui/src/index.ts
@@ -5,3 +5,5 @@ export * from './lib/menu/menu.component';
export * from './lib/menu/menu-trigger-for.directive';
export * from './lib/link/link.component';
+
+export * from './lib/code/code.component';
diff --git a/libs/shared/ui/src/lib/code/code.component.html b/libs/shared/ui/src/lib/code/code.component.html
new file mode 100644
index 0000000..aaf58a9
--- /dev/null
+++ b/libs/shared/ui/src/lib/code/code.component.html
@@ -0,0 +1,29 @@
+@if (code) {
+ @if (code.split(' ').length === 1) {
+
+ } @else {
+
+
{{ language }}
+
+
+
+ }
+}
diff --git a/libs/shared/ui/src/lib/code/code.component.scss b/libs/shared/ui/src/lib/code/code.component.scss
new file mode 100644
index 0000000..90b5559
--- /dev/null
+++ b/libs/shared/ui/src/lib/code/code.component.scss
@@ -0,0 +1,25 @@
+:host {
+ display: inline-block;
+ width: 100%;
+}
+
+pre {
+ padding: 24px;
+ border-radius: 4px;
+}
+
+.inline-flex {
+ pre {
+ padding: 0 6px;
+ }
+}
+
+html.dark .shiki,
+html.dark .shiki span {
+ color: var(--shiki-dark) !important;
+ background-color: var(--shiki-dark-bg) !important;
+ /* Optional, if you also want font styles */
+ font-style: var(--shiki-dark-font-style) !important;
+ font-weight: var(--shiki-dark-font-weight) !important;
+ text-decoration: var(--shiki-dark-text-decoration) !important;
+}
diff --git a/libs/shared/ui/src/lib/code/code.component.spec.ts b/libs/shared/ui/src/lib/code/code.component.spec.ts
new file mode 100644
index 0000000..50c9168
--- /dev/null
+++ b/libs/shared/ui/src/lib/code/code.component.spec.ts
@@ -0,0 +1,44 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CodeComponent } from './code.component';
+import { By } from '@angular/platform-browser';
+import { WINDOW_TOKEN } from '@valerymelou/common/browser';
+
+describe('CodeComponent', () => {
+ let component: CodeComponent;
+ let fixture: ComponentFixture;
+ const code = '```html\nHello World
This is a test
\n';
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CodeComponent],
+ providers: [
+ {
+ provide: WINDOW_TOKEN,
+ useValue: {
+ navigator: { clipboard: { writeText: () => Promise.resolve() } },
+ },
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CodeComponent);
+ component = fixture.componentInstance;
+ component.code = code;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should copy the code to the clipboard', () => {
+ const window = TestBed.inject(WINDOW_TOKEN);
+ const writeTextSpy = jest.spyOn(window.navigator.clipboard, 'writeText');
+ const button = fixture.debugElement.query(By.css('button'));
+
+ button.triggerEventHandler('click');
+ expect(writeTextSpy).toHaveBeenCalledWith(
+ code.replace('```html\n', '').replace('\n```', ''),
+ );
+ });
+});
diff --git a/libs/shared/ui/src/lib/code/code.component.ts b/libs/shared/ui/src/lib/code/code.component.ts
new file mode 100644
index 0000000..8903aae
--- /dev/null
+++ b/libs/shared/ui/src/lib/code/code.component.ts
@@ -0,0 +1,79 @@
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { Component, Inject, Input, ViewEncapsulation } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+import { NgIconComponent, provideIcons } from '@ng-icons/core';
+import { bootstrapCopy, bootstrapCheck } from '@ng-icons/bootstrap-icons';
+import { bundledLanguages } from 'shiki/langs';
+import { codeToHtml } from 'shiki';
+import { WINDOW_TOKEN } from '@valerymelou/common/browser';
+
+@Component({
+ selector: 'ui-code',
+ standalone: true,
+ imports: [CommonModule, NgIconComponent],
+ templateUrl: './code.component.html',
+ styleUrls: ['./code.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ viewProviders: [provideIcons({ bootstrapCopy, bootstrapCheck })],
+})
+export class CodeComponent {
+ @Input()
+ set code(value: string) {
+ this._code = value;
+ this.highlight();
+ }
+
+ get code(): string {
+ return this._code;
+ }
+
+ @Input() language!: string;
+ highlightedCode!: SafeHtml;
+
+ private _code!: string;
+ copied = false;
+
+ constructor(
+ private sanitizer: DomSanitizer,
+ @Inject(WINDOW_TOKEN) private window: Window,
+ ) {}
+
+ copy(): void {
+ this.window.navigator.clipboard.writeText(this.code).then(() => {
+ this.copied = true;
+ setTimeout(() => {
+ this.copied = false;
+ }, 3000);
+ });
+ }
+
+ highlight(): void {
+ this.language =
+ this.language ?? this.code.split('\n')[0].replace('```', '');
+
+ if (this.language in bundledLanguages) {
+ this._code = this.code.replace('```' + this.language + '\n', '');
+ } else {
+ this.language = 'javascript';
+ }
+
+ codeToHtml(this.code, {
+ lang: this.language,
+ themes: {
+ light: 'catppuccin-latte',
+ dark: 'material-theme-ocean',
+ },
+ transformers: [
+ {
+ pre(node) {
+ this.addClassToHast(node, 'overflow-x-auto');
+ },
+ },
+ ],
+ }).then((highlightedCode) => {
+ this.highlightedCode =
+ this.sanitizer.bypassSecurityTrustHtml(highlightedCode);
+ });
+ }
+}
diff --git a/package.json b/package.json
index d7a93d2..6338ba5 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,6 @@
"@ng-icons/material-icons": "^29.0.0",
"contentful": "^10.12.13",
"express": "~4.19.2",
- "prismjs": "^1.29.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
@@ -54,7 +53,6 @@
"@types/express": "4.17.21",
"@types/jest": "^29.4.0",
"@types/node": "18.16.9",
- "@types/prismjs": "^1.26.4",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@typescript-eslint/utils": "^7.16.0",
@@ -73,6 +71,7 @@
"postcss-url": "~10.1.3",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.6.5",
+ "shiki": "^1.11.1",
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.0",
"ts-node": "10.9.2",
diff --git a/yarn.lock b/yarn.lock
index 9e7f61e..0306a4e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3263,6 +3263,13 @@
"@angular-devkit/schematics" "18.1.1"
jsonc-parser "3.3.1"
+"@shikijs/core@1.11.1":
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.11.1.tgz#a102cf56f32fa8cf3ceb9f918f2da5511782efe7"
+ integrity sha512-Qsn8h15SWgv5TDRoDmiHNzdQO2BxDe86Yq6vIHf5T0cCvmfmccJKIzHtep8bQO9HMBZYCtCBzaXdd1MnxZBPSg==
+ dependencies:
+ "@types/hast" "^3.0.4"
+
"@sigstore/bundle@^2.3.2":
version "2.3.2"
resolved "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.2.tgz"
@@ -3625,6 +3632,13 @@
dependencies:
"@types/node" "*"
+"@types/hast@^3.0.4":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
+ dependencies:
+ "@types/unist" "*"
+
"@types/http-errors@*":
version "2.0.4"
resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz"
@@ -3714,11 +3728,6 @@
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz"
integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==
-"@types/prismjs@^1.26.4":
- version "1.26.4"
- resolved "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.4.tgz"
- integrity sha512-rlAnzkW2sZOjbqZ743IHUhFcvzaGbqijwOu8QZnZCjfQzBqFE3s4lOTJEsxikImav9uzz/42I+O7YUs1mWgMlg==
-
"@types/qs@*":
version "6.9.15"
resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz"
@@ -3785,6 +3794,11 @@
resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz"
integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==
+"@types/unist@*":
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.2.tgz#6dd61e43ef60b34086287f83683a5c1b2dc53d20"
+ integrity sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==
+
"@types/wrap-ansi@^3.0.0":
version "3.0.0"
resolved "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz"
@@ -9797,11 +9811,6 @@ pretty-format@^29.0.0, pretty-format@^29.7.0:
ansi-styles "^5.0.0"
react-is "^18.0.0"
-prismjs@^1.29.0:
- version "1.29.0"
- resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz"
- integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==
-
proc-log@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz"
@@ -10494,6 +10503,14 @@ shell-quote@^1.8.1:
resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz"
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
+shiki@^1.11.1:
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/shiki/-/shiki-1.11.1.tgz#6c06c5fcf55f1dac2db2596af935fef6a41a209d"
+ integrity sha512-VHD3Q0EBXaaa245jqayBe5zQyMQUdXBFjmGr9MpDaDpAKRMYn7Ff00DM5MLk26UyKjnml3yQ0O2HNX7PtYVNFQ==
+ dependencies:
+ "@shikijs/core" "1.11.1"
+ "@types/hast" "^3.0.4"
+
side-channel@^1.0.4, side-channel@^1.0.6:
version "1.0.6"
resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz"