├── .nvmrc ├── packages ├── pliny │ ├── .eslintrc.cjs │ ├── tsup.config.ts │ ├── tsconfig.json │ ├── src │ │ ├── utils │ │ │ ├── formatDate.ts │ │ │ ├── htmlEscaper.ts │ │ │ └── contentlayer.ts │ │ ├── ui │ │ │ ├── BlogNewsletterForm.tsx │ │ │ ├── Bleed.tsx │ │ │ ├── Pre.tsx │ │ │ ├── NewsletterForm.tsx │ │ │ └── TOCInline.tsx │ │ ├── newsletter │ │ │ ├── mailchimp.ts │ │ │ ├── buttondown.ts │ │ │ ├── convertkit.ts │ │ │ ├── emailOctopus.ts │ │ │ ├── beehiiv.ts │ │ │ ├── klaviyo.ts │ │ │ └── index.ts │ │ ├── mdx-plugins │ │ │ ├── remark-extract-frontmatter.ts │ │ │ ├── index.ts │ │ │ ├── remark-code-title.ts │ │ │ ├── remark-toc-headings.ts │ │ │ └── remark-img-to-jsx.ts │ │ ├── search │ │ │ ├── KBarButton.tsx │ │ │ ├── AlgoliaButton.tsx │ │ │ ├── index.tsx │ │ │ ├── KBar.tsx │ │ │ ├── KBarModal.tsx │ │ │ └── Algolia.tsx │ │ ├── analytics │ │ │ ├── MicrosoftClarity.tsx │ │ │ ├── SimpleAnalytics.tsx │ │ │ ├── GoogleAnalytics.tsx │ │ │ ├── Plausible.tsx │ │ │ ├── Posthog.tsx │ │ │ ├── Umami.tsx │ │ │ └── index.tsx │ │ ├── comments │ │ │ ├── Disqus.tsx │ │ │ ├── Giscus.tsx │ │ │ ├── Utterances.tsx │ │ │ └── index.tsx │ │ ├── mdx-components.tsx │ │ └── config.ts │ ├── tsup.ui.config.ts │ ├── add-use-client.mjs │ ├── package.json │ ├── CHANGELOG.md │ └── public │ │ └── algolia.css └── config │ ├── package.json │ ├── tsconfig.base.json │ └── eslint.js ├── pliny-sketch.png ├── commitlint.config.js ├── .husky ├── pre-commit └── _ │ └── husky.sh ├── .editorconfig ├── .github ├── changeset-version.cjs ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yml ├── prettier.config.js ├── .eslintignore ├── vitest-exclude-recipe.config.ts ├── .changeset ├── config.json └── README.md ├── .yarnrc.yml ├── turbo.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── .yarn └── plugins └── @yarnpkg ├── plugin-typescript.cjs └── plugin-workspace-tools.cjs /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /packages/pliny/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('@pliny/config/eslint') 2 | -------------------------------------------------------------------------------- /pliny-sketch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timlrx/pliny/HEAD/pliny-sketch.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/changeset-version.cjs: -------------------------------------------------------------------------------- 1 | const { execSync } = require('node:child_process') 2 | 3 | execSync('npx changeset version') 4 | execSync('YARN_ENABLE_IMMUTABLE_INSTALLS=false node .yarn/releases/yarn-3.6.1.cjs') 5 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | printWidth: 100, 5 | tabWidth: 2, 6 | useTabs: false, 7 | trailingComma: 'es5', 8 | bracketSpacing: true, 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .idea/ 3 | .vscode/ 4 | .git/ 5 | commitlint.config.js 6 | package.json 7 | craco.config.js 8 | node_modules/ 9 | webpack.config.js 10 | jest.config.ts 11 | 12 | dist/ 13 | coverage/ 14 | cypress/ 15 | -------------------------------------------------------------------------------- /vitest-exclude-recipe.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [...configDefaults.exclude, '**/new.test.ts', '**/install-recipe.test.ts'], 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /packages/pliny/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/**/*'], 5 | format: 'esm', 6 | clean: true, 7 | splitting: true, 8 | treeshake: true, 9 | dts: true, 10 | silent: true, 11 | }) 12 | -------------------------------------------------------------------------------- /packages/pliny/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@pliny/config/tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src/", 5 | "baseUrl": "./", 6 | "outDir": "dist", 7 | "module": "ES2020", 8 | "moduleResolution": "node" 9 | }, 10 | "exclude": ["dist", "node_modules", "public"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/pliny/src/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | export const formatDate = (date: string, locale = 'en-US') => { 2 | const options: Intl.DateTimeFormatOptions = { 3 | year: 'numeric', 4 | month: 'long', 5 | day: 'numeric', 6 | } 7 | const now = new Date(date).toLocaleDateString(locale, options) 8 | 9 | return now 10 | } 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [ 11 | "@pliny/config" 12 | ] 13 | } -------------------------------------------------------------------------------- /packages/pliny/tsup.ui.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | // Do not split UI files as we want to use default export 4 | // https://github.com/vercel/next.js/issues/52415 5 | export default defineConfig({ 6 | entry: ['src/ui/*'], 7 | format: 'esm', 8 | outDir: 'ui', 9 | clean: true, 10 | splitting: false, 11 | treeshake: true, 12 | dts: true, 13 | silent: true, 14 | }) 15 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs 5 | spec: "@yarnpkg/plugin-typescript" 6 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 7 | spec: "@yarnpkg/plugin-workspace-tools" 8 | - path: .yarn/plugins/@yarnpkg/plugin-version.cjs 9 | spec: "@yarnpkg/plugin-version" 10 | 11 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 12 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pliny/config", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "devDependencies": { 7 | "@typescript-eslint/eslint-plugin": "^6.0.0", 8 | "eslint": "^8.45.0", 9 | "eslint-config-next": "13.4.10", 10 | "eslint-config-prettier": "^8.8.0", 11 | "eslint-plugin-jsx-a11y": "^6.7.1", 12 | "eslint-plugin-prettier": "^5.0.0", 13 | "typescript": "^5.1.6" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/pliny/src/ui/BlogNewsletterForm.tsx: -------------------------------------------------------------------------------- 1 | import NewsletterForm, { NewsletterFormProps } from './NewsletterForm' 2 | 3 | const BlogNewsletterForm = ({ title, apiUrl }: NewsletterFormProps) => ( 4 |
5 |
6 | 7 |
8 |
9 | ) 10 | 11 | export default BlogNewsletterForm 12 | -------------------------------------------------------------------------------- /packages/pliny/src/ui/Bleed.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | 3 | export interface BleedProps { 4 | full?: boolean 5 | children: ReactNode 6 | } 7 | 8 | const Bleed = ({ full, children }: BleedProps) => { 9 | return ( 10 |
15 | {children} 16 |
17 | ) 18 | } 19 | 20 | export default Bleed 21 | -------------------------------------------------------------------------------- /packages/pliny/src/newsletter/mailchimp.ts: -------------------------------------------------------------------------------- 1 | import mailchimp from '@mailchimp/mailchimp_marketing' 2 | 3 | mailchimp.setConfig({ 4 | apiKey: process.env.MAILCHIMP_API_KEY, 5 | server: process.env.MAILCHIMP_API_SERVER, // E.g. us1 6 | }) 7 | 8 | export const mailchimpSubscribe = async (email: string) => { 9 | const response = await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, { 10 | email_address: email, 11 | status: 'subscribed', 12 | }) 13 | return response 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/pliny/src/mdx-plugins/remark-extract-frontmatter.ts: -------------------------------------------------------------------------------- 1 | import { Parent } from 'unist' 2 | import { VFile } from 'vfile' 3 | import { visit } from 'unist-util-visit' 4 | import yaml from 'js-yaml' 5 | 6 | /** 7 | * Extracts frontmatter from markdown file and adds it to the file's data object. 8 | * 9 | */ 10 | export function remarkExtractFrontmatter() { 11 | return (tree: Parent, file: VFile) => { 12 | visit(tree, 'yaml', (node: Parent) => { 13 | //@ts-ignore 14 | file.data.frontmatter = yaml.load(node.value) 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/pliny/src/search/KBarButton.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes } from 'react' 2 | import { useKBar } from 'kbar' 3 | 4 | /** 5 | * Button wrapper component that triggers the KBar modal on click. 6 | * 7 | * @return {*} 8 | */ 9 | export const KBarButton = ({ 10 | children, 11 | ...rest 12 | }: DetailedHTMLProps, HTMLButtonElement>) => { 13 | const { query } = useKBar() 14 | 15 | return ( 16 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /packages/pliny/src/newsletter/buttondown.ts: -------------------------------------------------------------------------------- 1 | export const buttondownSubscribe = async (email: string) => { 2 | const API_KEY = process.env.BUTTONDOWN_API_KEY 3 | const API_URL = 'https://api.buttondown.email/v1/' 4 | const buttondownRoute = `${API_URL}subscribers` 5 | 6 | const data = { email_address: email } 7 | 8 | const response = await fetch(buttondownRoute, { 9 | body: JSON.stringify(data), 10 | headers: { 11 | Authorization: `Token ${API_KEY}`, 12 | 'Content-Type': 'application/json', 13 | }, 14 | method: 'POST', 15 | }) 16 | return response 17 | } 18 | -------------------------------------------------------------------------------- /packages/config/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "isolatedModules": true, 6 | "module": "commonjs", 7 | "target": "es2015", 8 | "lib": ["dom", "dom.iterable", "ES2018"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "moduleResolution": "node", 16 | "jsx": "react-jsx", 17 | "resolveJsonModule": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/pliny/src/mdx-plugins/index.ts: -------------------------------------------------------------------------------- 1 | import { remarkExtractFrontmatter } from './remark-extract-frontmatter' 2 | import { remarkCodeTitles } from './remark-code-title' 3 | import type { Toc } from './remark-toc-headings' 4 | import { remarkTocHeadings, extractTocHeadings } from './remark-toc-headings' 5 | import type { ImageNode } from './remark-img-to-jsx' 6 | import { remarkImgToJsx } from './remark-img-to-jsx' 7 | 8 | export type { Toc, ImageNode } 9 | 10 | export { 11 | remarkExtractFrontmatter, 12 | remarkCodeTitles, 13 | remarkImgToJsx, 14 | remarkTocHeadings, 15 | extractTocHeadings, 16 | } 17 | -------------------------------------------------------------------------------- /packages/pliny/src/newsletter/convertkit.ts: -------------------------------------------------------------------------------- 1 | export const convertkitSubscribe = async (email: string) => { 2 | const FORM_ID = process.env.CONVERTKIT_FORM_ID 3 | const API_KEY = process.env.CONVERTKIT_API_KEY 4 | const API_URL = 'https://api.convertkit.com/v3/' 5 | 6 | // Send request to ConvertKit 7 | const data = { email, api_key: API_KEY } 8 | 9 | const response = await fetch(`${API_URL}forms/${FORM_ID}/subscribe`, { 10 | body: JSON.stringify(data), 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | }, 14 | method: 'POST', 15 | }) 16 | 17 | return response 18 | } 19 | -------------------------------------------------------------------------------- /packages/pliny/src/search/AlgoliaButton.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes, useContext } from 'react' 2 | import { AlgoliaSearchContext } from './Algolia' 3 | 4 | /** 5 | * Button wrapper component that triggers the Algolia modal on click. 6 | * 7 | * @return {*} 8 | */ 9 | export const AlgoliaButton = ({ 10 | children, 11 | ...rest 12 | }: DetailedHTMLProps, HTMLButtonElement>) => { 13 | const { query } = useContext(AlgoliaSearchContext) 14 | 15 | return ( 16 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /packages/pliny/src/newsletter/emailOctopus.ts: -------------------------------------------------------------------------------- 1 | export const emailOctopusSubscribe = async (email: string) => { 2 | const API_KEY = process.env.EMAILOCTOPUS_API_KEY 3 | const LIST_ID = process.env.EMAILOCTOPUS_LIST_ID 4 | const API_URL = 'https://emailoctopus.com/api/1.6/' 5 | 6 | const data = { email_address: email, api_key: API_KEY } 7 | 8 | const API_ROUTE = `${API_URL}lists/${LIST_ID}/contacts` 9 | 10 | const response = await fetch(API_ROUTE, { 11 | body: JSON.stringify(data), 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | method: 'POST', 16 | }) 17 | 18 | return response 19 | } 20 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": [ 6 | "^build" 7 | ], 8 | "outputs": [ 9 | "dist/**", 10 | ".next/**" 11 | ] 12 | }, 13 | "build-publishable-release": { 14 | "dependsOn": [ 15 | "^build-publishable-release" 16 | ], 17 | "outputs": [ 18 | "dist/**", 19 | ".next/**" 20 | ] 21 | }, 22 | "lint": { 23 | "outputs": [] 24 | }, 25 | "dev": { 26 | "cache": false 27 | }, 28 | "clean": { 29 | "cache": false 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /packages/pliny/src/utils/htmlEscaper.ts: -------------------------------------------------------------------------------- 1 | const { replace } = '' 2 | 3 | // escape 4 | const ca = /[&<>'"]/g 5 | 6 | const esca = { 7 | '&': '&', 8 | '<': '<', 9 | '>': '>', 10 | "'": ''', 11 | '"': '"', 12 | } 13 | const pe = (m: keyof typeof esca) => esca[m] 14 | 15 | /** 16 | * Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`. 17 | * @param {string} es the input to safely escape 18 | * @returns {string} the escaped input, and it **throws** an error if 19 | * the input type is unexpected, except for boolean and numbers, 20 | * converted as string. 21 | */ 22 | export const escape = (es: string): string => replace.call(es, ca, pe) 23 | -------------------------------------------------------------------------------- /packages/pliny/src/newsletter/beehiiv.ts: -------------------------------------------------------------------------------- 1 | export const beehiivSubscribe = async (email: string) => { 2 | const API_KEY = process.env.BEEHIIV_API_KEY 3 | const PUBLICATION_ID = process.env.BEEHIIV_PUBLICATION_ID 4 | const API_URL = `https://api.beehiiv.com/v2/publications/${PUBLICATION_ID}/subscriptions` 5 | 6 | const data = { 7 | email, 8 | reactivate_existing: false, 9 | send_welcome_email: true, 10 | } 11 | 12 | const response = await fetch(API_URL, { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | 'Authorization': `Bearer ${API_KEY}`, 17 | }, 18 | body: JSON.stringify(data), 19 | }) 20 | 21 | return response 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.husky/_/husky.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if [ -z "$husky_skip_init" ]; then 3 | debug () { 4 | [ "$HUSKY_DEBUG" = "1" ] && echo "husky (debug) - $1" 5 | } 6 | 7 | readonly hook_name="$(basename "$0")" 8 | debug "starting $hook_name..." 9 | 10 | if [ "$HUSKY" = "0" ]; then 11 | debug "HUSKY env variable is set to 0, skipping hook" 12 | exit 0 13 | fi 14 | 15 | if [ -f ~/.huskyrc ]; then 16 | debug "sourcing ~/.huskyrc" 17 | . ~/.huskyrc 18 | fi 19 | 20 | export readonly husky_skip_init=1 21 | sh -e "$0" "$@" 22 | exitCode="$?" 23 | 24 | if [ $exitCode != 0 ]; then 25 | echo "husky - $hook_name hook exited with code $exitCode (error)" 26 | exit $exitCode 27 | fi 28 | 29 | exit 0 30 | fi 31 | -------------------------------------------------------------------------------- /packages/pliny/src/analytics/MicrosoftClarity.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script.js' 2 | 3 | export interface ClarityProps { 4 | ClarityWebsiteId: string 5 | } 6 | 7 | // https://clarity.microsoft.com/ 8 | // https://learn.microsoft.com/en-us/clarity/setup-and-installation/clarity-setup 9 | export const Clarity = ({ ClarityWebsiteId }: ClarityProps) => { 10 | return ( 11 | <> 12 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /packages/pliny/src/analytics/SimpleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script.js' 2 | 3 | export interface SimpleAnalyticsProps { 4 | src?: string 5 | } 6 | 7 | export const SimpleAnalytics = ({ 8 | src = 'https://scripts.simpleanalyticscdn.com/latest.js', 9 | }: SimpleAnalyticsProps) => { 10 | return ( 11 | <> 12 | 17 | 23 | 24 | ) 25 | } 26 | 27 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events 28 | export const logEvent = (action, category, label, value) => { 29 | // @ts-ignore 30 | window.gtag?.('event', action, { 31 | event_category: category, 32 | event_label: label, 33 | value: value, 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Timothy Lin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # package-lock 8 | package-lock.json 9 | 10 | # builds 11 | build 12 | dist 13 | .rpt2_cache 14 | packages/pliny/analytics 15 | packages/pliny/comments 16 | packages/pliny/config 17 | packages/pliny/mdx-components 18 | packages/pliny/mdx-plugins 19 | packages/pliny/newsletter 20 | packages/pliny/search 21 | packages/pliny/seo 22 | packages/pliny/ui 23 | packages/pliny/utils 24 | packages/pliny/chunk-* 25 | packages/pliny/*.js 26 | packages/pliny/*.d.ts 27 | 28 | # misc 29 | .DS_Store 30 | .env 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | .tmp* 37 | /tmp 38 | 39 | /.yarn/* 40 | !/.yarn/releases 41 | !/.yarn/plugins 42 | !/.yarn/sdks 43 | 44 | npm-debug.log* 45 | yarn-debug.log* 46 | yarn-error.log* 47 | lerna-debug.log* 48 | .turbo 49 | /_release 50 | oclif.manifest.json 51 | 52 | # ide's setting folder 53 | .vscode/ 54 | .idea/ 55 | 56 | # jest code coverage reports 57 | coverage/ 58 | 59 | # cypress miscellaneous 60 | cypress/screenshots 61 | cypress/videos 62 | 63 | # rollup 64 | stats.html 65 | 66 | # Contentlayer 67 | .contentlayer 68 | -------------------------------------------------------------------------------- /packages/pliny/src/mdx-plugins/remark-code-title.ts: -------------------------------------------------------------------------------- 1 | import { Parent } from 'unist' 2 | import { visit } from 'unist-util-visit' 3 | 4 | /** 5 | * Parses title from code block and inserts it as a sibling title node. 6 | * 7 | */ 8 | export function remarkCodeTitles() { 9 | return (tree: Parent & { lang?: string }) => 10 | visit(tree, 'code', (node: Parent & { lang?: string }, index, parent: Parent) => { 11 | const nodeLang = node.lang || '' 12 | let language = '' 13 | let title = '' 14 | 15 | if (nodeLang.includes(':')) { 16 | language = nodeLang.slice(0, nodeLang.search(':')) 17 | title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length) 18 | } 19 | 20 | if (!title) { 21 | return 22 | } 23 | 24 | const className = 'remark-code-title' 25 | 26 | const titleNode = { 27 | type: 'mdxJsxFlowElement', 28 | name: 'div', 29 | attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }], 30 | children: [{ type: 'text', value: title }], 31 | data: { _xdmExplicitJsx: true }, 32 | } 33 | 34 | parent.children.splice(index, 0, titleNode) 35 | node.lang = language 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /packages/pliny/src/comments/Disqus.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import { useEffect, useCallback } from 'react' 3 | 4 | export interface DisqusConfig { 5 | provider: 'disqus' 6 | disqusConfig: { 7 | shortname: string 8 | } 9 | } 10 | 11 | export type DisqusProps = DisqusConfig['disqusConfig'] & { 12 | slug?: string 13 | } 14 | 15 | export const Disqus = ({ shortname, slug }: DisqusProps) => { 16 | const { theme } = useTheme() 17 | 18 | const COMMENTS_ID = 'disqus_thread' 19 | 20 | const LoadComments = useCallback(() => { 21 | window.disqus_config = function () { 22 | this.page.url = window.location.href 23 | this.page.identifier = slug 24 | } 25 | // Reset Disqus does not reset the style 26 | // As an ugly workaround we just reload it 27 | const script = document.createElement('script') 28 | script.src = 'https://' + shortname + '.disqus.com/embed.js' 29 | script.setAttribute('data-timestamp', Date.now().toString()) 30 | script.async = true 31 | document.body.appendChild(script) 32 | }, [shortname, slug, theme]) 33 | 34 | useEffect(() => { 35 | LoadComments() 36 | }, [LoadComments]) 37 | 38 | return
39 | } 40 | -------------------------------------------------------------------------------- /packages/pliny/src/analytics/Plausible.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script.js' 2 | 3 | export interface PlausibleProps { 4 | plausibleDataDomain: string 5 | dataApi?: string 6 | src?: string 7 | } 8 | 9 | /** 10 | * Plausible analytics component. 11 | * To proxy the requests through your own domain, you can use the dataApi and src attribute. 12 | * See [Plausible docs](https://plausible.io/docs/proxy/guides/nextjs#step-2-adjust-your-deployed-script) 13 | * for more information. 14 | * 15 | */ 16 | export const Plausible = ({ 17 | plausibleDataDomain, 18 | dataApi = undefined, 19 | src = 'https://plausible.io/js/plausible.js', 20 | }: PlausibleProps) => { 21 | return ( 22 | <> 23 | 34 | 35 | ) 36 | } 37 | 38 | // https://plausible.io/docs/custom-event-goals 39 | export const logEvent = (eventName, ...rest) => { 40 | return window.plausible?.(eventName, ...rest) 41 | } 42 | -------------------------------------------------------------------------------- /packages/pliny/src/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import React from 'react' 3 | import * as _jsx_runtime from 'react/jsx-runtime' 4 | import ReactDOM from 'react-dom' 5 | import type { MDXComponents } from 'mdx/types' 6 | 7 | export interface MDXLayoutRenderer { 8 | code: string 9 | components?: MDXComponents 10 | [key: string]: unknown 11 | } 12 | 13 | const getMDXComponent = ( 14 | code: string, 15 | globals: Record = {} 16 | ): React.ComponentType => { 17 | const scope = { React, ReactDOM, _jsx_runtime, ...globals } 18 | const fn = new Function(...Object.keys(scope), code) 19 | return fn(...Object.values(scope)).default 20 | } 21 | 22 | // TS transpile it to a require which causes ESM error 23 | // Copying the function from contentlayer as a workaround 24 | // Copy of https://github.com/contentlayerdev/contentlayer/blob/main/packages/next-contentlayer/src/hooks/useMDXComponent.ts 25 | export const useMDXComponent = ( 26 | code: string, 27 | globals: Record = {} 28 | ): React.ComponentType => { 29 | return React.useMemo(() => getMDXComponent(code, globals), [code, globals]) 30 | } 31 | 32 | export const MDXLayoutRenderer = ({ code, components, ...rest }: MDXLayoutRenderer) => { 33 | const Mdx = useMDXComponent(code) 34 | 35 | return 36 | } 37 | -------------------------------------------------------------------------------- /packages/pliny/src/mdx-plugins/remark-toc-headings.ts: -------------------------------------------------------------------------------- 1 | import { VFile } from 'vfile' 2 | import { Parent } from 'unist' 3 | import { visit } from 'unist-util-visit' 4 | import { Heading } from 'mdast' 5 | import GithubSlugger from 'github-slugger' 6 | import { toString } from 'mdast-util-to-string' 7 | import { remark } from 'remark' 8 | 9 | export type TocItem = { 10 | value: string 11 | url: string 12 | depth: number 13 | } 14 | 15 | export type Toc = TocItem[] 16 | 17 | /** 18 | * Extracts TOC headings from markdown file and adds it to the file's data object. 19 | */ 20 | export function remarkTocHeadings() { 21 | const slugger = new GithubSlugger() 22 | return (tree: Parent, file: VFile) => { 23 | const toc: Toc = [] 24 | visit(tree, 'heading', (node: Heading) => { 25 | const textContent = toString(node) 26 | toc.push({ 27 | value: textContent, 28 | url: '#' + slugger.slug(textContent), 29 | depth: node.depth, 30 | }) 31 | }) 32 | file.data.toc = toc 33 | } 34 | } 35 | 36 | /** 37 | * Passes markdown file through remark to extract TOC headings 38 | * 39 | * @param {string} markdown 40 | * @return {*} {Promise} 41 | */ 42 | export async function extractTocHeadings(markdown: string): Promise { 43 | const vfile = await remark().use(remarkTocHeadings).process(markdown) 44 | // @ts-ignore 45 | return vfile.data.toc 46 | } 47 | -------------------------------------------------------------------------------- /packages/pliny/add-use-client.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import globby from 'globby' 3 | 4 | // Append "use client" to all path chunks that contain "use" hooks 5 | // So these packages can be directly used in Next.js directly 6 | // This allows us to see support file splitting with easy next import 7 | ; (async () => { 8 | console.log('Added use client directive to the following files:') 9 | const chunkPaths = await globby('chunk*') 10 | for (const path of chunkPaths) { 11 | const data = fs.readFileSync(path, 'utf8') 12 | if ( 13 | /useState|useEffect|useRef|useCallback|useContext|useMemo|useTheme|useRouter|useRegisterActions|useMatches|useKBar/.test( 14 | data 15 | ) 16 | ) { 17 | console.log(path) 18 | const insert = Buffer.from('"use client"\n') 19 | fs.writeFileSync(path, insert + data) 20 | } 21 | } 22 | // Handle ui differently as they are not split 23 | const clientPaths = await globby([ 24 | 'ui/NewsletterForm.js', 25 | 'ui/BlogNewsletterForm.js', 26 | 'ui/Pre.js', 27 | 'search/KBarButton.js', 28 | 'search/AlgoliaButton.js', 29 | ]) 30 | for (const path of clientPaths) { 31 | console.log(path) 32 | const data = fs.readFileSync(path) 33 | const insert = Buffer.from('"use client"\n') 34 | fs.writeFileSync(path, insert + data) 35 | } 36 | })() 37 | -------------------------------------------------------------------------------- /packages/pliny/src/newsletter/klaviyo.ts: -------------------------------------------------------------------------------- 1 | export const klaviyoSubscribe = async (email: string) => { 2 | const API_KEY = process.env.KLAVIYO_API_KEY 3 | const LIST_ID = process.env.KLAVIYO_LIST_ID 4 | 5 | const data = { 6 | data: { 7 | type: 'profile-subscription-bulk-create-job', 8 | attributes: { 9 | custom_source: 'Marketing Event', 10 | profiles: { 11 | data: [ 12 | { 13 | type: 'profile', 14 | attributes: { 15 | email: email, 16 | subscriptions: { 17 | email: { 18 | marketing: { 19 | consent: 'SUBSCRIBED', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | ], 26 | }, 27 | }, 28 | relationships: { 29 | list: { 30 | data: { 31 | type: 'list', 32 | id: LIST_ID, 33 | }, 34 | }, 35 | }, 36 | }, 37 | } 38 | 39 | const response = await fetch('https://a.klaviyo.com/api/profile-subscription-bulk-create-jobs', { 40 | method: 'POST', 41 | headers: { 42 | Authorization: `Klaviyo-API-Key ${API_KEY}`, 43 | Accept: 'application/json', 44 | 'Content-Type': 'application/json', 45 | revision: '2024-07-15', 46 | }, 47 | body: JSON.stringify(data), 48 | }) 49 | 50 | return response 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pliny/monorepo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky install", 7 | "build": "turbo run build", 8 | "dev": "turbo run dev --parallel --no-cache", 9 | "build:starter": "turbo run build", 10 | "dev:starter": "turbo run dev --parallel --no-cache", 11 | "serve": "turbo run serve", 12 | "lint": "turbo run lint", 13 | "test": "vitest run", 14 | "clean": "turbo run clean && rm -rf node_modules", 15 | "format": "prettier --write \"**/*.{ts,tsx,md,mdx}\"", 16 | "changeset": "changeset", 17 | "version-packages": "changeset version", 18 | "release": "yarn run build && changeset publish" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/timlrx/pliny" 23 | }, 24 | "lint-staged": { 25 | "./packages/*/src/**/*.+(js|jsx|ts|tsx)": [ 26 | "eslint --fix" 27 | ], 28 | "./packages/*/src/**/*.+(js|jsx|ts|tsx|json|css|md|mdx)": [ 29 | "prettier --write" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "@changesets/cli": "^2.26.0", 34 | "@commitlint/cli": "^17.0.0", 35 | "@commitlint/config-conventional": "^17.1.0", 36 | "@types/react": "18.3.3", 37 | "husky": "^8.0.0", 38 | "lint-staged": "^13.0.0", 39 | "prettier": "^3.0.0", 40 | "turbo": "2.0.3", 41 | "vitest": "1.4.0" 42 | }, 43 | "workspaces": { 44 | "packages": [ 45 | "packages/*" 46 | ] 47 | }, 48 | "packageManager": "yarn@3.6.1" 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | # test: 12 | # name: Unit Test 13 | # runs-on: ubuntu-latest 14 | # steps: 15 | # - uses: actions/checkout@v3 16 | # - uses: actions/setup-node@v3 17 | # with: 18 | # node-version: '18.x' 19 | # - name: Install dependencies 20 | # run: yarn 21 | # - name: Build 22 | # run: yarn build 23 | # - name: Run unit tests 24 | # run: yarn test 25 | 26 | release: 27 | name: Release 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout Repo 31 | uses: actions/checkout@v3 32 | with: 33 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 34 | fetch-depth: 0 35 | 36 | - name: Setup Node.js 18.x 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version: 18.x 40 | 41 | - name: Install Dependencies 42 | run: yarn 43 | 44 | - name: Create Release Pull Request or Publish to npm 45 | id: changesets 46 | uses: changesets/action@v1 47 | with: 48 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 49 | version: node .github/changeset-version.cjs 50 | publish: yarn release 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | -------------------------------------------------------------------------------- /packages/pliny/src/analytics/Posthog.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script.js' 2 | 3 | export interface PosthogProps { 4 | posthogProjectApiKey: string 5 | apiHost?: string 6 | } 7 | 8 | /** 9 | * Posthog analytics component. 10 | * See [Posthog docs](https://posthog.com/docs/libraries/js#option-1-add-javascript-snippet-to-your-html-badgerecommendedbadge) for more information. 11 | * 12 | */ 13 | export const Posthog = ({ 14 | posthogProjectApiKey, 15 | apiHost = 'https://app.posthog.com', 16 | }: PosthogProps) => { 17 | return ( 18 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /packages/pliny/src/comments/Giscus.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import GiscusComponent from '@giscus/react' 3 | import type { Mapping, BooleanString, InputPosition } from '@giscus/react' 4 | 5 | // TODO: type optional fields 6 | export interface GiscusConfig { 7 | provider: 'giscus' 8 | giscusConfig: { 9 | themeURL?: string 10 | theme?: string 11 | darkTheme?: string 12 | mapping: Mapping 13 | repo: string 14 | repositoryId: string 15 | category: string 16 | categoryId: string 17 | reactions: BooleanString 18 | metadata: BooleanString 19 | inputPosition?: InputPosition 20 | lang?: string 21 | } 22 | } 23 | 24 | export type GiscusProps = GiscusConfig['giscusConfig'] 25 | 26 | export const Giscus = ({ 27 | themeURL, 28 | theme, 29 | darkTheme, 30 | repo, 31 | repositoryId, 32 | category, 33 | categoryId, 34 | reactions, 35 | metadata, 36 | inputPosition, 37 | lang, 38 | mapping, 39 | }: GiscusProps) => { 40 | const { theme: nextTheme, resolvedTheme } = useTheme() 41 | const commentsTheme = 42 | themeURL === '' 43 | ? nextTheme === 'dark' || resolvedTheme === 'dark' 44 | ? darkTheme 45 | : theme 46 | : themeURL 47 | 48 | const COMMENTS_ID = 'comments-container' 49 | 50 | return ( 51 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /packages/config/eslint.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:react/recommended', 6 | 'plugin:prettier/recommended', 7 | 'prettier', 8 | ], 9 | plugins: ['@typescript-eslint'], 10 | env: { 11 | node: true, 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2020, 15 | ecmaFeatures: { 16 | legacyDecorators: true, 17 | }, 18 | }, 19 | rules: { 20 | 'react/display-name': 0, 21 | 'react/require-default-props': 0, 22 | 'no-restricted-syntax': 0, 23 | 'import/prefer-default-export': 0, 24 | 'import/no-named-as-default': 0, 25 | 'import/named': 0, 26 | '@typescript-eslint/no-explicit-any': 0, 27 | '@typescript-eslint/explicit-module-boundary-types': 0, 28 | '@typescript-eslint/explicit-function-return-type': 0, 29 | '@typescript-eslint/ban-ts-comment': 0, 30 | '@typescript-eslint/ban-ts-ignore': 0, 31 | '@typescript-eslint/ban-types': 0, 32 | '@typescript-eslint/no-var-requires': 0, 33 | '@typescript-eslint/no-unused-vars': 'warn', 34 | '@typescript-eslint/no-use-before-define': ['error', { variables: false, functions: false }], 35 | 'space-before-function-paren': 0, 36 | 'no-plusplus': 0, 37 | 'react/prop-types': 'off', 38 | 'react/jsx-handler-names': 0, 39 | 'react/jsx-fragments': 0, 40 | 'react/react-in-jsx-scope': 'off', 41 | 'react/no-unused-prop-types': 0, 42 | 'react/jsx-props-no-spreading': 0, 43 | 'react/static-property-placement': 0, 44 | 'react/no-array-index-key': 0, 45 | 'arrow-parens': 1, 46 | '@typescript-eslint/no-shadow': 'off', 47 | 'no-useless-return': 'off', 48 | 'import/no-extraneous-dependencies': 'off', 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /packages/pliny/src/comments/Utterances.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react' 2 | import { useTheme } from 'next-themes' 3 | 4 | export interface UtterancesConfig { 5 | provider: 'utterances' 6 | utterancesConfig: { 7 | theme?: string 8 | darkTheme?: string 9 | repo?: string 10 | label?: string 11 | issueTerm?: string 12 | } 13 | } 14 | 15 | export type UtterancesProps = UtterancesConfig['utterancesConfig'] 16 | 17 | export const Utterances = ({ theme, darkTheme, repo, label, issueTerm }: UtterancesProps) => { 18 | const { theme: nextTheme, resolvedTheme } = useTheme() 19 | const commentsTheme = nextTheme === 'dark' || resolvedTheme === 'dark' ? darkTheme : theme 20 | 21 | const COMMENTS_ID = 'comments-container' 22 | 23 | const LoadComments = useCallback(() => { 24 | const script = document.createElement('script') 25 | script.src = 'https://utteranc.es/client.js' 26 | script.setAttribute('repo', repo) 27 | script.setAttribute('issue-term', issueTerm) 28 | script.setAttribute('label', label) 29 | script.setAttribute('theme', commentsTheme) 30 | script.setAttribute('crossorigin', 'anonymous') 31 | script.async = true 32 | 33 | const comments = document.getElementById(COMMENTS_ID) 34 | if (comments) comments.appendChild(script) 35 | 36 | return () => { 37 | const comments = document.getElementById(COMMENTS_ID) 38 | if (comments) comments.innerHTML = '' 39 | } 40 | }, [commentsTheme, issueTerm, label, repo]) 41 | 42 | // Reload on theme change 43 | useEffect(() => { 44 | LoadComments() 45 | }, [LoadComments]) 46 | 47 | // Added `relative` to fix a weird bug with `utterances-frame` position 48 | return
49 | } 50 | -------------------------------------------------------------------------------- /packages/pliny/src/search/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { AlgoliaSearchProvider } from './Algolia' 3 | import { KBarSearchProvider } from './KBar' 4 | 5 | import type { AlgoliaConfig } from './Algolia' 6 | import type { KBarConfig } from './KBar' 7 | 8 | export type SearchConfig = AlgoliaConfig | KBarConfig 9 | export interface SearchConfigProps { 10 | searchConfig: SearchConfig 11 | children: React.ReactNode 12 | } 13 | 14 | /** 15 | * Command palette like search component - `ctrl-k` to open the palette. 16 | * Or use the search context to bind toggle to an onOpen event. 17 | * Currently supports Algolia or Kbar (local search). 18 | * 19 | * To toggle the modal or search from child components, use the search context: 20 | * 21 | * For Algolia: 22 | * ``` 23 | * import { AlgoliaSearchContext } from 'pliny/search/algolia' 24 | * const { query } = useContext(AlgoliaSearchContext) 25 | * ``` 26 | * 27 | * For Kbar: 28 | * ``` 29 | * import { useKBar } from 'kbar' 30 | * const { query } = useKBar() 31 | * ``` 32 | * 33 | * @param {SearchConfig} searchConfig 34 | * @return {*} 35 | */ 36 | export const SearchProvider = ({ searchConfig, children }: SearchConfigProps) => { 37 | if (searchConfig && searchConfig.provider) { 38 | switch (searchConfig.provider) { 39 | case 'algolia': 40 | return ( 41 | 42 | {children} 43 | 44 | ) 45 | case 'kbar': 46 | return ( 47 | {children} 48 | ) 49 | default: 50 | console.log('No suitable provider found. Please choose from algolia or kbar.') 51 | return <>{children} 52 | } 53 | } else { 54 | return <>{children} 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/pliny/src/mdx-plugins/remark-img-to-jsx.ts: -------------------------------------------------------------------------------- 1 | import { Parent, Node, Literal } from 'unist' 2 | import { visit } from 'unist-util-visit' 3 | import { sync as sizeOf } from 'probe-image-size' 4 | import fs from 'fs' 5 | 6 | export type ImageNode = Parent & { 7 | url: string 8 | alt: string 9 | name: string 10 | attributes: (Literal & { name: string })[] 11 | } 12 | 13 | /** 14 | * Converts markdown image nodes to next/image jsx. 15 | * 16 | */ 17 | export function remarkImgToJsx() { 18 | return (tree: Node) => { 19 | visit( 20 | tree, 21 | // only visit p tags that contain an img element 22 | (node: Parent): node is Parent => 23 | node.type === 'paragraph' && node.children.some((n) => n.type === 'image'), 24 | (node: Parent) => { 25 | const imageNodeIndex = node.children.findIndex((n) => n.type === 'image') 26 | const imageNode = node.children[imageNodeIndex] as ImageNode 27 | 28 | // only local files 29 | if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) { 30 | const dimensions = sizeOf(fs.readFileSync(`${process.cwd()}/public${imageNode.url}`)) 31 | 32 | // Convert original node to next/image 33 | ;(imageNode.type = 'mdxJsxFlowElement'), 34 | (imageNode.name = 'Image'), 35 | (imageNode.attributes = [ 36 | { type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt }, 37 | { type: 'mdxJsxAttribute', name: 'src', value: imageNode.url }, 38 | { type: 'mdxJsxAttribute', name: 'width', value: dimensions.width }, 39 | { type: 'mdxJsxAttribute', name: 'height', value: dimensions.height }, 40 | ]) 41 | // Change node type from p to div to avoid nesting error 42 | node.type = 'div' 43 | node.children[imageNodeIndex] = imageNode 44 | } 45 | } 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/pliny/src/ui/Pre.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, ReactNode } from 'react' 2 | 3 | const Pre = ({ children }: { children: ReactNode }) => { 4 | const textInput = useRef(null) 5 | const [hovered, setHovered] = useState(false) 6 | const [copied, setCopied] = useState(false) 7 | 8 | const onEnter = () => { 9 | setHovered(true) 10 | } 11 | const onExit = () => { 12 | setHovered(false) 13 | setCopied(false) 14 | } 15 | const onCopy = () => { 16 | setCopied(true) 17 | navigator.clipboard.writeText(textInput.current.textContent) 18 | setTimeout(() => { 19 | setCopied(false) 20 | }, 2000) 21 | } 22 | 23 | return ( 24 |
25 | {hovered && ( 26 | 63 | )} 64 | 65 |
{children}
66 |
67 | ) 68 | } 69 | 70 | export default Pre 71 | -------------------------------------------------------------------------------- /packages/pliny/src/analytics/Umami.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | 3 | /** 4 | * Props for the Umami component. 5 | */ 6 | export interface UmamiProps { 7 | /** The unique Umami website ID. */ 8 | umamiWebsiteId: string 9 | /** The Umami host URL. */ 10 | umamiHostUrl?: string 11 | /** Tag to identify the script. */ 12 | umamiTag?: string 13 | /** Enable or disable automatic tracking. Defaults to true. */ 14 | umamiAutoTrack?: boolean 15 | /** Exclude URL query parameters from tracking. Defaults to false. */ 16 | umamiExcludeSearch?: boolean 17 | /** A comma-separated list of domains to limit tracking to. */ 18 | umamiDomains?: string 19 | /** Source URL for the Umami script. Defaults to the official CDN. */ 20 | src?: string 21 | /** Additional data attributes for the script tag. */ 22 | [key: `data${string}`]: any 23 | } 24 | 25 | const propToDataAttributeMap: { [key: string]: string } = { 26 | umamiWebsiteId: 'data-website-id', 27 | umamiHostUrl: 'data-host-url', 28 | umamiTag: 'data-tag', 29 | umamiAutoTrack: 'data-auto-track', 30 | umamiExcludeSearch: 'data-exclude-search', 31 | umamiDomains: 'data-domains', 32 | } 33 | 34 | /** 35 | * A React component that integrates Umami analytics via a script tag. 36 | * 37 | * @param props - The props for the Umami component. 38 | * @returns A Script element with the Umami analytics script and dynamic data attributes. 39 | */ 40 | export const Umami = ({ src = 'https://analytics.umami.is/script.js', ...props }: UmamiProps) => { 41 | const dataAttributes: Record = {} 42 | 43 | // Map known Umami props to data attributes 44 | Object.entries(propToDataAttributeMap).forEach(([propName, dataAttrName]) => { 45 | const value = props[propName as keyof UmamiProps] 46 | if (value !== undefined) { 47 | dataAttributes[dataAttrName] = typeof value === 'boolean' ? String(value) : value 48 | } 49 | }) 50 | 51 | // Include additional data attributes passed via props 52 | Object.entries(props).forEach(([key, value]) => { 53 | if (key.startsWith('data') && value !== undefined && !(key in propToDataAttributeMap)) { 54 | // Convert camelCase to kebab-case for HTML attributes 55 | const attributeName = key.replace(/([A-Z])/g, '-$1').toLowerCase() 56 | dataAttributes[attributeName] = value 57 | } 58 | }) 59 | 60 | return