├── .npmrc ├── .env.example ├── .gitignore ├── pages ├── index.vue └── [owner] │ ├── index.vue │ └── [name].vue ├── tsconfig.json ├── content └── index.md ├── nuxt.config.ts ├── package.json ├── README.md ├── components └── content │ └── ProseImg.vue ├── composables └── useMarkdown.ts ├── app.vue └── utils └── rehype-video.ts /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TYPOGRAPHY_THEME= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /content/index.md: -------------------------------------------------------------------------------- 1 | # Readme Typography 2 | 3 | Lookup a GitHub repository and see how it renders with [Nuxt Typography](https://typography.nuxt.space). 4 | 5 | See [source code](https://github.com/Atinux/nuxt-typo-readme). -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | ssr: false, 4 | extends: [ 5 | process.env.TYPOGRAPHY_THEME || '@nuxt-themes/typography' 6 | ], 7 | modules: [ 8 | '@nuxt/content', 9 | '@vueuse/nuxt' 10 | ], 11 | content: { 12 | experimental: { 13 | clientDB: true 14 | } 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "preview": "nuxt preview", 8 | "postinstall": "nuxt prepare" 9 | }, 10 | "devDependencies": { 11 | "@nuxt-themes/typography": "^0.4.0", 12 | "@nuxt/content": "npm:@nuxt/content-edge@latest", 13 | "@vueuse/nuxt": "^9.10.0", 14 | "nuxt": "npm:nuxt3@latest", 15 | "remark-github": "^11.2.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pages/[owner]/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /pages/[owner]/[name].vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Readme / Nuxt Typography 2 | 3 | Render any GitHub readme with [Nuxt Typography](https://github.com/nuxt-themes/typography). 4 | 5 | https://nuxt-typo-readme.vercel.app 6 | 7 | ## Setup 8 | 9 | Make sure to install the dependencies: 10 | 11 | ```bash 12 | # pnpm 13 | pnpm install 14 | ``` 15 | 16 | ## Development Server 17 | 18 | Start the development server on http://localhost:3000 19 | 20 | ```bash 21 | npm run dev 22 | ``` 23 | 24 | ## Production 25 | 26 | Build the application for production: 27 | 28 | ```bash 29 | npm run build 30 | ``` 31 | 32 | Locally preview production build: 33 | 34 | ```bash 35 | npm run preview 36 | ``` 37 | 38 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 39 | -------------------------------------------------------------------------------- /components/content/ProseImg.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 32 | 33 | -------------------------------------------------------------------------------- /composables/useMarkdown.ts: -------------------------------------------------------------------------------- 1 | import { transformContent } from '@nuxt/content/transformers' 2 | import remarkGithub from 'remark-github' 3 | import rehypeExternalLinks from 'rehype-external-links' 4 | 5 | export interface ParseOptions { 6 | id?: string 7 | content?: string 8 | repository?: string 9 | checkboxCallback?: (position: number, checked: boolean) => void 10 | } 11 | 12 | export const useMarkdown = () => { 13 | async function parse ({ id = 'content:_markdown.md', content = '', repository = undefined, checkboxCallback = undefined }: ParseOptions = {}) { 14 | let parsed = await transformContent(id, content, { 15 | highlight: { 16 | theme: { 17 | dark: 'dark-plus', 18 | default: 'light-plus' 19 | } 20 | }, 21 | markdown: { 22 | remarkPlugins: { 23 | ...repository 24 | ? { 25 | 'remark-github': { 26 | instance: remarkGithub, 27 | repository, 28 | mentionStrong: false, 29 | buildUrl (values: any, defaultBuildUrl: any) { 30 | if (values.type === 'issue') { 31 | return `/${values.user}/${values.project}/issues/${values.no}` 32 | } else if (values.type === 'mention') { 33 | return `/${values.user}` 34 | } 35 | 36 | return defaultBuildUrl(values) 37 | } 38 | } 39 | } 40 | : {} 41 | }, 42 | rehypePlugins: { 43 | 'rehype-external-links': { 44 | instance: rehypeExternalLinks, 45 | target: '_blank', 46 | rel: ['nofollow', 'noopener', 'noreferrer'] 47 | }, 48 | 'rehype-video': { 49 | instance: RehypeVideo 50 | } 51 | } 52 | } 53 | }) 54 | 55 | return parsed 56 | } 57 | 58 | return { 59 | parse 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 44 | 45 | 99 | -------------------------------------------------------------------------------- /utils/rehype-video.ts: -------------------------------------------------------------------------------- 1 | // origin file: https://github.com/jaywcjlove/rehype-video/blob/f006fbfe59060f504a80be15d2b20c4ed5d1d9e7/src/index.ts#L1 2 | 3 | import type { Plugin } from 'unified' 4 | import type { Root } from 'hast' 5 | import type { Node } from 'unist' 6 | import { visit } from 'unist-util-visit' 7 | 8 | export type RehypeVideoOptions = { 9 | /** 10 | * iFrame video ratio. 11 | * @default 16/9 12 | */ 13 | ratio: number 14 | } 15 | 16 | const videoProperties = { muted: 'muted', controls: 'controls', style: 'max-width:100%;' } 17 | const iframeProperties = { 18 | frameborder: 0, 19 | webkitallowfullscreen: true, 20 | mozallowfullscreen: true, 21 | allowfullscreen: true, 22 | style: 'position: absolute; top: 32px; left: 0; width: 100%; height: 100%;' 23 | } 24 | 25 | export const RehypeVideo: Plugin<[RehypeVideoOptions?], Root> = (options) => { 26 | const ratio = options?.ratio || 16 / 9 27 | 28 | const validUrl = /(https?:\/\/)(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g 29 | const videoUrls: { regex: RegExp, replace?: (url: string) => string, type?: string }[] = [ 30 | // youtube 31 | { regex: /(https?:\/\/)(www\.)?youtube\.com\/embed\/[a-zA-Z0-9-_]+/g }, 32 | { regex: /(https?:\/\/)(www\.)?youtube\.com\/watch\?v=[a-zA-Z0-9-_]+/g, replace: url => url.replace(/youtube\.com\/watch\?v=/g, 'youtube.com/embed/') }, 33 | { regex: /(https?:\/\/)(www\.)?youtu\.be\/[a-zA-Z0-9-_]+/g, replace: url => url.replace(/youtu\.be/g, 'youtube.com/embed') }, 34 | // loom 35 | { regex: /(https?:\/\/)(www\.)?loom\.com\/embed\/[a-zA-Z0-9]+/g }, 36 | { regex: /(https?:\/\/)(www\.)?loom\.com\/share\/[a-zA-Z0-9]+/g, replace: url => url.replace(/loom\.com\/share\//g, 'loom.com/embed/') }, 37 | // mp4 38 | { regex: /(https?:\/\/)(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\/[-a-zA-Z0-9@:%_+.~#?&//=]*\.mp4(\b[-a-zA-Z0-9@:%_+.~#?&//=]*)?/gi, type: 'video' }, 39 | // mov 40 | { regex: /(https?:\/\/)(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\/[-a-zA-Z0-9@:%_+.~#?&//=]*\.mov(\b[-a-zA-Z0-9@:%_+.~#?&//=]*)?/gi, type: 'video' } 41 | ] 42 | 43 | return transform 44 | 45 | function transform (tree: Node) { 46 | visit(tree, 'element', (node: Node) => { 47 | if (['div', 'p', 'a'].includes(node.tagName) && node.children.length === 1) { 48 | const child = node.children[0] 49 | if (child.type === 'text') { 50 | const data = getValidUrl(child.value) 51 | if (data) { 52 | updateVideoNode(node, data.type, data.url) 53 | } 54 | } 55 | } 56 | }) 57 | } 58 | 59 | function updateVideoNode (node: Node, videoType: string, url: string) { 60 | if (videoType === 'video') { 61 | node.type = 'element' 62 | node.tagName = 'video' 63 | node.properties = { ...videoProperties, src: url } 64 | node.children = [] 65 | } else { 66 | node.tagName = 'div' 67 | node.properties = { style: `position: relative; width: 100%; padding-bottom: ${100 / ratio}%; margin-bottom: 64px;` } 68 | node.children = [{ 69 | type: 'element', 70 | tagName: 'iframe', 71 | properties: { 72 | ...iframeProperties, 73 | src: url 74 | }, 75 | children: [] 76 | }] 77 | } 78 | } 79 | 80 | function getValidUrl (url: string) { 81 | if (url.match(validUrl) === null) { 82 | return null 83 | } 84 | 85 | for (const videoUrl of videoUrls) { 86 | if (url.match(videoUrl.regex)) { 87 | return { 88 | url: videoUrl.replace ? videoUrl.replace(url) : url, 89 | type: videoUrl.type || 'iframe' 90 | } 91 | } 92 | } 93 | 94 | return null 95 | } 96 | } 97 | --------------------------------------------------------------------------------