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\n

Hello 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"