Repository: eungyeole/velog-readme-stats Branch: main Commit: 1a587d66efb2 Files: 31 Total size: 21.1 KB Directory structure: gitextract_ok3hzzs_/ ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ └── dependabot.yml ├── .gitignore ├── .nvmrc ├── .vscode/ │ └── settings.json ├── README.md ├── api/ │ └── serverless.ts ├── package.json ├── src/ │ ├── app.ts │ ├── errors/ │ │ └── ApiError.ts │ ├── index.ts │ ├── router.ts │ ├── routes/ │ │ ├── badge.ts │ │ ├── ping.ts │ │ ├── post-list.ts │ │ ├── post.ts │ │ └── redirect.ts │ └── utils/ │ ├── get-recently-post.ts │ ├── get-text-width.ts │ └── velog-client/ │ ├── client.ts │ ├── gql/ │ │ ├── posts.ts │ │ └── read-post.ts │ ├── index.ts │ ├── types.ts │ └── velog-client.ts ├── templates/ │ ├── badge.ejs │ ├── post-list.ejs │ └── post.ejs ├── tsconfig.json └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ * @eungyeole ================================================ FILE: .github/FUNDING.yml ================================================ github: eungyeole ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "npm" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" ================================================ FILE: .gitignore ================================================ .vercel node_modules ================================================ FILE: .nvmrc ================================================ v18.12.1 ================================================ FILE: .vscode/settings.json ================================================ { "editor.tabSize": 2, "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } } ================================================ FILE: README.md ================================================ # velog-readme-stats란? > markdown에서 나의 velog의 정보를 가져올수 있는 도구입니다. # Velog 뱃지 > ?name= 의 값을 변경하세요 ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api/badge?name=eungyeole)](https://velog.io/@eungyeole) ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api/badge?name=eungyeole)](https://velog.io/@eungyeole) # 최신 글 가져오기 > ?name= 의 값을 변경하세요 ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole)](https://github.com/eungyeole/velog-readme-stats) ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole)](https://github.com/eungyeole/velog-readme-stats) ## 특정태그를 가진 최신글 가져오기 > Option : `&tag` ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole&tag=github)](https://github.com/eungyeole/velog-readme-stats) ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole&tag=github)](https://github.com/eungyeole/velog-readme-stats) ## 특정 제목을 가진 글 > Option : `&slug` ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole&slug=Velog-포스트로-Github를-꾸며보자)](https://github.com/eungyeole/velog-readme-stats) ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole&slug=Velog-포스트로-Github를-꾸며보자)](https://github.com/eungyeole/velog-readme-stats) ## 상태카드와 최신글 연결하기 > Option : name, tag > 카드클릭시 최신글로 리다이렉트 합니다. ``` https://velog-readme-stats.vercel.app/api/redirect?name=eungyeole&tag=github ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api?name=eungyeole&tag=github)](https://velog-readme-stats.vercel.app/api/redirect?name=eungyeole&tag=github) # 최신 글 목록 가져오기 (Beta) > ?name= 의 값을 변경하세요 ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api/list?name=eungyeole)](https://velog.io/@eungyeole) ``` [![Velog's GitHub stats](https://velog-readme-stats.vercel.app/api/list?name=eungyeole)](https://velog.io/@eungyeole) ================================================ FILE: api/serverless.ts ================================================ import app from "../src/app"; import { VercelRequest, VercelResponse } from "@vercel/node"; const serverless = async (req: VercelRequest, res: VercelResponse) => { await app.ready(); return app.server.emit("request", req, res); }; export default serverless; ================================================ FILE: package.json ================================================ { "name": "velog-readme-stats", "version": "1.0.0", "description": "당신의 Velog 상태를 깃허브에서 확인하세요.", "main": "./api/index.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only ./src/index.ts", "dev:vercel": "vercel dev --debug", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "MIT", "dependencies": { "@fastify/view": "^9.1.0", "@napi-rs/canvas": "^0.1.44", "ejs": "^3.1.10", "fastify": "^4.28.1", "graphql-request": "^6.1.0" }, "devDependencies": { "@types/ejs": "^3.1.5", "@types/node": "^20.14.9", "@vercel/node": "^3.2.1", "ts-node-dev": "^2.0.0", "typescript": "^5.5.3", "vercel": "^34.3.0" } } ================================================ FILE: src/app.ts ================================================ import Fastify from "fastify"; import { router } from "./router"; import fastifyView from "@fastify/view"; import ejs from "ejs"; import path from "path"; import { cwd } from "process"; const app = Fastify({ logger: false, }); app.register(router, { prefix: "/api", }); app.register(fastifyView, { engine: { ejs, }, templates: path.resolve(cwd(), "templates"), }); export default app; ================================================ FILE: src/errors/ApiError.ts ================================================ export class ApiError extends Error { statusCode: number; constructor(message: string, statusCode: number) { super(message); this.statusCode = statusCode; Error.captureStackTrace(this, this.constructor); } } export class NotFoundError extends ApiError { constructor(message = "Resource not found") { super(message, 404); } } export class InternalServerError extends ApiError { constructor(message = "Internal Server Error") { super(message, 500); } } ================================================ FILE: src/index.ts ================================================ import app from "./app"; app.listen( { port: 3000, }, (err, address) => { if (err) { throw err; } console.log(`Server listening on ${address}`); } ); ================================================ FILE: src/router.ts ================================================ import { FastifyInstance } from "fastify"; import { ping } from "./routes/ping"; import { redirect } from "./routes/redirect"; import { postList } from "./routes/post-list"; import { post } from "./routes/post"; import { badge } from "./routes/badge"; export const router = async (fastify: FastifyInstance) => { fastify.register(ping); fastify.register(redirect); fastify.register(postList); fastify.register(post); fastify.register(badge); }; ================================================ FILE: src/routes/badge.ts ================================================ import { FastifyInstance, FastifyRequest } from "fastify"; import { NotFoundError } from "../errors/ApiError"; import { getTextWidth } from "../utils/get-text-width"; export const badge = async (fastify: FastifyInstance) => { fastify.get( "/badge", async ( req: FastifyRequest<{ Querystring: { name: string }; }>, res ) => { const { name } = req.query; if (!name) { throw new NotFoundError("name is required"); } return res.type("image/svg+xml").view("badge.ejs", { name, getTextWidth, }); } ); }; ================================================ FILE: src/routes/ping.ts ================================================ import { FastifyInstance } from "fastify"; export const ping = async (fastify: FastifyInstance) => { fastify.get("/ping", async (_, res) => { res.send("pong"); }); }; ================================================ FILE: src/routes/post-list.ts ================================================ import { FastifyInstance, FastifyRequest } from "fastify"; import { velogClient } from "../utils/velog-client"; export const postList = async (fastify: FastifyInstance) => { fastify.get( "/list", async ( req: FastifyRequest<{ Querystring: { name: string }; }>, res ) => { const { name } = req.query; try { const { posts } = await velogClient.getPosts({ username: name, limit: 4, }); const data = { username: name, posts: posts.map((post) => ({ user: { username: name }, url_slug: post.url_slug, title: post.title, })), }; return res.type("image/svg+xml").view("post-list.ejs", data); } catch (e) { return res.send(e); } } ); }; ================================================ FILE: src/routes/post.ts ================================================ import { FastifyInstance, FastifyRequest } from "fastify"; import { getTextWidth } from "../utils/get-text-width"; import { NotFoundError } from "../errors/ApiError"; import { getRecentlyPost } from "../utils/get-recently-post"; export const post = async (fastify: FastifyInstance) => { fastify.get( "/", async ( req: FastifyRequest<{ Querystring: { name: string; tag?: string; slug?: string }; }>, res ) => { const { name, slug, tag } = req.query; if (!name) { throw new NotFoundError("name is required"); } try { const post = await getRecentlyPost(name, { tag, slug }); return res.type("image/svg+xml").view("post.ejs", { ...post, getTextWidth, }); } catch (e) { return res.send(e); } } ); }; ================================================ FILE: src/routes/redirect.ts ================================================ import { FastifyInstance, FastifyRequest } from "fastify"; import { NotFoundError } from "../errors/ApiError"; import { velogClient } from "../utils/velog-client"; export const redirect = async (fastify: FastifyInstance) => { fastify.get( "/redirect", async ( req: FastifyRequest<{ Querystring: { name: string; tag: string }; }>, res ) => { const { name, tag } = req.query; try { const { posts } = await velogClient.getPosts({ username: name, tag, limit: 1, }); const [post] = posts; if (!post.url_slug) { throw new NotFoundError("Post not found"); } const url = new URL(`https://velog.io/@${name}/${post.url_slug}`); res.redirect(url.toString(), 301); } catch (e) { return res.send(e); } } ); }; ================================================ FILE: src/utils/get-recently-post.ts ================================================ import { velogClient } from "./velog-client"; interface GetRecentlyPostOptions { slug?: string; tag?: string; } interface RecentlyPost { title: string | null; likes: number | null; short_description: string | null; user: { username: string }; tags: Array; } export const getRecentlyPost = async ( username: string, options?: GetRecentlyPostOptions ) => { const { slug, tag } = options || {}; if (slug) { const { post } = await velogClient.getPostByUrlSlug({ username, url_slug: slug, }); return post as RecentlyPost; } const { posts } = await velogClient.getPosts({ username, limit: 1, tag, }); return posts[0] as RecentlyPost; }; ================================================ FILE: src/utils/get-text-width.ts ================================================ import canvas from "@napi-rs/canvas"; export function getTextWidth(text: string, font: number) { const ctx = canvas.createCanvas(1, 1).getContext("2d"); ctx.font = `${font}px -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji`; const textMetrics = ctx.measureText(text); return textMetrics.width; } ================================================ FILE: src/utils/velog-client/client.ts ================================================ import { GraphQLClient } from 'graphql-request' export const client = new GraphQLClient('https://v3.velog.io/graphql') ================================================ FILE: src/utils/velog-client/gql/posts.ts ================================================ import { gql } from "graphql-request"; export const postsGql = gql` query velogPosts($input: GetPostsInput!) { posts(input: $input) { id title short_description thumbnail user { username profile { thumbnail } } url_slug released_at updated_at comments_count tags likes } } `; ================================================ FILE: src/utils/velog-client/gql/read-post.ts ================================================ import { gql } from "graphql-request"; export const readPostGql = gql` query readPost($input: ReadPostInput!) { post(input: $input) { id title released_at updated_at body short_description is_markdown is_private is_temp thumbnail comments_count url_slug likes is_liked is_followed tags user { id username profile { id display_name thumbnail short_bio profile_links } velog_config { title } } comments { id user { id username profile { id thumbnail } } text replies_count level created_at level deleted } series { id name url_slug series_posts { id post { id title url_slug user { id username } } } } linked_posts { previous { id title url_slug user { id username } } next { id title url_slug user { id username } } } } } `; ================================================ FILE: src/utils/velog-client/index.ts ================================================ export * from "./velog-client"; export * from "./types"; ================================================ FILE: src/utils/velog-client/types.ts ================================================ export interface GetPostsParams { cursor?: number; limit?: number; username: string; tag?: string; } export type GetPostsResponse = { posts: Array<{ id: string; title: string | null; short_description: string | null; thumbnail: string | null; url_slug: string | null; released_at: any | null; updated_at: any; comments_count: number | null; tags: Array; is_private: boolean; likes: number | null; user: { id: string; username: string; profile: { id: string; thumbnail: string | null; display_name: string }; } | null; }>; }; export interface GetPostByUrlSlugParams { username: string; url_slug: string; } export interface GetPostByUrlSlugResponse { post: { id: string; title: string | null; released_at: any | null; updated_at: any; body: string | null; short_description: string | null; is_markdown: boolean | null; is_private: boolean; is_temp: boolean | null; thumbnail: string | null; comments_count: number | null; url_slug: string | null; likes: number | null; is_liked: boolean | null; is_followed: boolean | null; tags: Array; user: { id: string; username: string; profile: { id: string; display_name: string; thumbnail: string | null; short_bio: string; profile_links: Record; }; velog_config: { title: string | null } | null; } | null; comments: Array<{ id: string; text: string | null; replies_count: number | null; level: number | null; created_at: any | null; deleted: boolean | null; user: { id: string; username: string; profile: { id: string; thumbnail: string | null }; } | null; }>; series: { id: string; name: string | null; url_slug: string | null; series_posts: Array<{ id: string; post: { id: string; title: string | null; url_slug: string | null; user: { id: string; username: string } | null; } | null; }> | null; } | null; linked_posts: { previous: { id: string; title: string | null; url_slug: string | null; user: { id: string; username: string } | null; } | null; next: { id: string; title: string | null; url_slug: string | null; user: { id: string; username: string } | null; } | null; } | null; } | null; } ================================================ FILE: src/utils/velog-client/velog-client.ts ================================================ import { client } from "./client"; import { postsGql } from "./gql/posts"; import { GetPostByUrlSlugParams, GetPostByUrlSlugResponse, GetPostsParams, GetPostsResponse, } from "./types"; import { readPostGql } from "./gql/read-post"; class VelogClient { constructor() {} public async getPosts(params: GetPostsParams) { return await client.request(postsGql, { input: params, }); } public async getPostByUrlSlug(params: GetPostByUrlSlugParams) { return await client.request(readPostGql, { input: params, }); } } export const velogClient = new VelogClient(); ================================================ FILE: templates/badge.ejs ================================================ <% const size = getTextWidth(name, 11); const rectWidth = size + 28; %> <%= name %> ================================================ FILE: templates/post-list.ejs ================================================ <%= username %>.log's latest posts <% posts.forEach((post, index) => { %> <%= post.title || "-" %> <% }); %> ================================================ FILE: templates/post.ejs ================================================ <%= user.username %>.log <%= title %> <%= short_description %> <% let prev = 25; %> <% tags.forEach((tag, index) => { const size = getTextWidth(tag, 12) + 20; const pos = prev; if (prev + size > 400) return; prev += size + 6; %> <%= tag %> <% }); %> ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "sourceMap": true, "outDir": "./dist", "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "noImplicitThis": true, "noUnusedParameters": true, "noImplicitReturns": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true }, "exclude": ["node_modules"], "include": ["./src/**/*.ts"] } ================================================ FILE: vercel.json ================================================ { "redirects": [ { "source": "/", "destination": "https://github.com/eungyeole/velog-readme-stats" } ], "rewrites": [ { "source": "/api", "destination": "/api/serverless.ts" }, { "source": "/api/(.*)", "destination": "/api/serverless.ts" } ] }