Repository: m1guelpf/mirror-next
Branch: main
Commit: 5804ee09d479
Files: 47
Total size: 63.2 KB
Directory structure:
gitextract_m1cqlie7/
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── next-env.d.ts
├── next.config.js
├── package.json
├── patches/
│ └── re2@1.20.11.patch
├── postcss.config.js
├── scripts/
│ └── resolve-ens.js
├── src/
│ ├── app/
│ │ ├── [digest]/
│ │ │ └── page.tsx
│ │ ├── api/
│ │ │ ├── link-preview/
│ │ │ │ └── route.ts
│ │ │ └── nft-data/
│ │ │ └── route.ts
│ │ ├── error.tsx
│ │ ├── feed.xml/
│ │ │ └── route.ts
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ ├── post/
│ │ │ └── [slug]/
│ │ │ └── route.ts
│ │ └── posts.json/
│ │ └── route.ts
│ ├── components/
│ │ ├── EntryLink.tsx
│ │ ├── LinkButton.tsx
│ │ ├── NFT.tsx
│ │ └── OpenGraph.tsx
│ ├── context/
│ │ ├── image_sizes.tsx
│ │ └── theme.tsx
│ ├── data/
│ │ ├── ERC721.ts
│ │ ├── ens.ts
│ │ ├── entries.ts
│ │ └── publication.ts
│ ├── hooks/
│ │ └── getConfig.ts
│ ├── lib/
│ │ ├── arweave.ts
│ │ └── graphql.ts
│ ├── queries/
│ │ ├── arweave/
│ │ │ ├── fetch-single-transaction.ts
│ │ │ └── fetch-transactions.ts
│ │ └── mirror/
│ │ └── fetch-publication.ts
│ ├── styles/
│ │ └── style.css
│ ├── types/
│ │ └── link-preview.ts
│ └── utils/
│ ├── address.ts
│ ├── embeds.ts
│ ├── excerpt.ts
│ ├── highlightMarkdown.ts
│ ├── images.ts
│ ├── markdown.tsx
│ └── url.ts
├── tailwind.config.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": "next/core-web-vitals"
}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# custom
src/data/ens.js
================================================
FILE: LICENSE
================================================
Copyright (c) Miguel Piedrafita
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# A Next.js-powered frontend for your Mirror blog
> This project mimics the [Mirror](https://mirror.xyz) publication design and pulls data from their APIs, allowing you to self-host your Mirror blog on a custom domain.
You can view a demo of this project by visiting [m1guelpf.blog](https://m1guelpf.blog), which hosts the [my Mirror publication](https://miguel.mirror.xyz).
## Features
- [x] Article list
- [x] Article page
- [x] Code highlighting (using VSCode's rendering engine, allows for custom themes. Currently using `github-light` and `github-dark`, depending on the publication's theme)
- [x] Dark mode (when enabled on Mirror)
- [x] Patience page (when no articles exist)
- [x] Static generation (all pages should load instantly once deployed)
- [x] Static re-generation (new articles should appear without re-deploying)
- [x] Embeds
- [x] Tweet embeds
- [x] YouTube embeds
- [x] Additional embeds (CodePen, JSBin, Gists, etc., not sure if supported by Mirror already)
- [x] NFT embeds
- [x] Bookmark cards (Open Graph)
- [x] Email list support (when enabled on Mirror)
- [x] Pull content from Arweave
- [x] Write Mirror entry about this project
## Development
- Clone this repo in a local directory
- Install dependencies (`pnpm install`)
- Copy the `.env.example` file to `.env.local`, and fill in your mirror subdomain and an RPC URL
- Start the server! (`pnpm dev`)
## Deploying to Vercel
You can deploy this project to Vercel (and load your own publication!) by clicking the button below:
[](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fm1guelpf%2Fmirror-next&env=NEXT_PUBLIC_AUTHOR_ENS,NEXT_PUBLIC_RPC_URL&envDescription=The%20ENS%20for%20the%20publication%20you%20want%20to%20load%2C%20and%20an%20RPC%20URL.&project-name=mirror-next&repo-name=mirror-next)
Once it's ready, you should be able to attach your custom domain from the Vercel settings page.
## FAQ
**Is this decentralized?**
Kind of. While I'm pulling the entry listing and contents from the Arweave chain directly, the publication details come from Mirror's APIs.
**Why did you make this?**
I like playing with stuff :). I really like Mirror's design, so I decided to create a [Ghost](https://ghost.org) theme "inspired" by their design. Once that was finished, I decided to turn that theme into something slightly more useful.
**Who are you?**
:wave: Hi! I'm [Miguel Piedrafita](https://twitter.com/m1guelpf), an 22-year-old maker. You can follow my journey and all the little things I make on the way [on Twitter](https://twitter.com/m1guelpf).
**I have another question**
Read [Building apps with Mirror](https://m1guelpf.blog/post/building-apps-with-mirror), an entry I wrote in my own Mirror publication explaining how this project works, and how you can build your own Mirror apps. If you still have questions after that, [drop me a line on Twitter](https://twitter.com/m1guelpf).
## License
This project is open-sourced software licensed under the MIT license. See the [License file](LICENSE.md) for more information.
================================================
FILE: next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: next.config.js
================================================
import resolveEns from './scripts/resolve-ens.js'
export default () => {
resolveEns()
return {
images: {
remotePatterns: [{ hostname: 'images.mirror-media.xyz' }],
},
}
}
================================================
FILE: package.json
================================================
{
"name": "mirror-next",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@apollo/client": "^3.10.4",
"@heroicons/react": "^2.1.3",
"@tailwindcss/typography": "^0.5.13",
"arweave": "^1.15.1",
"autoprefixer": "^10.4.19",
"fs": "^0.0.1-security",
"graphql": "^16.8.1",
"image-size": "^1.1.1",
"metascraper": "^5.45.8",
"metascraper-author": "^5.45.7",
"metascraper-clearbit": "^5.45.7",
"metascraper-date": "^5.45.7",
"metascraper-description": "^5.45.7",
"metascraper-image": "^5.45.7",
"metascraper-lang": "^5.45.7",
"metascraper-logo": "^5.45.7",
"metascraper-logo-favicon": "^5.45.7",
"metascraper-publisher": "^5.45.7",
"metascraper-title": "^5.45.7",
"metascraper-url": "^5.45.7",
"net": "^1.0.2",
"next": "^14.2.3",
"postcss": "^8.4.38",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-embed": "^3.7.0",
"react-markdown": "^9.0.1",
"react-medium-image-zoom": "^4.3.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"remark-stringify": "^11.0.0",
"rss": "^1.2.2",
"shiki": "^1.6.0",
"slug": "^9.0.0",
"strip-markdown": "^6.0.0",
"swr": "^2.2.5",
"tailwindcss": "^3.4.3",
"timeago.js": "^4.0.2",
"tls": "^0.0.1",
"unified": "^11.0.4",
"unist-util-visit": "^5.0.0",
"viem": "^2.11.1"
},
"devDependencies": {
"eslint": "^9.3.0",
"prettier": "^2.2.1",
"prettier-plugin-sort-imports-desc": "^1.0.0",
"typescript": "5.4.5"
},
"prettier": {
"semi": false,
"tabWidth": 4,
"useTabs": true,
"printWidth": 120,
"singleQuote": true,
"arrowParens": "avoid",
"trailingComma": "es5",
"bracketSpacing": true,
"plugins": [
"prettier-plugin-sort-imports-desc"
]
},
"pnpm": {
"patchedDependencies": {
"re2@1.20.11": "patches/re2@1.20.11.patch"
}
}
}
================================================
FILE: patches/re2@1.20.11.patch
================================================
diff --git a/package.json b/package.json
index c3bc354aa66946406f62bd7805fc88182039b081..b53fb74d1410881b38b0fcbac2f7ad49a12fe778 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,8 @@
"test": "node tests/tests.js",
"ts-test": "tsc",
"save-to-github": "save-to-github-cache --artifact build/Release/re2.node",
- "install": "install-from-cache --artifact build/Release/re2.node --host-var RE2_DOWNLOAD_MIRROR --skip-path-var RE2_DOWNLOAD_SKIP_PATH --skip-ver-var RE2_DOWNLOAD_SKIP_VER || node-gyp rebuild",
+ "original-install": "install-from-cache --artifact build/Release/re2.node --host-var RE2_DOWNLOAD_MIRROR --skip-path-var RE2_DOWNLOAD_SKIP_PATH --skip-ver-var RE2_DOWNLOAD_SKIP_VER || node-gyp rebuild",
+ "install": "echo 'Skipping re2 installation.'",
"verify-build": "node scripts/verify-build.js",
"rebuild": "node-gyp rebuild"
},
diff --git a/re2.js b/re2.js
index 3f32be925d96464242e503c03855c37ac5b354af..fdecf3f1a46d9f36a81b211fd5dbba9426b4bceb 100644
--- a/re2.js
+++ b/re2.js
@@ -1,38 +1,3 @@
'use strict';
-const RE2 = require('./build/Release/re2.node');
-
-if (typeof Symbol != 'undefined') {
- Symbol.match &&
- (RE2.prototype[Symbol.match] = function (str) {
- return this.match(str);
- });
- Symbol.search &&
- (RE2.prototype[Symbol.search] = function (str) {
- return this.search(str);
- });
- Symbol.replace &&
- (RE2.prototype[Symbol.replace] = function (str, repl) {
- return this.replace(str, repl);
- });
- Symbol.split &&
- (RE2.prototype[Symbol.split] = function (str, limit) {
- return this.split(str, limit);
- });
- Symbol.matchAll &&
- (RE2.prototype[Symbol.matchAll] = function* (str) {
- if (!this.global) {
- throw TypeError('String.prototype.matchAll called with a non-global RE2 argument');
- }
- const re = new RE2(this);
- re.lastIndex = this.lastIndex;
- for (;;) {
- const result = re.exec(str);
- if (!result) break;
- if (result[0] === '') ++re.lastIndex;
- yield result;
- }
- });
-}
-
-module.exports = RE2;
+module.exports = RegExp;
================================================
FILE: postcss.config.js
================================================
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
================================================
FILE: scripts/resolve-ens.js
================================================
import fs from 'fs'
import { normalize } from 'viem/ens'
import { mainnet } from 'viem/chains'
import { createPublicClient, http } from 'viem'
export default async () => {
if (!process.env.NEXT_PUBLIC_AUTHOR_ENS) {
console.error('NEXT_PUBLIC_AUTHOR_ENS is not set')
}
if (fs.existsSync('./src/data/ens.ts')) {
// If the author ENS wasn't set on the first run, we might have an invalid publication address.
// This makes sure invalid addresses are re-calculated on every run instead of just the first one.
if (!String(fs.readFileSync('./src/data/ens.ts')).includes("'null'")) return
}
const client = createPublicClient({ chain: mainnet, transport: http(process.env.NEXT_PUBLIC_RPC_URL) })
const publicationAddress = await client.getEnsAddress({ name: normalize(process.env.NEXT_PUBLIC_AUTHOR_ENS) })
fs.writeFileSync('./src/data/ens.ts', `export const contributorAddresses = ['${publicationAddress}']\n`)
}
================================================
FILE: src/app/[digest]/page.tsx
================================================
import { unified } from 'unified'
import rehypeRaw from 'rehype-raw'
import strip from 'strip-markdown'
import remarkParse from 'remark-parse'
import { getEntry } from '@/data/entries'
import { notFound } from 'next/navigation'
import { uriTransformer } from '@/utils/url'
import { format as timeago } from 'timeago.js'
import remarkStringify from 'remark-stringify'
import { formatAddress } from '@/utils/address'
import getPublication from '@/data/publication'
import { SetImageSizes } from '@/context/image_sizes'
import highlightCode from '@/utils/highlightMarkdown'
import type { Metadata, ResolvingMetadata } from 'next'
import ReactMarkdown, { Components } from 'react-markdown'
import { Image, LinkOrEmbed, BlockQuote, Block } from '@/utils/markdown'
export const revalidate = 1 * 60 * 60 // refresh article contents every hour
const components: Components = {
img: Image,
a: LinkOrEmbed,
blockquote: BlockQuote,
p: Block,
}
type Props = {
params: { digest: string }
}
export async function generateMetadata({ params: { digest } }: Props, parent: ResolvingMetadata): Promise<Metadata> {
const [publication, entry] = await Promise.all([getPublication(), getEntry(digest)])
if (!entry) return notFound()
// If there's an image we want to use the second paragraph as the description instead of the first one.
// We'll also strip the markdown from the description (to avoid things like links showing up) and trim the newline at the end
const description = String(
await unified()
.use(remarkParse)
.use(strip)
.use(remarkStringify)
.process(entry.body.split('\n\n')[entry.cover_image ? 1 : 0])
).slice(0, -1)
return {
description,
title: `${entry.title} — Mirror`,
openGraph: {
description,
title: `${entry.title} — Mirror`,
images: entry.cover_image ? [{ url: entry.cover_image }] : [],
},
twitter: {
description,
card: 'summary_large_image',
title: `${entry.title} — Mirror`,
images: entry.cover_image ? [{ url: entry.cover_image }] : [],
},
}
}
const Article = async ({ params: { digest } }: Props) => {
const [publication, entry] = await Promise.all([getPublication(), getEntry(digest)])
if (!entry) return notFound()
const body = String(
await unified()
.use(remarkParse) // Parse markdown
.use(highlightCode, { theme: publication.theme.colorMode == 'DARK' ? 'dark' : 'light' }) // Highlight code
.use(remarkStringify) // Serialize markdown
.process(entry.body)
)
return (
<article>
<header>
<h1 className="text-gray-900 dark:text-gray-200 text-3xl sm:text-5xl font-bold">{entry.title}</h1>
<div className="flex flex-wrap items-center my-4 gap-x-4 gap-y-2 max-w-xl">
{publication.members.map(contributor => (
<div className="flex items-center" key={contributor.address}>
<img className="rounded-full w-10 h-10" src={contributor.avatarURL} />
<div className="flex items-center tracking-normal text-gray-800 dark:text-gray-400">
<div className="flex items-center p-1 leading-5">
<p className="mr-2 font-medium">{contributor.displayName}</p>
<a
className="bg-gray-100 dark:bg-gray-800 rounded-full px-1.5 font-medium text-sm text-gray-400 dark:text-gray-500"
href={`https://etherscan.io/address/${contributor.address}`}
target="_blank"
rel="noreferrer"
>
{formatAddress(contributor.address)}
</a>
</div>
</div>
</div>
))}
<div className="flex items-center">
<time
dateTime={new Date(entry.timestamp * 1000).toISOString()}
className="block bg-gray-100 dark:bg-gray-800 rounded-full px-2 py-0.5 font-medium text-sm text-gray-400 dark:text-gray-500"
>
{timeago(entry.timestamp * 1000)}
</time>
</div>
</div>
</header>
<div className="prose lg:prose-lg dark:prose-dark pb-10">
<SetImageSizes sizes={entry.image_sizes}>
<ReactMarkdown components={components} urlTransform={uriTransformer} rehypePlugins={[rehypeRaw]}>
{body}
</ReactMarkdown>
</SetImageSizes>
</div>
{publication.mailingListURL && (
<div className="flex items-center justify-center mb-10">
<a
href={publication.mailingListURL}
target="_blank"
rel="noreferrer"
className="bg-blue-100 dark:bg-yellow-400 dark:bg-opacity-20 font-medium text-blue-500 dark:text-yellow-300 rounded-lg p-4 hover:ring-4 ring-blue-100 dark:ring-yellow-400 dark:ring-opacity-20 transition duration-300 text-base shadow-xs hover:shadow-xs sm:text-lg sm:px-10 inline-flex items-center space-x-2"
>
<svg
className="w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span>Subscribe</span>
</a>
</div>
)}
<footer className="border dark:border-gray-800 rounded-lg divide-y dark:divide-gray-800 font-mono max-w-xl mx-auto">
{entry.transaction && (
<a
href={`https://viewblock.io/arweave/tx/${entry.transaction}`}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between text-gray-400 dark:text-gray-500 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<div className="flex items-center">
<p className="uppercase text-xs mr-1">Arweave TX</p>
<p className="transform text-xs -rotate-45 font-sans">→</p>
</div>
<p className="text-xs">{formatAddress(entry.transaction)}</p>
</a>
)}
<a
href={`https://etherscan.io/address/${entry.contributor}`}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between text-gray-400 dark:text-gray-500 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<div className="flex items-center">
<p className="uppercase text-xs mr-1">Ethereum Address</p>
<p className="transform text-xs -rotate-45 font-sans">→</p>
</div>
<p className="text-xs">{formatAddress(entry.contributor)}</p>
</a>
<div className="flex items-center justify-between text-gray-400 dark:text-gray-500 px-4 py-3">
<p className="uppercase text-xs">Content Digest</p>
<p className="text-xs">{formatAddress(entry.digest)}</p>
</div>
</footer>
</article>
)
}
export default Article
================================================
FILE: src/app/api/link-preview/route.ts
================================================
import metascraper from 'metascraper'
import urlFetcher from 'metascraper-url'
import langFetcher from 'metascraper-lang'
import dateFetcher from 'metascraper-date'
import logoFetcher from 'metascraper-logo'
import titleFetcher from 'metascraper-title'
import imageFetcher from 'metascraper-image'
import authorFetcher from 'metascraper-author'
import { Metadata } from '@/types/link-preview'
import clearbitFetcher from 'metascraper-clearbit'
import { calculateImageSize } from '@/utils/images'
import publisherFetcher from 'metascraper-publisher'
import faviconFetcher from 'metascraper-logo-favicon'
import { NextRequest, NextResponse } from 'next/server'
import descriptionFetcher from 'metascraper-description'
const scraper = metascraper([
titleFetcher(),
descriptionFetcher(),
langFetcher(),
authorFetcher(),
publisherFetcher(),
imageFetcher(),
dateFetcher(),
urlFetcher(),
logoFetcher(),
clearbitFetcher(),
faviconFetcher(),
])
export async function GET(request: NextRequest): Promise<NextResponse> {
const url = request.nextUrl.searchParams.get('url')
if (!url) return NextResponse.json({ error: 'Missing url parameter' }, { status: 422 })
return NextResponse.json(await getMeta(url))
}
const getMeta = async (url: string): Promise<Metadata> => {
const html = await fetch(url).then(res => res.text())
const meta = await scraper({ html, url })
await Promise.all(
['image', 'logo'].map(async key => {
if (!meta[key]) return
// @ts-ignore
meta[key] = {
url: meta[key],
...(await calculateImageSize(meta[key]!)),
}
})
)
return meta as Metadata
}
================================================
FILE: src/app/api/nft-data/route.ts
================================================
import ERC721 from '@/data/ERC721'
import { NextRequest, NextResponse } from 'next/server'
import { mainnet, base, optimism, zora, polygon } from 'viem/chains'
import { createPublicClient, http, getContract, extractChain } from 'viem'
export async function GET(request: NextRequest): Promise<NextResponse> {
const chainId = request.nextUrl.searchParams.get('chain')
const tokenId = request.nextUrl.searchParams.get('tokenId') as bigint | null
const contractAddress = request.nextUrl.searchParams.get('contract') as `0x${string}` | null
if (!chainId || !contractAddress || !tokenId) {
return NextResponse.json({ error: 'Missing chain, contract or tokendId parameters' }, { status: 400 })
}
const chain = extractChain({
// @ts-ignore
id: parseInt(chainId),
chains: [mainnet, base, optimism, zora, polygon],
})
const contract = getContract({
abi: ERC721,
address: contractAddress,
client: createPublicClient({ chain, transport: http() }),
})
const token = await fetch(buildURL(await contract.read.tokenURI([tokenId]))).then(res => res.json())
return NextResponse.json({
...token,
tokenId,
contractAddress,
image: buildURL(token.image),
mimeType: await getMimeType(buildURL(token.image)),
})
}
const buildURL = (rawURL: string): string => {
const url = new URL(rawURL)
switch (url.protocol) {
case 'ipfs:':
return `https://ipfs.io/ipfs/${url.hostname}${url.pathname}`
case 'ar:':
case 'arweave:':
return `https://arweave.net/${url.hostname}${url.pathname}`
default:
return rawURL
}
}
const getMimeType = async (url: string): Promise<string | null> => {
return fetch(url, { method: 'HEAD' }).then(res => res.headers.get('Content-Type'))
}
================================================
FILE: src/app/error.tsx
================================================
'use client'
import Link from 'next/link'
import { FC, useEffect } from 'react'
const InternalError: FC = ({ error }: { error: Error & { digest?: string } }) => {
useEffect(() => {
console.error(error)
}, [error])
return (
<div className="absolute inset-0 flex flex-col space-y-10 items-center justify-center h-full">
<p className="text-gray-800 text-3xl dark:text-gray-300 font-semibold">Internal Error</p>
<Link
href="/"
className="bg-blue-100 dark:bg-fuchsia-400 dark:bg-opacity-20 font-medium text-blue-500 dark:text-fuchsia-300 rounded-lg px-4 py-3 hover:ring-4 ring-blue-50 dark:ring-fuchsia-400 dark:ring-opacity-20 transition duration-300 text-base shadow-xs hover:shadow-xs sm:text-lg"
>
Return Home
</Link>
</div>
)
}
export default InternalError
================================================
FILE: src/app/feed.xml/route.ts
================================================
import RSS from 'rss'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import getEntries from '@/data/entries'
import remark2rehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import getPublication from '@/data/publication'
import { NextRequest, NextResponse } from 'next/server'
import highlightMarkdown from '@/utils/highlightMarkdown'
export async function GET(request: NextRequest): Promise<NextResponse> {
const host = request.headers.get('host')
let [publication, entries] = await Promise.all([getPublication(), getEntries()])
const markdownRenderer = unified()
.use(remarkParse) // Parse markdown
.use(highlightMarkdown, { theme: 'light' }) // Highlight code, doesn't work on production yet
.use(remark2rehype, { allowDangerousHtml: true }) // Convert to HTML
.use(rehypeStringify) // Serialize HTML
const feed = new RSS({
title: publication.displayName,
description: publication.description || 'A Mirror publication',
image_url: publication?.headerImage?.url,
managingEditor: publication.members.length == 1 ? publication.members[0].displayName : publication.displayName,
webMaster: 'MirrorXYZ',
ttl: 1 * 60, // cache for an hour
site_url: `https://${host}/`,
generator: 'RSS for Mirror, by Miguel Piedrafita',
feed_url: `https://${host}/feed.xml`,
})
await Promise.all(
entries.map(async entry => {
const body = String(await markdownRenderer.process(entry.body))
feed.item({
title: entry.title,
guid: entry.digest,
url: `https://${host}/${entry.digest}`,
date: new Date(entry.timestamp * 1000).toISOString(),
description: body,
})
})
)
return new NextResponse(feed.xml({ indent: true }), {
headers: {
'Content-Type': 'application/rss+xml',
},
})
}
================================================
FILE: src/app/layout.tsx
================================================
import '@/styles/style.css'
import Link from 'next/link'
import { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { SetTheme } from '@/context/theme'
import getPublication from '@/data/publication'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' })
export async function generateMetadata(): Promise<Metadata> {
const publication = await getPublication()
return {
title: `${publication.displayName} — Mirror`,
description: publication.description,
openGraph: {
title: `${publication.displayName} — Mirror`,
description: publication.description,
images: [{ url: publication.avatarURL }],
},
twitter: {
card: 'summary',
description: publication.description,
images: [{ url: publication.avatarURL }],
title: `${publication.displayName} — Mirror`,
},
alternates: {
types: {
'application/rss+xml': '/feed.xml',
},
},
}
}
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
const publication = await getPublication()
return (
<html lang="en" className={inter.variable}>
<body className={publication.theme.colorMode == 'DARK' ? 'dark' : ''}>
<div className="font-sans dark:bg-gray-900 min-h-screen">
<header className="p-4">
<Link href="/" className="flex items-center space-x-4">
<img
src={publication.avatarURL}
className="ring-1 ring-gray-200 dark:ring-gray-700 rounded-full w-14 h-14 hover:ring-4 transition ease-in-out"
/>
<div>
<h1 className="text-xl font-medium dark:text-gray-200">{publication.displayName}</h1>
<div className="flex items-center space-x-2">
<span className="rounded-full bg-gray-200 dark:bg-gray-800 px-1 text-sm text-gray-500 font-medium">
ENS
</span>
<p className="text-gray-400 dark:text-gray-600 pb-0.5">{publication.ens}</p>
</div>
</div>
</Link>
</header>
<main className="max-w-3xl mx-auto py-16 px-4 sm:px-0">
<SetTheme theme={publication.theme}>{children}</SetTheme>
</main>
</div>
</body>
</html>
)
}
export default RootLayout
================================================
FILE: src/app/not-found.tsx
================================================
import { FC } from 'react'
import getPublication from '@/data/publication'
import LinkButton from '@/components/LinkButton'
const PageNotFound: FC = async () => {
const publication = await getPublication()
return (
<div className="absolute inset-0 flex flex-col space-y-10 items-center justify-center h-full">
<p className="text-gray-800 text-3xl dark:text-gray-300 font-semibold">Page Not Found</p>
<LinkButton href="/" accentColor={publication.theme.accent}>
Return Home
</LinkButton>
</div>
)
}
export default PageNotFound
================================================
FILE: src/app/page.tsx
================================================
import Link from 'next/link'
import getEntries from '@/data/entries'
import { getExcerpt } from '@/utils/excerpt'
import { format as timeago } from 'timeago.js'
import getPublication from '@/data/publication'
import LinkButton from '@/components/LinkButton'
import { SetImageSizes } from '@/context/image_sizes'
import ReactMarkdown, { Components } from 'react-markdown'
import { BlockQuote, LinkOrEmbed, Block, Image } from '@/utils/markdown'
export const revalidate = 5 * 60 // refresh page index every 5 minutes
const components: Components = {
img: Image,
a: LinkOrEmbed,
blockquote: BlockQuote,
p: Block,
}
const Index = async () => {
const [publication, entries] = await Promise.all([getPublication(), getEntries()])
return (
<div className="space-y-32 mb-10">
{entries.map(entry => (
<article key={entry.digest}>
<Link
href={`/${entry.transaction}`}
className="text-gray-900 dark:text-gray-200 text-3xl sm:text-5xl font-bold"
>
{entry.title}
</Link>
<div className="flex flex-wrap items-center my-4 gap-x-4 gap-y-2 max-w-xl">
{publication.members.map(contributor => (
<div className="flex items-center" key={contributor.address}>
<img className="rounded-full w-10 h-10" src={contributor.avatarURL} />
<div className="flex items-center tracking-normal text-gray-800 dark:text-gray-400">
<div className="flex items-center p-1 leading-5">
<p className="mr-2 font-medium">{contributor.displayName}</p>
<a
className="bg-gray-100 dark:bg-gray-800 rounded-full px-1.5 font-medium text-sm text-gray-400 dark:text-gray-500"
href={`https://etherscan.io/address/${contributor.address}`}
target="_blank"
rel="noreferrer"
>
{contributor.address.substr(0, 6)}
</a>
</div>
</div>
</div>
))}
<div className="flex items-center">
<time
dateTime={new Date(entry.timestamp * 1000).toISOString()}
className="block bg-gray-100 dark:bg-gray-800 rounded-full px-2 py-0.5 font-medium text-sm text-gray-400 dark:text-gray-500"
>
{timeago(entry.timestamp * 1000)}
</time>
</div>
</div>
<div className="prose lg:prose-lg dark:prose-dark mb-8">
<SetImageSizes sizes={entry.image_sizes}>
<ReactMarkdown components={components}>{getExcerpt(entry.body)}</ReactMarkdown>
</SetImageSizes>
</div>
{entry.body.split('\n\n').length > 4 && (
<LinkButton href={`/${entry.transaction}`} accentColor={publication.theme.accent}>
Continue Reading
</LinkButton>
)}
</article>
))}
{entries.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center h-full">
<p className="text-gray-400 dark:text-gray-600 font-medium">Patience</p>
</div>
)}
</div>
)
}
export default Index
================================================
FILE: src/app/post/[slug]/route.ts
================================================
import getEntries from '@/data/entries'
import { notFound, redirect } from 'next/navigation'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(
request: NextRequest,
{ params: { slug } }: { params: { slug: string } }
): Promise<NextResponse> {
const entry = (await getEntries()).filter(entry => entry.slug === slug)?.[0]
if (!entry) return notFound()
return redirect(`/${entry.digest}`)
}
================================================
FILE: src/app/posts.json/route.ts
================================================
import getEntries from '@/data/entries'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest): Promise<NextResponse> {
const host = request.headers.get('host')
const entries = (await getEntries()).map(entry => ({ ...entry, url: `https://${host}/post/${entry.slug}` }))
return NextResponse.json(entries)
}
================================================
FILE: src/components/EntryLink.tsx
================================================
'use client'
import Link from 'next/link'
import { useTheme } from '@/context/theme'
import { FC, PropsWithChildren } from 'react'
type Props = PropsWithChildren<{ href: string; className?: string }>
const EntryLink: FC<Props> = ({ href, children, className }) => {
const { accent } = useTheme()
className = className ? className : getClass(accent)
if (href.startsWith('/') || (typeof window !== 'undefined' && href.startsWith(window.location.origin))) {
return (
<Link href={href} className={className}>
{children}
</Link>
)
}
return (
// eslint-disable-next-line react/jsx-no-target-blank
<a href={href} target={href.startsWith('#') ? '' : '_blank'} rel="noopener" className={className}>
{children}
</a>
)
}
const getClass = (accentColor: string): string => {
switch (accentColor.toLowerCase()) {
case 'purple':
return '!text-fuchsia-400'
case 'pink':
return '!text-red-500'
case 'red':
return '!text-red-500'
case 'orange':
return '!text-orange-400'
case 'yellow':
return '!text-yellow-400'
case 'teal':
return '!text-cyan-400'
case 'blue':
return '!text-blue-500'
case 'indigo':
return '!text-indigo-400'
case 'green':
return '!text-emerald-400'
case 'foreground':
return '!text-white'
default:
return '!text-blue-400'
}
}
export default EntryLink
================================================
FILE: src/components/LinkButton.tsx
================================================
import Link, { LinkProps } from 'next/link'
import { FC, PropsWithChildren } from 'react'
type Props = {
accentColor: string
} & LinkProps
const LinkButton: FC<PropsWithChildren<Props>> = ({ href, children, accentColor, ...props }) => {
return (
<Link
{...props}
href={href}
className={`${getClasses(
accentColor
)} dark:bg-opacity-20 font-medium rounded-lg p-4 hover:ring-4 dark:ring-opacity-20 transition duration-300 text-base shadow-xs hover:shadow-xs sm:text-lg sm:px-10`}
>
{children}
</Link>
)
}
const getClasses = (accentColor: string): string => {
switch (accentColor.toLowerCase()) {
case 'yellow':
return 'bg-yellow-100 dark:bg-yellow-400 text-yellow-500 dark:text-yellow-300 ring-yellow-100 dark:ring-yellow-400'
case 'purple':
return 'bg-fuchsia-100 dark:bg-fuchsia-400 text-fuchsia-500 dark:text-fuchsia-300 ring-fuchsia-100 dark:ring-fuchsia-400'
case 'pink':
case 'red':
return 'bg-red-100 dark:bg-red-400 text-red-500 dark:text-red-300 ring-red-100 dark:ring-red-400'
case 'orange':
return 'bg-orange-100 dark:bg-orange-400 text-orange-500 dark:text-orange-300 ring-orange-100 dark:ring-orange-400'
case 'teal':
return 'bg-cyan-100 dark:bg-cyan-400 text-cyan-500 dark:text-cyan-300 ring-cyan-100 dark:ring-cyan-400'
case 'indigo':
return 'bg-indigo-100 dark:bg-indigo-400 text-indigo-500 dark:text-indigo-300 ring-indigo-100 dark:ring-indigo-400'
case 'green':
return 'bg-emerald-100 dark:bg-emerald-400 text-emerald-500 dark:text-emerald-300 ring-emerald-100 dark:ring-emerald-400'
case 'foreground':
return 'bg-white text-white ring-white'
case 'blue':
default:
return 'bg-blue-100 dark:bg-blue-400 text-blue-500 dark:text-blue-300 ring-blue-100 dark:ring-blue-400'
}
}
export default LinkButton
================================================
FILE: src/components/NFT.tsx
================================================
'use client'
import useSWR from 'swr'
import { useEffect, useRef, useState } from 'react'
const NFT = ({ data: { name, image, contractAddress, tokenId, mimeType } }) => {
return (
<div
className="shadow dark:shadow-none dark:bg-black rounded-lg max-w-lg mx-auto not-prose overflow-hidden group"
data-nft
>
<section className="flex items-center justify-between py-5 px-6">
<p className="font-semibold text-gray-700 dark:text-gray-300 text-lg">{name}</p>
<a
className="bg-gray-100 dark:bg-gray-900 !rounded-full py-2 px-2.5 block"
href={`https://etherscan.io/nft/${contractAddress}/${tokenId}`}
target="_blank"
rel="noreferrer"
>
<svg viewBox="0 0 177 68" fill="currentColor" className="w-6 text-gray-500 dark:text-gray-600">
<path
d="M17.405 64.127l5.324-24.978c1.127-5.098 2.15-11.419 2.56-15.802.819 3.058 2.662 8.767 4.3 13.05l10.75 28.24c1.024 2.548 1.331 2.956 2.355 3.262.41.081.492.098 1.556.101h11.606c3.232-.02 3.365-.3 4.141-3.873L72.693 4.894c.205-1.02.307-1.937.307-2.345 0-.816-.307-1.326-.921-1.937C71.464.102 71.157 0 68.597 0h-8.19c-3.89 0-3.993.102-4.812 3.874L50.68 26.813c-.921 4.588-2.355 13.05-2.764 17.637-1.024-3.568-2.867-9.073-4.403-13.05L33.07 3.365C32.046.816 31.842.408 30.715.102a16.23 16.23 0 00-.14-.028l-.126-.022c-.246-.039-.491-.049-1.29-.051L28.448 0H17.712c-3.788 0-3.89.102-4.71 3.874L.308 63.107C.102 64.127 0 65.044 0 65.452c0 .815.307 1.325.921 1.937.588.487.895.602 3.16.611h9.096c3.32-.02 3.452-.3 4.228-3.873zm65.413 0l4.812-22.328h25.35c3.233-.02 3.366-.3 4.142-3.874l1.126-5.097c.41-1.733.41-1.835.41-2.345 0-.816-.308-1.325-.922-1.937-.614-.51-.922-.612-3.482-.612H90.6l2.765-13.151h29.184c3.789 0 3.891-.102 4.71-3.772l1.331-6.117c.362-1.53.404-1.789.41-2.179v-.166c0-.816-.307-1.326-.922-1.937-.614-.51-.921-.612-3.481-.612H82.715c-3.789 0-3.891.102-4.71 3.772L65.307 63.107c-.205 1.02-.307 1.937-.307 2.345 0 .815.307 1.325.922 1.937.587.487.894.602 3.16.611h9.595c3.334-.02 3.46-.295 4.14-3.873zm61.259 0l10.564-49.344h16c3.795 0 3.897-.102 4.718-3.772l1.333-6.117c.205-1.122.308-1.836.308-2.345 0-.816-.308-1.326-.923-1.937-.615-.51-.923-.612-3.487-.612h-49.231c-3.795 0-3.897.102-4.718 3.772l-1.333 6.117c-.205 1.02-.308 1.937-.308 2.345 0 .816.308 1.325.923 1.937.615.51.923.612 3.487.612h15.385l-10.257 48.324c-.307 1.427-.41 1.835-.41 2.345 0 .815.308 1.325.923 1.937.589.487.896.602 3.166.611h9.624c3.326-.02 3.459-.3 4.236-3.873z"
fill="currentColor"
fillRule="nonzero"
/>
</svg>
</a>
</section>
{image && mimeType && (
<section className="nfte__media">
<Media media={image} mediaMimeType={mimeType} />
</section>
)}
</div>
)
}
const Loading = () => (
<div className="shadow dark:shadow-none dark:bg-black rounded-lg max-w-lg mx-auto">
<div className="py-16 px-8 flex items-center justify-center">
<svg
className="w-6 h-6 animate-spin text-black dark:text-white text-opacity-40"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle fill="none" strokeWidth="2" strokeLinecap="round" strokeDasharray="32" cx="12" cy="12" r="10" />
<circle fill="none" strokeWidth="2" strokeLinecap="round" cx="12" cy="12" r="10" opacity="0.25" />
</svg>
</div>
</div>
)
const TextMedia = ({ media }) => {
const [content, setContent] = useState<string | null>(null)
useEffect(() => {
fetch(media)
.then(r => r.text())
.then(r => setContent(r))
}, [])
return <div>{content}</div>
}
const VideoMedia = ({ media }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [isMuted, setMuted] = useState(true)
useEffect(() => {
if (!videoRef.current) return
videoRef.current.muted = isMuted
}, [isMuted])
return (
<div className="relative">
<video ref={videoRef} muted autoPlay={true} controls={false} loop playsInline>
<source src={media} />
</video>
<button
onClick={() => setMuted(state => !state)}
className="absolute bottom-2 right-2 bg-white bg-opacity-10 !rounded-full p-2 text-white/30 transition group-hover:text-white/80 focus:outline-none"
style={{ backdropFilter: 'blur(2px) brightness(1.3)' }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="transition duration-200 w-6 h-6"
>
{isMuted ? (
<>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
clipRule="evenodd"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
/>
</>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
/>
)}
</svg>
</button>
</div>
)
}
const AudioMedia = ({ media }) => <audio controls src={media} />
const Media = ({ media, mediaMimeType }) => {
if (mediaMimeType?.includes('text')) return <TextMedia media={media} />
if (mediaMimeType?.includes('video')) return <VideoMedia media={media} />
if (mediaMimeType?.includes('audio')) return <AudioMedia media={media} />
return <img src={media} />
}
const Embed = ({ chainId, contract, tokenId }) => {
const { data, error, isLoading } = useSWR(
() => `/api/nft-data?chain=${chainId}&contract=${contract}&tokenId=${tokenId}`,
url => fetch(url).then(r => r.json())
)
if (error) {
return (
<div className="rounded-md bg-red-50 dark:bg-red-900 dark:bg-opacity-50 p-4 not-prose">
<div className="flex items-center justify-center">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400 dark:text-red-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
></path>
</svg>
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm text-red-700 dark:text-red-400">The NFT embed failed to load</p>
</div>
</div>
</div>
)
}
if (isLoading) return <Loading />
return <NFT data={data} />
}
export default Embed
================================================
FILE: src/components/OpenGraph.tsx
================================================
'use client'
import useSWR from 'swr'
import EntryLink from './EntryLink'
import { FC, PropsWithChildren } from 'react'
import { Metadata } from '@/types/link-preview'
import { LinkIcon } from '@heroicons/react/solid'
const OpenGraph: FC<PropsWithChildren<{ url: string }>> = ({ url, children }) => {
const { data, error, isLoading } = useSWR<Metadata>(`/api/link-preview?url=${url}`, url =>
fetch(url).then(res => res.json())
)
if (error) return <EntryLink href={url}>{children}</EntryLink>
return (
<figure
className={`rounded-lg max-w-lg mx-auto shadow-card bg-white dark:bg-gray-800 opengraph overflow-hidden ${
isLoading ? '' : 'hover:ring-4 transition duration-300 ring-gray-300 dark:ring-gray-700'
}`}
>
{isLoading ? (
<div className="py-16 px-8 flex items-center justify-center">
<svg
className="w-6 h-6 animate-spin text-black dark:text-white text-opacity-40"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="32"
cx="12"
cy="12"
r="10"
></circle>
<circle
fill="none"
strokeWidth="2"
strokeLinecap="round"
cx="12"
cy="12"
r="10"
opacity="0.25"
></circle>
</svg>
</div>
) : (
<EntryLink className="block" href={url}>
<div>
{data!.image && (
<div className="pb-[50%] relative border-b dark:border-gray-700">
<img
alt={data!.title}
src={data!.image.url}
className="object-cover absolute inset-0 h-full w-full"
/>
</div>
)}
<div className="py-5 px-5">
<p className="text-gray-800 dark:text-gray-200 font-medium break-words text-base">
{data!.title}
</p>
<p className="text-gray-400 dark:text-gray-500 text-base !mt-1">{data!.description}</p>
<div className="flex items-center space-x-2 !mt-3">
<LinkIcon className="w-4 h-4 text-gray-400 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-600 text-base">
{new URL(data!.url!).hostname}
</p>
</div>
</div>
</div>
</EntryLink>
)}
</figure>
)
}
export default OpenGraph
================================================
FILE: src/context/image_sizes.tsx
================================================
'use client'
import { createContext, useContext, FC, PropsWithChildren } from 'react'
import { ISizeCalculationResult } from 'image-size/dist/types/interface'
const ImageSizeProvider = createContext<Record<string, ISizeCalculationResult> | null>(null)
ImageSizeProvider.displayName = 'ImageSizeProvider'
export default ImageSizeProvider
export const useImageSizes = (): Record<string, ISizeCalculationResult> => {
return useContext(ImageSizeProvider)!
}
type Props = PropsWithChildren<{ sizes: Record<string, ISizeCalculationResult> }>
export const SetImageSizes: FC<Props> = ({ children, sizes }) => {
return <ImageSizeProvider.Provider value={sizes}>{children}</ImageSizeProvider.Provider>
}
================================================
FILE: src/context/theme.tsx
================================================
'use client'
import { createContext, useContext, FC, PropsWithChildren } from 'react'
const ThemeProvider = createContext<{ colorMode: 'DARK' | 'LIGHT'; accent: string } | null>(null)
ThemeProvider.displayName = 'ThemeProvider'
export default ThemeProvider
export const useTheme = (): { colorMode: 'DARK' | 'LIGHT'; accent: string } => {
return useContext(ThemeProvider)!
}
type Props = PropsWithChildren<{ theme: { colorMode: 'DARK' | 'LIGHT'; accent: string } }>
export const SetTheme: FC<Props> = ({ children, theme }) => {
return <ThemeProvider.Provider value={theme}>{children}</ThemeProvider.Provider>
}
================================================
FILE: src/data/ERC721.ts
================================================
export default [
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: 'address',
name: 'owner',
type: 'address',
},
{
indexed: true,
internalType: 'address',
name: 'approved',
type: 'address',
},
{
indexed: true,
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'Approval',
type: 'event',
},
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: 'address',
name: 'owner',
type: 'address',
},
{
indexed: true,
internalType: 'address',
name: 'operator',
type: 'address',
},
{
indexed: false,
internalType: 'bool',
name: 'approved',
type: 'bool',
},
],
name: 'ApprovalForAll',
type: 'event',
},
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: 'address',
name: 'from',
type: 'address',
},
{
indexed: true,
internalType: 'address',
name: 'to',
type: 'address',
},
{
indexed: true,
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'Transfer',
type: 'event',
},
{
inputs: [
{
internalType: 'address',
name: 'to',
type: 'address',
},
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'approve',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'owner',
type: 'address',
},
],
name: 'balanceOf',
outputs: [
{
internalType: 'uint256',
name: 'balance',
type: 'uint256',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'getApproved',
outputs: [
{
internalType: 'address',
name: 'operator',
type: 'address',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'owner',
type: 'address',
},
{
internalType: 'address',
name: 'operator',
type: 'address',
},
],
name: 'isApprovedForAll',
outputs: [
{
internalType: 'bool',
name: '',
type: 'bool',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [],
name: 'name',
outputs: [
{
internalType: 'string',
name: '',
type: 'string',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'ownerOf',
outputs: [
{
internalType: 'address',
name: 'owner',
type: 'address',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'from',
type: 'address',
},
{
internalType: 'address',
name: 'to',
type: 'address',
},
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'safeTransferFrom',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'from',
type: 'address',
},
{
internalType: 'address',
name: 'to',
type: 'address',
},
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
{
internalType: 'bytes',
name: 'data',
type: 'bytes',
},
],
name: 'safeTransferFrom',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'operator',
type: 'address',
},
{
internalType: 'bool',
name: '_approved',
type: 'bool',
},
],
name: 'setApprovalForAll',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{
internalType: 'bytes4',
name: 'interfaceId',
type: 'bytes4',
},
],
name: 'supportsInterface',
outputs: [
{
internalType: 'bool',
name: '',
type: 'bool',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [],
name: 'symbol',
outputs: [
{
internalType: 'string',
name: '',
type: 'string',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'uint256',
name: 'index',
type: 'uint256',
},
],
name: 'tokenByIndex',
outputs: [
{
internalType: 'uint256',
name: '',
type: 'uint256',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'owner',
type: 'address',
},
{
internalType: 'uint256',
name: 'index',
type: 'uint256',
},
],
name: 'tokenOfOwnerByIndex',
outputs: [
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'tokenURI',
outputs: [
{
internalType: 'string',
name: '',
type: 'string',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [],
name: 'totalSupply',
outputs: [
{
internalType: 'uint256',
name: '',
type: 'uint256',
},
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{
internalType: 'address',
name: 'from',
type: 'address',
},
{
internalType: 'address',
name: 'to',
type: 'address',
},
{
internalType: 'uint256',
name: 'tokenId',
type: 'uint256',
},
],
name: 'transferFrom',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
] as const
================================================
FILE: src/data/ens.ts
================================================
export const contributorAddresses = ['0xE340b00B6B622C136fFA5CFf130eC8edCdDCb39D']
================================================
FILE: src/data/entries.ts
================================================
import slug from 'slug'
import { cache } from 'react'
import arweave from '@/lib/arweave'
import { arweaveQL } from '@/lib/graphql'
import { contributorAddresses } from './ens'
import { calculateSizes } from '@/utils/images'
import fetchTransactions from '@/queries/arweave/fetch-transactions'
import { ISizeCalculationResult } from 'image-size/dist/types/interface'
import fetchSingleTransaction from '@/queries/arweave/fetch-single-transaction'
export type Entry = {
title: string
slug: string
body: string
timestamp: number
digest: string
contributor: string
transaction: string
cover_image: string
image_sizes: Record<string, ISizeCalculationResult>
}
export const getEntryPaths = async () => {
const {
data: {
transactions: { edges },
},
} = await arweaveQL.query({ query: fetchTransactions, variables: { addresses: contributorAddresses } })
return edges
.map(({ node }) => {
const tags = Object.fromEntries(node.tags.map(tag => [tag.name, tag.value]))
return { slug: tags['Original-Content-Digest'], path: node.id, timestamp: node.block.timestamp }
})
.filter(entry => entry.slug && entry.slug !== '')
.reduce((acc, current) => {
const x = acc.findIndex(entry => entry.slug === current.slug)
if (x == -1) return acc.concat([current])
else {
acc[x].timestamp = current.timestamp
return acc
}
}, [])
}
const getEntries = async () => {
const paths = await getEntryPaths()
return (
await Promise.all(
paths.map(async entry =>
formatEntry(
JSON.parse(
(await arweave.transactions.getData(entry.path, { decode: true, string: true })) as string
),
entry.slug,
entry.timestamp
)
)
)
)
.sort((a, b) => b.timestamp - a.timestamp)
.reduce((acc, current) => {
const x = acc.find(entry => entry.slug === current.slug)
if (!x) return acc.concat([current])
else return acc
}, [])
}
export const getEntry = async (digest: string): Promise<Entry | null> => {
const {
data: {
transactions: { edges },
},
} = await arweaveQL.query({ query: fetchSingleTransaction, variables: { digest } })
if (edges.length === 0) return null
const {
0: {
node: { id: transactionId, block },
},
} = edges
return formatEntry(
JSON.parse((await arweave.transactions.getData(transactionId, { decode: true, string: true })) as string),
transactionId,
block?.timestamp ?? Date.now() / 1000,
digest
)
}
const formatEntry = async (entry, transactionId, timestamp, digest?: string): Promise<Entry> => ({
title: entry.content.title,
slug: slug(entry.content.title),
body: entry.content.body,
timestamp: timestamp,
digest: digest ?? entry.originalDigest ?? entry.digest,
contributor: entry.authorship.contributor,
transaction: transactionId,
cover_image:
(entry.content.body.split('\n\n')[0].match(/!\[[^\]]*\]\((.*?)\s*("(?:.*[^"])")?\s*\)/m) || [])?.[1] || null,
image_sizes: await calculateSizes(entry.content.body),
})
export default cache(getEntries)
================================================
FILE: src/data/publication.ts
================================================
import { cache } from 'react'
import { mirrorQL } from '@/lib/graphql'
import getConfig from '@/hooks/getConfig'
import fetchPublication from '@/queries/mirror/fetch-publication'
export type Publication = {
ens: string
domain: string
avatarURL: string
displayName: string
headerImage?: { url: string }
theme: { colorMode: 'DARK' | 'LIGHT'; accent: string }
description: string
mailingListURL?: string
members: Array<{
address: string
avatarURL: string
displayName: string
}>
}
const getPublication = async (): Promise<Publication> => {
const { publicationAddress } = getConfig()
const {
data: { projectFeed: publication },
} = await mirrorQL.query({ query: fetchPublication, variables: { publicationAddress } })
return publication
}
export default cache(getPublication)
================================================
FILE: src/hooks/getConfig.ts
================================================
import { contributorAddresses } from '@/data/ens'
const getConfig = (): { ensDomain: string; publicationAddress: string } => {
return { ensDomain: process.env.NEXT_PUBLIC_AUTHOR_ENS!, publicationAddress: contributorAddresses[0] }
}
export default getConfig
================================================
FILE: src/lib/arweave.ts
================================================
import Arweave from 'arweave'
const arweave = Arweave.init({ host: 'arweave.net', protocol: 'https', port: 443, timeout: 5000 })
export default arweave
================================================
FILE: src/lib/graphql.ts
================================================
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
export const mirrorQL = new ApolloClient({
link: new HttpLink({ uri: 'https://mirror.xyz/api/graphql', fetch, headers: { origin: 'https://mirror.xyz' } }),
cache: new InMemoryCache(),
})
export const arweaveQL = new ApolloClient({
uri: 'https://arweave.net/graphql',
cache: new InMemoryCache(),
})
================================================
FILE: src/queries/arweave/fetch-single-transaction.ts
================================================
import { gql } from '@apollo/client'
export default gql`
query FetchTransaction($digest: String!) {
transactions(
tags: [{ name: "Original-Content-Digest", values: [$digest] }, { name: "App-Name", values: "MirrorXYZ" }]
) {
edges {
node {
id
block {
timestamp
}
}
}
}
}
`
================================================
FILE: src/queries/arweave/fetch-transactions.ts
================================================
import { gql } from '@apollo/client'
export default gql`
query FetchTransactions($addresses: [String!]!) {
transactions(
first: 100
tags: [{ name: "App-Name", values: ["MirrorXYZ"] }, { name: "Contributor", values: $addresses }]
) {
edges {
node {
id
block {
timestamp
}
tags {
name
value
}
}
}
}
}
`
================================================
FILE: src/queries/mirror/fetch-publication.ts
================================================
import { gql } from '@apollo/client'
export default gql`
query PublicationInfo($publicationAddress: String!) {
projectFeed(projectAddress: $publicationAddress) {
displayName
avatarURL
domain
ens
headerImage {
url
}
theme {
colorMode
accent
}
description
mailingListURL
members {
address
displayName
avatarURL
}
}
}
`
================================================
FILE: src/styles/style.css
================================================
@import 'react-medium-image-zoom/dist/styles.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
button[aria-label='Zoom image'],
button[aria-label='Unzoom image'] {
@apply outline-none focus-visible:ring;
}
.nfte--loaded .nfte__media {
aspect-ratio: unset !important;
}
================================================
FILE: src/types/link-preview.ts
================================================
import type { Metadata as MetascraperMetadata } from 'metascraper'
import type { ISizeCalculationResult } from 'image-size/dist/types/interface'
export type Metadata = MetascraperMetadata & {
logo?: { url: string } & ISizeCalculationResult
image?: { url: string } & ISizeCalculationResult
}
================================================
FILE: src/utils/address.ts
================================================
export const formatAddress = (address: string): string => {
if (!address) return ''
const chars = address.split('')
return `${chars.slice(0, 6).join('')}…${chars.slice(-6).join('')}`
}
================================================
FILE: src/utils/embeds.ts
================================================
import routeToBlock from 'react-embed/lib/routeToBlock'
export const shouldEmbed = (url: string): boolean => {
return routeToBlock({}, { url, ...new URL(url) }) !== undefined
}
================================================
FILE: src/utils/excerpt.ts
================================================
export const getExcerpt = (content: string): string => {
let firstParagraphs = content.split('\n\n').slice(0, 4)
if (firstParagraphs[firstParagraphs.length - 1].startsWith('#')) firstParagraphs.pop()
return firstParagraphs.join('\n\n')
}
================================================
FILE: src/utils/highlightMarkdown.ts
================================================
import { visit } from 'unist-util-visit'
import { bundledLanguages, getHighlighter } from 'shiki'
const highlighter = getHighlighter({ langs: Object.keys(bundledLanguages), themes: ['github-dark', 'github-light'] })
export default ({ theme = 'light' }: { theme: 'light' | 'dark' }) =>
async tree => {
await highlighter.then(highlighter => {
visit(tree, 'code', node => {
node.type = 'html'
node.children = undefined
node.value = highlighter
.codeToHtml(node.value, {
lang: node.lang,
theme: theme === 'dark' ? 'github-dark' : 'github-light',
})
.replace(
'<pre class="shiki',
`<pre language="${node.lang}" meta="${node.meta}" class="border dark:border-transparent rounded-lg`
)
})
})
}
================================================
FILE: src/utils/images.ts
================================================
import sizeOf from 'image-size'
import { ISizeCalculationResult } from 'image-size/dist/types/interface'
export const calculateSizes = async (markdown: string): Promise<Record<string, ISizeCalculationResult>> => {
return Object.fromEntries(
await Promise.all(
[...markdown.matchAll(/!\[[^\]]*\]\((?<url>.*?)\s*("(?:.*[^"])")?\s*\)/gm)].map(async match => [
match.groups!.url,
await calculateImageSize(match.groups!.url),
])
)
)
}
export const calculateImageSize = async (url: string): Promise<ISizeCalculationResult> => {
const image = await fetch(url).then(res => res.arrayBuffer())
return sizeOf(new Uint8Array(image))
}
================================================
FILE: src/utils/markdown.tsx
================================================
'use client'
import Embed from 'react-embed'
import NextImage from 'next/image'
import NFT from '@/components/NFT'
import { shouldEmbed } from './embeds'
import getConfig from '@/hooks/getConfig'
import { useTheme } from '@/context/theme'
import Zoom from 'react-medium-image-zoom'
import OpenGraph from '@/components/OpenGraph'
import EntryLink from '@/components/EntryLink'
import { useImageSizes } from '@/context/image_sizes'
export const Image = ({ alt, src }) => {
const { colorMode } = useTheme()
const {
[src]: { width, height },
} = useImageSizes()
return (
<figure>
<Zoom
wrapElement="span"
wrapStyle={{ width: '100%' }}
overlayBgColorStart={colorMode == 'DARK' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)'}
overlayBgColorEnd={colorMode == 'DARK' ? 'rgba(0, 0, 0, 0.95)' : 'rgba(255, 255, 255, 0.95)'}
>
<NextImage width={width} height={height} alt={alt} src={src} />
</Zoom>
{alt && <figcaption>{alt}</figcaption>}
</figure>
)
}
export const LinkOrEmbed = ({ href, children, ...props }) => {
const { colorMode } = useTheme()
const { ensDomain } = getConfig()
const blockSize = props?.node?.blockSize
if (blockSize != 1) return <EntryLink href={href}>{children}</EntryLink>
const parsedURL = new URL(href)
if (parsedURL.protocol === 'nft:') {
const [chainId, contract, tokenId] = parsedURL.pathname.replace(/^\/+/, '').split('/')
return <NFT chainId={chainId} contract={contract} tokenId={tokenId} />
}
if (parsedURL.protocol === 'crowdfund:') {
const [, crowdfundAddress] = href.match(/crowdfund:\/\/(\w*)/m)
return <EntryLink href={`https://${ensDomain}.mirror.xyz/crowdfunds/${crowdfundAddress}`}>{children}</EntryLink>
}
if (typeof window !== 'undefined' && shouldEmbed(href)) {
return <Embed url={href} isDark={colorMode === 'DARK'} />
}
return <OpenGraph url={href}>{children}</OpenGraph>
}
const getClass = (accentColor: string): string => {
switch (accentColor.toLowerCase()) {
case 'purple':
return '!border-fuchsia-400'
case 'pink':
return '!border-red-500'
case 'red':
return '!border-red-500'
case 'orange':
return '!border-orange-400'
case 'yellow':
return '!border-yellow-400'
case 'teal':
return '!border-cyan-400'
case 'blue':
return '!border-blue-500'
case 'indigo':
return '!border-indigo-400'
case 'green':
return '!border-emerald-400'
case 'foreground':
return '!border-white'
default:
return '!border-blue-400'
}
}
export const BlockQuote = ({ children }) => {
const { accent } = useTheme()
return <blockquote className={getClass(accent)}>{children}</blockquote>
}
export const Block = ({ children }) => {
children = Array.isArray(children) ? children : [children]
const blockAwareChildren = children.map(child => {
if (child?.props?.node) child.props.node.blockSize = children.length
return child
})
return <p>{blockAwareChildren}</p>
}
================================================
FILE: src/utils/url.ts
================================================
const allowedLinkProtocols = ['http', 'https', 'mailto', 'tel', 'crowdfund', 'nft']
export const uriTransformer = (uri: string): string => {
const url = (uri || '').trim()
const first = url.charAt(0)
if (first === '#' || first === '/') {
return url
}
const colon = url.indexOf(':')
if (colon === -1) {
return url
}
const length = allowedLinkProtocols.length
let index = -1
while (++index < length) {
const protocol = allowedLinkProtocols[index]
if (colon === protocol.length && url.slice(0, protocol.length).toLowerCase() === protocol) {
return url
}
}
index = url.indexOf('?')
if (index !== -1 && colon > index) {
return url
}
index = url.indexOf('#')
if (index !== -1 && colon > index) {
return url
}
return '#'
}
================================================
FILE: tailwind.config.js
================================================
import colors from 'tailwindcss/colors'
import defaultTheme from 'tailwindcss/defaultTheme'
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
gray: colors.neutral,
fuchsia: colors.fuchsia,
orange: colors.orange,
cyan: colors.cyan,
emerald: colors.emerald,
},
fontFamily: {
sans: ['var(--font-inter)', ...defaultTheme.fontFamily.sans],
mono: ['iAWriter Mono', ...defaultTheme.fontFamily.mono],
},
boxShadow: {
card: '0 0 0.5rem rgba(0, 0, 0, 0.075)',
},
typography: theme => ({
DEFAULT: {
css: {
whiteSpace: 'pre-wrap',
'> *, blockquote > *': {
marginTop: '0 !important',
marginBottom: '0 !important',
},
figcaption: {
textAlign: 'center',
},
strong: {
fontWeight: theme('fontWeight.medium'),
},
img: {
marginTop: null,
marginBottom: null,
borderRadius: theme('borderRadius.lg'),
},
blockquote: {
display: 'flex',
fontStyle: null,
borderLeftWidth: '2px',
borderColor: theme('colors.blue.600'),
fontWeight: theme('fontWeight.normal'),
},
hr: {
borderTopWidth: '2px',
maxWidth: '12rem',
marginLeft: 'auto',
marginRight: 'auto',
},
'blockquote p:first-of-type::before, blockquote p:last-of-type::after': {
content: 'unset !important',
},
a: {
color: theme('colors.blue.600'),
wordWrap: 'break-word',
fontWeight: theme('fontWeight.normal'),
textDecoration: 'none',
textUnderlineOffset: '0.2em',
'&:hover': {
textDecoration: 'underline',
},
},
'ol > li::before': {
fontFamily: theme('fontFamily.mono').join(', '),
color: theme('colors.gray.400'),
},
'ul > li::before': {
backgroundColor: theme('colors.gray.400'),
},
'.twitter-tweet': {
marginLeft: 'auto',
marginRight: 'auto',
},
code: {
fontWeight: theme('fontWeight.normal'),
background: theme('colors.gray.200'),
color: theme('colors.gray.600'),
borderRadius: theme('borderRadius.lg'),
padding: `0.125rem ${theme('padding.1')}`,
'&::before, &::after': {
content: 'unset !important',
},
},
'[data-nft] *, .opengraph *, .opengraph *:hover': {
margin: '0',
textDecoration: 'none',
borderRadius: 'unset',
},
},
},
dark: {
css: {
color: theme('colors.gray.300'),
strong: {
color: theme('colors.gray.300'),
},
'h1, h2, h3, h4, h5, h6': {
color: theme('colors.gray.200'),
},
blockquote: {
color: theme('colors.gray.300'),
borderColor: theme('colors.yellow.400'),
},
'ol > li::before': {
color: theme('colors.gray.500'),
},
'ul > li::before': {
backgroundColor: theme('colors.gray.600'),
},
':not(pre) > code': {
background: theme('colors.gray.800'),
color: 'unset',
},
hr: {
borderColor: theme('colors.gray.700'),
opacity: '0.6',
},
},
},
lg: {
css: {
'ol > li, ul > li': {
marginTop: '0',
marginBottom: '0',
},
// Image margin is handled by `figure`
img: {
marginTop: null,
marginBottom: null,
},
},
},
}),
},
},
plugins: [require('@tailwindcss/typography')],
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "ESNext",
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/scripts/resolve-ens.js",
"next.config.js"
],
"exclude": ["node_modules"]
}
gitextract_m1cqlie7/ ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── patches/ │ └── re2@1.20.11.patch ├── postcss.config.js ├── scripts/ │ └── resolve-ens.js ├── src/ │ ├── app/ │ │ ├── [digest]/ │ │ │ └── page.tsx │ │ ├── api/ │ │ │ ├── link-preview/ │ │ │ │ └── route.ts │ │ │ └── nft-data/ │ │ │ └── route.ts │ │ ├── error.tsx │ │ ├── feed.xml/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── post/ │ │ │ └── [slug]/ │ │ │ └── route.ts │ │ └── posts.json/ │ │ └── route.ts │ ├── components/ │ │ ├── EntryLink.tsx │ │ ├── LinkButton.tsx │ │ ├── NFT.tsx │ │ └── OpenGraph.tsx │ ├── context/ │ │ ├── image_sizes.tsx │ │ └── theme.tsx │ ├── data/ │ │ ├── ERC721.ts │ │ ├── ens.ts │ │ ├── entries.ts │ │ └── publication.ts │ ├── hooks/ │ │ └── getConfig.ts │ ├── lib/ │ │ ├── arweave.ts │ │ └── graphql.ts │ ├── queries/ │ │ ├── arweave/ │ │ │ ├── fetch-single-transaction.ts │ │ │ └── fetch-transactions.ts │ │ └── mirror/ │ │ └── fetch-publication.ts │ ├── styles/ │ │ └── style.css │ ├── types/ │ │ └── link-preview.ts │ └── utils/ │ ├── address.ts │ ├── embeds.ts │ ├── excerpt.ts │ ├── highlightMarkdown.ts │ ├── images.ts │ ├── markdown.tsx │ └── url.ts ├── tailwind.config.js └── tsconfig.json
SYMBOL INDEX (15 symbols across 14 files)
FILE: src/app/[digest]/page.tsx
type Props (line 27) | type Props = {
function generateMetadata (line 31) | async function generateMetadata({ params: { digest } }: Props, parent: R...
FILE: src/app/api/link-preview/route.ts
function GET (line 31) | async function GET(request: NextRequest): Promise<NextResponse> {
FILE: src/app/api/nft-data/route.ts
function GET (line 6) | async function GET(request: NextRequest): Promise<NextResponse> {
FILE: src/app/feed.xml/route.ts
function GET (line 11) | async function GET(request: NextRequest): Promise<NextResponse> {
FILE: src/app/layout.tsx
function generateMetadata (line 10) | async function generateMetadata(): Promise<Metadata> {
FILE: src/app/post/[slug]/route.ts
function GET (line 5) | async function GET(
FILE: src/app/posts.json/route.ts
function GET (line 4) | async function GET(request: NextRequest): Promise<NextResponse> {
FILE: src/components/EntryLink.tsx
type Props (line 7) | type Props = PropsWithChildren<{ href: string; className?: string }>
FILE: src/components/LinkButton.tsx
type Props (line 4) | type Props = {
FILE: src/context/image_sizes.tsx
type Props (line 15) | type Props = PropsWithChildren<{ sizes: Record<string, ISizeCalculationR...
FILE: src/context/theme.tsx
type Props (line 14) | type Props = PropsWithChildren<{ theme: { colorMode: 'DARK' | 'LIGHT'; a...
FILE: src/data/entries.ts
type Entry (line 11) | type Entry = {
FILE: src/data/publication.ts
type Publication (line 6) | type Publication = {
FILE: src/types/link-preview.ts
type Metadata (line 4) | type Metadata = MetascraperMetadata & {
Condensed preview — 47 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (76K chars).
[
{
"path": ".eslintrc.json",
"chars": 39,
"preview": "{\n\t\"extends\": \"next/core-web-vitals\"\n}\n"
},
{
"path": ".gitignore",
"chars": 417,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "LICENSE",
"chars": 1056,
"preview": "Copyright (c) Miguel Piedrafita\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this so"
},
{
"path": "README.md",
"chars": 3167,
"preview": "# A Next.js-powered frontend for your Mirror blog\n\n> This project mimics the [Mirror](https://mirror.xyz) publication de"
},
{
"path": "next-env.d.ts",
"chars": 201,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "next.config.js",
"chars": 183,
"preview": "import resolveEns from './scripts/resolve-ens.js'\n\nexport default () => {\n\tresolveEns()\n\n\treturn {\n\t\timages: {\n\t\t\tremote"
},
{
"path": "package.json",
"chars": 1982,
"preview": "{\n\t\"name\": \"mirror-next\",\n\t\"version\": \"0.1.0\",\n\t\"private\": true,\n\t\"type\": \"module\",\n\t\"scripts\": {\n\t\t\"dev\": \"next dev\",\n\t"
},
{
"path": "patches/re2@1.20.11.patch",
"chars": 2150,
"preview": "diff --git a/package.json b/package.json\nindex c3bc354aa66946406f62bd7805fc88182039b081..b53fb74d1410881b38b0fcbac2f7ad4"
},
{
"path": "postcss.config.js",
"chars": 74,
"preview": "export default {\n\tplugins: {\n\t\ttailwindcss: {},\n\t\tautoprefixer: {},\n\t},\n}\n"
},
{
"path": "scripts/resolve-ens.js",
"chars": 926,
"preview": "import fs from 'fs'\nimport { normalize } from 'viem/ens'\nimport { mainnet } from 'viem/chains'\nimport { createPublicClie"
},
{
"path": "src/app/[digest]/page.tsx",
"chars": 6665,
"preview": "import { unified } from 'unified'\nimport rehypeRaw from 'rehype-raw'\nimport strip from 'strip-markdown'\nimport remarkPar"
},
{
"path": "src/app/api/link-preview/route.ts",
"chars": 1602,
"preview": "import metascraper from 'metascraper'\nimport urlFetcher from 'metascraper-url'\nimport langFetcher from 'metascraper-lang"
},
{
"path": "src/app/api/nft-data/route.ts",
"chars": 1701,
"preview": "import ERC721 from '@/data/ERC721'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { mainnet, base, optim"
},
{
"path": "src/app/error.tsx",
"chars": 795,
"preview": "'use client'\n\nimport Link from 'next/link'\nimport { FC, useEffect } from 'react'\n\nconst InternalError: FC = ({ error }: "
},
{
"path": "src/app/feed.xml/route.ts",
"chars": 1787,
"preview": "import RSS from 'rss'\nimport { unified } from 'unified'\nimport remarkParse from 'remark-parse'\nimport getEntries from '@"
},
{
"path": "src/app/layout.tsx",
"chars": 2157,
"preview": "import '@/styles/style.css'\nimport Link from 'next/link'\nimport { Metadata } from 'next'\nimport { Inter } from 'next/fon"
},
{
"path": "src/app/not-found.tsx",
"chars": 549,
"preview": "import { FC } from 'react'\nimport getPublication from '@/data/publication'\nimport LinkButton from '@/components/LinkButt"
},
{
"path": "src/app/page.tsx",
"chars": 2931,
"preview": "import Link from 'next/link'\nimport getEntries from '@/data/entries'\nimport { getExcerpt } from '@/utils/excerpt'\nimport"
},
{
"path": "src/app/post/[slug]/route.ts",
"chars": 428,
"preview": "import getEntries from '@/data/entries'\nimport { notFound, redirect } from 'next/navigation'\nimport { NextRequest, NextR"
},
{
"path": "src/app/posts.json/route.ts",
"chars": 360,
"preview": "import getEntries from '@/data/entries'\nimport { NextRequest, NextResponse } from 'next/server'\n\nexport async function G"
},
{
"path": "src/components/EntryLink.tsx",
"chars": 1346,
"preview": "'use client'\n\nimport Link from 'next/link'\nimport { useTheme } from '@/context/theme'\nimport { FC, PropsWithChildren } f"
},
{
"path": "src/components/LinkButton.tsx",
"chars": 1799,
"preview": "import Link, { LinkProps } from 'next/link'\nimport { FC, PropsWithChildren } from 'react'\n\ntype Props = {\n\taccentColor: "
},
{
"path": "src/components/NFT.tsx",
"chars": 6802,
"preview": "'use client'\n\nimport useSWR from 'swr'\nimport { useEffect, useRef, useState } from 'react'\n\nconst NFT = ({ data: { name,"
},
{
"path": "src/components/OpenGraph.tsx",
"chars": 2321,
"preview": "'use client'\n\nimport useSWR from 'swr'\nimport EntryLink from './EntryLink'\nimport { FC, PropsWithChildren } from 'react'"
},
{
"path": "src/context/image_sizes.tsx",
"chars": 701,
"preview": "'use client'\n\nimport { createContext, useContext, FC, PropsWithChildren } from 'react'\nimport { ISizeCalculationResult }"
},
{
"path": "src/context/theme.tsx",
"chars": 617,
"preview": "'use client'\n\nimport { createContext, useContext, FC, PropsWithChildren } from 'react'\n\nconst ThemeProvider = createCont"
},
{
"path": "src/data/ERC721.ts",
"chars": 5738,
"preview": "export default [\n\t{\n\t\tanonymous: false,\n\t\tinputs: [\n\t\t\t{\n\t\t\t\tindexed: true,\n\t\t\t\tinternalType: 'address',\n\t\t\t\tname: 'owne"
},
{
"path": "src/data/ens.ts",
"chars": 83,
"preview": "export const contributorAddresses = ['0xE340b00B6B622C136fFA5CFf130eC8edCdDCb39D']\n"
},
{
"path": "src/data/entries.ts",
"chars": 2985,
"preview": "import slug from 'slug'\nimport { cache } from 'react'\nimport arweave from '@/lib/arweave'\nimport { arweaveQL } from '@/l"
},
{
"path": "src/data/publication.ts",
"chars": 797,
"preview": "import { cache } from 'react'\nimport { mirrorQL } from '@/lib/graphql'\nimport getConfig from '@/hooks/getConfig'\nimport "
},
{
"path": "src/hooks/getConfig.ts",
"chars": 260,
"preview": "import { contributorAddresses } from '@/data/ens'\n\nconst getConfig = (): { ensDomain: string; publicationAddress: string"
},
{
"path": "src/lib/arweave.ts",
"chars": 154,
"preview": "import Arweave from 'arweave'\n\nconst arweave = Arweave.init({ host: 'arweave.net', protocol: 'https', port: 443, timeout"
},
{
"path": "src/lib/graphql.ts",
"chars": 375,
"preview": "import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'\n\nexport const mirrorQL = new ApolloClient({\n\tlink"
},
{
"path": "src/queries/arweave/fetch-single-transaction.ts",
"chars": 319,
"preview": "import { gql } from '@apollo/client'\n\nexport default gql`\n\tquery FetchTransaction($digest: String!) {\n\t\ttransactions(\n\t\t"
},
{
"path": "src/queries/arweave/fetch-transactions.ts",
"chars": 373,
"preview": "import { gql } from '@apollo/client'\n\nexport default gql`\n\tquery FetchTransactions($addresses: [String!]!) {\n\t\ttransacti"
},
{
"path": "src/queries/mirror/fetch-publication.ts",
"chars": 384,
"preview": "import { gql } from '@apollo/client'\n\nexport default gql`\n\tquery PublicationInfo($publicationAddress: String!) {\n\t\tproje"
},
{
"path": "src/styles/style.css",
"chars": 289,
"preview": "@import 'react-medium-image-zoom/dist/styles.css';\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbutton[a"
},
{
"path": "src/types/link-preview.ts",
"chars": 294,
"preview": "import type { Metadata as MetascraperMetadata } from 'metascraper'\nimport type { ISizeCalculationResult } from 'image-si"
},
{
"path": "src/utils/address.ts",
"chars": 189,
"preview": "export const formatAddress = (address: string): string => {\n\tif (!address) return ''\n\n\tconst chars = address.split('')\n\t"
},
{
"path": "src/utils/embeds.ts",
"chars": 179,
"preview": "import routeToBlock from 'react-embed/lib/routeToBlock'\n\nexport const shouldEmbed = (url: string): boolean => {\n\treturn "
},
{
"path": "src/utils/excerpt.ts",
"chars": 243,
"preview": "export const getExcerpt = (content: string): string => {\n\tlet firstParagraphs = content.split('\\n\\n').slice(0, 4)\n\n\tif ("
},
{
"path": "src/utils/highlightMarkdown.ts",
"chars": 756,
"preview": "import { visit } from 'unist-util-visit'\nimport { bundledLanguages, getHighlighter } from 'shiki'\n\nconst highlighter = g"
},
{
"path": "src/utils/images.ts",
"chars": 648,
"preview": "import sizeOf from 'image-size'\nimport { ISizeCalculationResult } from 'image-size/dist/types/interface'\n\nexport const c"
},
{
"path": "src/utils/markdown.tsx",
"chars": 2923,
"preview": "'use client'\n\nimport Embed from 'react-embed'\nimport NextImage from 'next/image'\nimport NFT from '@/components/NFT'\nimpo"
},
{
"path": "src/utils/url.ts",
"chars": 760,
"preview": "const allowedLinkProtocols = ['http', 'https', 'mailto', 'tel', 'crowdfund', 'nft']\n\nexport const uriTransformer = (uri:"
},
{
"path": "tailwind.config.js",
"chars": 3549,
"preview": "import colors from 'tailwindcss/colors'\nimport defaultTheme from 'tailwindcss/defaultTheme'\n\nexport default {\n\tcontent: "
},
{
"path": "tsconfig.json",
"chars": 656,
"preview": "{\n\t\"compilerOptions\": {\n\t\t\"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n\t\t\"allowJs\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"strict"
}
]
About this extraction
This page contains the full source code of the m1guelpf/mirror-next GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 47 files (63.2 KB), approximately 20.6k tokens, and a symbol index with 15 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.