├── .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 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/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 |
12 | {{ error }}
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/pages/[owner]/[name].vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 | {{ error }}
16 |
17 |
18 |
19 |
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 |
30 |
31 |
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 |
26 |
27 |
28 |
41 |
42 |
43 |
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 |
--------------------------------------------------------------------------------